Java中的双重检查锁 - Sanarous的博客

Java中的双重检查锁

Java中的双重检查锁 Double Checked Locking

在实现单例模式中,如果没有考虑多线程并发的情况下,初学者很容易写出下面的错误单例模式代码:

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton(){
private static Singleton uniqueSingleton;

private Singleton(){}

private Singleton getInstance(){
if(null == uniqueSingleton){
uniqueSingleton = new Singleton();
}
return uniqueSingleton;
}
}

很显然,在多线程的情况下,这样写可能会导致uniqueSingleton被创建出多个实例,比如下面的情况考虑有两个线程同时调用getInstance()方法时:

TimeThread A 记为线程AThread B 记为线程B
T1检查到uniqueSingleton为空
T2检查到uniqueSingleton为空
T3初始化对象A
T4返回对象A
T5初始化对象B
T6返回对象B

用文字表述如下:

如果线程A和B同时执行了getInstance()方法,然后以如下方式执行:

  1. 线程A进入if判断,此时instancenull,因此可以进入if内
  2. 线程B进入if判断,此时A还没有创建instance,因此instance也为null,因此线程B也进入了if内
  3. 线程B初始化了一个对象并返回
  4. 线程B也初始化了一个对象并返回

因此此时一个对象实际上还是可以被创建多次,并没有达到单例的效果。


加锁

那么出现这种情况,第一反应肯定是加锁,因此可以将上述代码更改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton(){
private static Singleton uniqueSingleton;

private Singleton(){}

//加锁
private synchronized Singleton getInstance(){
if(null == uniqueSingleton){
uniqueSingleton = new Singleton();
}
return uniqueSingleton;
}
}

这样虽然解决了问题,但是因为用到了synchronized会导致比较大的开销,并且加锁实际中只需要在第一次初始化的时候用到,之后的调用并不需要再进行加锁,因此这种方法实际上需要改进。

双重检查锁

双重检查锁是对上述加锁问题的一种优化,先判断对象是否已经被初始化,再决定是否加锁。

错误的双重检查锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Singleton{
private static Singleton uniqueSingleton;

private SIngleton(){}

public Singleton getInstance(){
if(null == uniqueSingleton){
synchronized(Singleton.class){
if(null == uniqueSingleton){
uniqueSingleton = new Singleton(); //错误示范
}
}
}
return uniqueSingleton;
}
}

如果这样写的话,运行顺序就变成了:

1.检查变量是否被初始化(不去获得锁),如果已经被初始化就直接返回

2.获得锁

3.再次检查变量是否已经被初始化,如果还没有初始化就初始化一个对象

执行双重检查是因为,如果多个线程同时通过了第一次检查,并且其中的一个线程首先通过了第二次检查并实例化了对象,那么剩余通过了第一次检查的线程就不会再去实例化对象。

这样,除了初始化的时候会出现加锁的情况,后续的调用都会避免加锁而直接返回,解决了性能消耗的问题。

隐患

上述的写法看似解决了问题,实际上有个很大的隐患。实例化对象的那一行代码,实际上可以分解为三个步骤:

  1. 分配内存空间
  2. 初始化对象
  3. 将对象指向刚分配的内存空间

但是有些编译器为了提升性能,会采用指令重排序,因此可能会将第二步和第三步进行重排序(在某些JIT编译器中这种情况是会真实发生的),顺序就变成了:

  1. 分配内存空间
  2. 将对象指向刚分配的内存空间
  3. 初始化对象

而当考虑重排序后,两个线程发生了以下的调用:

TimeThread A 线程AThread B 线程B
T1检查到uniqueSingleton为空
T2获得锁
T3再次检查到uniqueSingleton为空
T4uniqueSingleton分配内存空间
T5uniqueSingleton指向内存空间
T6检查到uniqueSingleton不为空
T7访问uniqueSingleton(此时对象还未完成初始化)
T8初始化uniqueSingleton

文字表述如下:

  1. A、B线程同时进入了第一个if判断

  2. A首先初始化synchronized块,由于uniqueSingleton为null,所以它执行了uniqueSingleton = new Singleton();

  3. 由于JVM内部的优化机制,JVM先划出一些分配给Singleton实例的空白内存,并赋值给instance成员,注意此时JVM还没有开始初始化这个实例,然后A离开了这个synchronized块。

    1. B进入synchronized块,由于uniqueSingleton此时不是null,因此它马上离开了synchronized块 并将结果返回给调用该方法的程序。
  4. 此时B线程打算使用Singleton实例,却发现它没有被初始化,于是错误发生了。

在知晓了问题发生的根源之后,我们可以想出两种办法来实现线程安全的单例模式,

1)不允许2和3重排序。

2)允许2和3重排序,但是不允许其它线程“看到”这个重排序。

正确的双重检查锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
private volatile static Singleton uniqueSingleton;

private Singleton() {
}

public Singleton getInstance() {
if (null == uniqueSingleton) {
synchronized (Singleton.class) {
if (null == uniqueSingleton) {
uniqueSingleton = new Singleton();
}
}
}
return uniqueSingleton;
}
}

为了解决隐患问题,我们需要在uniqueSingleton前加上关键字volatile。使用了volatile关键词后,编译器的指令重排序被禁止,所有对加上该关键字的共享变量的写(write)操作都发生在读(read)操作之前。至此,双重检查锁就可以完美工作了!

基于类初始化的解决方案

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的 初始化。在执行类的初始化期间,JVM会尝试去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。

基于这个特性,可以实现另外一种线程安全的延迟加载初始化方案:

1
2
3
4
5
6
7
8
9
public class SingletonFactory{
private static class InstanceHolder{
public static Instance instance = new Instance();
}

public static Instance getInstance(){
return InstanceHolder.instance; //这里将导致InstanceHodler类被初始化
}
}

这个方案的实质是:允许重排序,但是不允许非构造线程“看到”这个排序。

如果这篇文章对您很有帮助,不妨
-------------    本文结束  感谢您的阅读    -------------
0%