为什么需要同步

在大多数实际的多线程应用中,两个或两个以上线程需要共享对统一数据的存取。如果两个线程同时修改一个对象,可能就是导致数据不准确,使对象状态混乱,引起程序错误。因此加入同步锁以避免在该线程没有完成操作之前,被其他线程的调用,从而保证了该变量的唯一性和准确性。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 模拟三个人买票,一人买10张
public class BuyTicket {
public static void main(String[] args) {
UnsafeBuyTicket unsafeBuyTicket = new UnsafeBuyTicket();
new Thread(unsafeBuyTicket, "我").start();
new Thread(unsafeBuyTicket, "你").start();
new Thread(unsafeBuyTicket, "黄牛").start();
}


}


class UnsafeBuyTicket implements Runnable {
// 100张票
private int ticketNums = 100;

@Override
// 买十张票
public void run() {
for (int i = 0; i < 10; i++) {
buy();
}
}

/**
* 买票
*/
public void buy() {
// 模拟延时
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 买票
System.out.println(Thread.currentThread().getName() + "拿到:" + ticketNums--);
}
}

如上,运行结果中有时会出现重复购买同一张票的情况,这就是线程不安全的表现。

七种解决办法

  1. 同步方法
    有 synchronized 关键字修饰的方法。 由于 java 的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。
  2. 同步代码块
    有 synchronized 关键字修饰的语句块。 被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。synchronized(this){/*区块*/},它的作用域是当前对象;
  3. 使用特殊域变量(volatile)实现线程同步
  4. 使用重入锁(Lock)实现线程同步
  5. 使用局部变量(ThreadLocal)实现线程同步
  6. 使用阻塞队列(LinkedBlockingQueue)实现线程同步
  7. 使用原子变量(Atomic)实现线程同步

同步方法

同步方法即有 synchronized 关键字修饰的方法。
由于 java 的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。

示例如下:

1
2
3
public synchronized void buy() {
...
}

同步代码块

同步代码块即有 synchronized 关键字修饰的语句块。
被该关键字修饰的语句块会自动被加上内置锁,从而实现同步

代码示例:

1
2
3
4
5
6
7
 public void buy() {
...
// 买票
synchronized (this){
System.out.println(Thread.currentThread().getName() + "拿到:" + ticketNums--);
}
}

注:同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用 synchronized 代码块同步关键代码即可。

synchronized

关键字 synchronized 的作用是实现线程间的同步。它的工作是对同步的代码加锁,使得每一次,只能有一个线程进入同步块,从而保证线程间的安全性。

  1. synchronized 修饰方法时,只要一个线程访问了此对象其中的一个 synchronized 方法,其它线程不能同时访问这个对象中任何一个 synchronized 方法。但是,不同的对象实例的 synchronized 方法是不相干扰的。也就是说,其它线程照样可以同时访问相同类的另一个对象实例中的 synchronized 方法。锁的是当前实例对象。
  2. synchronized 修饰类的静态方法时,对此类的所有实例对象起作用。锁的是当前类的所有实例对象
  3. 无论 synchronized 关键字加在方法上还是对象上,它取得的锁都是对象,而不是把一段代码或函数当作锁――否则同步方法很可能还会被其他线程的对象访问。
  4. synchronized 关键字是不能继承的,也就是说,基类的方法 synchronized f(){} 在继承类中并不自动是 synchronized f(){},而是变成了 f(){}。继承类需要你显式的指定它的某个方法为 synchronized 方法

使用特殊域变量(volatile)实现线程同步

  1. volatile 关键字为域变量的访问提供了一种免锁机制,
  2. 使用 volatile 修饰域相当于告诉虚拟机该域可能会被其他线程更新,
  3. 因此每次使用该域就要重新计算,而不是使用寄存器中的值
  4. volatile 不会提供任何原子操作,它也不能用来修饰 final 类型的变量

例如:
在上面的例子当中,在 ticketNums 前面加上 volatile 修饰

1
private volatile int ticketNums = 100;

你会发现运行结果还是不能避免重复购票的情况。这是因为ticketNums--看起来是一个原子操作,但其实是由读取-修改-写入操作序列组成的组合操作,必须以原子方式执行,而 volatile 不能提供必须的原子特性。如果在读取后 ticketNums 的值又发生变化,volatile 是无法解决的。

volatile 条件

所以要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件需要满足以下两个条件:

  1. 对变量的写操作不依赖于当前值。
  2. 该变量没有包含在具有其他变量的不变式中。

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

volatile 适用场景

  1. 状态标志
  2. 一次性安全发布(one-time safe publication)
  3. 独立观察(independent observation)
  4. “volatile bean” 模式
  5. 开销较低的“读-写锁”策略

具体使用参考【Java 线程】volatile 的适用场景

使用重入锁实现线程同步

在 JavaSE5.0 中新增了一个java.util.concurrent包来支持同步。

synchronized 和 Lock 区别

  • Lock 是一个接口,而 synchronized 是 Java 中的关键字,synchronized 是内置的语言实现;
  • synchronized 无法判断是否获取锁的状态,Lock 可以判断是否获取到锁;
  • synchronized 会自动释放锁 (a 线程执行完同步代码会释放锁 ;b 线程执行过程中发生异常会释放锁),Lock 需在 finally 中手工释放锁(unlock () 方法释放锁),否则容易造成线程死锁;
  • Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用 synchronized 时,等待的线程会一直等待下去,不能够响应中断;
  • Lock 锁适合大量同步的代码的同步问题,synchronized 锁适合代码少量的同步问题。
  • Lock 可以提高多个线程读的效率
  • synchronized 的锁可重入、不可中断、非公平,而 Lock 锁可重入(两者皆可)、可中断、可公平;

ReentrantLock 类是可重入、互斥、实现了 Lock 接口的锁,
它与 synchronized 具有相同的基本行为和语义,并且扩展了其能力

ReenreantLock 类的常用方法有:

  • ReentrantLock() : 创建一个 ReentrantLock 实例
  • lock() : 获得锁
  • unlock() : 释放锁

注:ReentrantLock()还有一个可以创建公平锁的构造方法,但是会大幅度降低程序运行效率,不推荐使用

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class UnsafeBuyTicket implements Runnable {
// 10张票
private int ticketNums = 100;
// 创建ReentrantLock实例
private Lock lock = new ReentrantLock();
...
public void buy() {
lock.lock();
try {
...
} finally {
lock.unlock();
}
}
}

使用局部变量实现线程同步

ThreadLocal 是一个本地线程副本变量工具类。主要用于将私有线程和该线程存放的副本对象做一个映射,各个线程之间的变量互不干扰,在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不同的变量值完成操作的场景。
ThreadLocal 的作用主要是做数据隔离,填充的数据只属于当前线程,变量的数据对别的线程而言是相对隔离的,在多线程环境下,防止自己的变量被其它线程篡改。

核心机制

  • 每个 Thread 线程内部都有一个 Map。
  • Map 里面存储线程本地对象(key)和线程的变量副本(value)
  • 但是,Thread 内部的 Map 是由 ThreadLocal 维护的,由 ThreadLocal 负责向 map 获取和设置线程的变量值。

所以对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成了副本的隔离,互不干扰。

ThreadLocal 在 Spring 中发挥着巨大的作用,在管理 Request 作用域中的 Bean、事务管理、任务调度、AOP 等模块都出现了它的身影。

Spring 中绝大部分 Bean 都可以声明成 Singleton 作用域,采用 ThreadLocal 进行封装,因此有状态的 Bean 就能够以 singleton 的方式在多线程中正常工作了。

ThreadLocal 类的常用方法

  • ThreadLocal() : 创建一个线程本地变量
  • get() : 获取当前线程的副本变量值。
  • initialValue() : 返回此线程局部变量的当前线程的”初始值”
  • set(T value) : 设置当前线程的副本变量值为 value。
  • remove():移除当前线程的副本变量值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class UnsafeBuyTicket implements Runnable {
// 100张票
private static ThreadLocal<Integer> ticketNums = new ThreadLocal<Integer>() {
@Override
protected Integer initialValue() {
return 100;
}
};

@Override
public void run() {
for (int i = 0; i < 10; i++) {
buy();
}
}

/**
* 买票
*/
public void buy() {
// 延时
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 买票
ticketNums.set(ticketNums.get() - 1);
int num = ticketNums.get();
System.out.println(Thread.currentThread().getName() + "拿到:" + num);
}
}

如上,修改代码后可以看到三个线程的变量值互不影响。

ThreadLocal 适用场景

例:Hibernate 的 session 获取场景。
每个线程访问数据库都应当是一个独立的 Session 会话,如果多个线程共享同一个 Session 会话,有可能其他线程关闭连接了,当前线程再执行提交时就会出现会话已关闭的异常,导致系统异常。此方式能避免线程争抢 Session,提高并发下的安全性。

使用 ThreadLocal 的典型场景正如上面的数据库连接管理,线程会话管理等场景,只适用于独立变量副本的情况,如果变量为全局共享的,则不适用在高并发下使用。
又或者:时间格式:SimpleDataFormat

解析参考ThreadLocal-面试必问深度解析

使用阻塞队列实现线程同步

前面 5 种同步方式都是在底层实现的线程同步,但是我们在实际开发当中,应当尽量远离底层结构。
使用 javaSE5.0 版本中新增的java.util.concurrent包将有助于简化开发。
本小节主要是使用LinkedBlockingQueue<E>来实现线程的同步
LinkedBlockingQueue<E>是一个基于已连接节点的,范围任意的 blocking queue。

LinkedBlockingQueue 类常用方法

  • LinkedBlockingQueue() : 创建一个容量为 Integer.MAX_VALUE 的- LinkedBlockingQueue
  • put(E e) : 在队尾添加一个元素,如果队列满则阻塞
  • size() : 返回队列中的元素个数
  • take() : 移除并返回队头元素,如果队列空则阻塞

使用阻塞队列实现生产和消费,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

/**
* 用阻塞队列实现线程同步 LinkedBlockingQueue的使用
*/
public class BlockingSynchronizedThread {
/**
* 定义一个阻塞队列用来存储生产出来的商品
*/
private LinkedBlockingQueue<Integer> queue = new LinkedBlockingQueue<Integer>();
/**
* 定义生产商品个数
*/
private static final int SIZE = 10;
/**
* 定义启动线程的标志,为0时,启动生产商品的线程;为1时,启动消费商品的线程
*/
private int flag = 0;

private class LinkBlockThread implements Runnable {
@Override
public void run() {
int newFlag = flag++;
System.out.println("启动线程 " + newFlag);
if (newFlag == 0) {
for (int i = 0; i < SIZE; i++) {
int b = new Random().nextInt(255);
System.out.println("生产商品:" + b + "号");
try {
queue.put(b);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("生产者:仓库中还有商品:" + queue.size() + "个");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
} else {
for (int i = 0; i < SIZE / 2; i++) {
try {
int n = queue.take();
System.out.println("消费者买去了" + n + "号商品");
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("消费者:仓库中还有商品:" + queue.size() + "个");
try {
Thread.sleep(100);
} catch (Exception e) {
// TODO: handle exception
}
}
}
}
}

public static void main(String[] args) {
BlockingSynchronizedThread bst = new BlockingSynchronizedThread();
LinkBlockThread lbt = bst.new LinkBlockThread();
Thread thread1 = new Thread(lbt);
Thread thread2 = new Thread(lbt);
thread1.start();
thread2.start();
}
}

注:BlockingQueue<E>定义了阻塞队列的常用方法,尤其是三种添加元素的方法需要多加注意,当队列满时:

  • add()方法会抛出异常
  • offer()方法返回 false
  • put()方法会阻塞

使用原子变量实现线程同步

需要使用线程同步的根本原因在于对普通变量的操作不是原子的。

在 java 的 util.concurrent.atomic 包中提供了创建了原子类型变量的工具类,使用该类可以简化线程同步。

其中AtomicInteger 包可以用原子方式更新 int 的值,可用在应用程序中(如以原子方式增加的计数器),
但不能用于替换 Integer;可扩展 Number,允许那些处理机遇数字类的工具和实用工具进行统一访问。

AtomicInteger 类常用方法:

  • AtomicInteger(int initialValue) : 创建具有给定初始值的新的 AtomicInteger
  • addAddGet(int dalta) : 以原子方式将给定值与当前值相加
  • get() : 获取当前值

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class UnsafeBuyTicket implements Runnable {
// 100张票
private AtomicInteger ticketNums = new AtomicInteger(100);

@Override
public void run() {
for (int i = 0; i < 10; i++) {
buy();
}
}

/**
* 买票
*/
public void buy() {
// 延时
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 买票
System.out.println(Thread.currentThread().getName() + "拿到:" + ticketNums.getAndAdd(-1));
//}
}
}

参考

Java反射