熟悉设计模式的朋友应该都知道单例模式,这里不再对单例模式的基础进行介绍,本文重点在于解释为什么双重检查没有达到真正意义上的线程安全,当然也要介绍怎么达到真正的线程安全。
本文的知识点主要来源:
1.方腾飞、魏鹏、程晓明三位老师的《Java 并发编程的艺术》
2.周志明老师的《深入理解Java虚拟机》
1. 传统的单例模式
大家都知道,单例模式主要分为:懒汉模式和饿汉模式。当我们在使用单例模式时,考虑到延迟加载,懒汉模式肯定是必须的。但是懒汉模式有一个很大的缺点,那就是线程不安全。我们为了解决这个问题,发明了双重检查锁定的写法,如下:
public class SingleTon {
private static SingleTon instance = null;
private SingleTon(){
}
public static SingleTon getInstance(){
if(instance == null){ // 1
synchronized (SingleTon.class){
if(instance == null){
instance = new SingleTon(); // 2
}
}
}
return instance;
}
}
如上代码所示,如果第一次检查instnace不为null的话,那么就不需要去获取锁来进行instance的初始化。因此,看上去可以大大的降低synchronized带来的性能开销。
双重检查锁定看上去非常的完美,但是却是一个错误的优化。当一个线程在执行到1时,读取到instance不为null时,instance引用的对象可能还没有初始化完毕。
2.问题的根源
在前面的代码中,instance = new SingleTon();是用来创建对象。这一行代码可以分解为如下三行伪代码:
memory = allocate(); //1.分配对象内存空间
ctorInstance(memory); //2.初始化对象
instance = memory; //3.设置instance指向刚分配的内存地址
因为2和3不存在数据依赖性,所以可能会被重排序。2和3重排序之后的执行顺讯可能如下:
memory = allocate(); //1.分配对象内存空间
instance = memory; //3.设置instance指向刚分配的内存地址,注意,此时对象还没有被初始化
ctorInstance(memory); //2.初始化对象
这里可能有人对重排序存在疑惑,如果想要理解什么是重排序,为什么要重排序等等原因,强烈推荐:方腾飞、魏鹏、程晓明三位老师的《Java 并发编程的艺术》。这里我就不对这部分进行展开,主要是自己太菜了,害怕对这部分的解释不好。
我们知道instance = new SingleTon()这一步可能会被重排序之后,现在我们来看看什么情况下能够导致问题。假设有两个线程,ThreadA和ThreadB,这两个线程都在调用SingleTone的getInstance方法来获取一个SingleTon的对象。执行顺序可能出现如下情况:
由于单线程内需要遵守intra-thread semantics,从而能保证ThreadA的执行结果不会被改变(所有线程在执行Java程序必须遵守intra-thread semantics,而intra-thread semantics保证所有的重排序在单线程里内,程序的执行结果不会被改变)。但是当ThreadB在按照上图的顺序在执行时,ThreadB将看到一个还没有被初始化的SingleTon对象。
从我们的程序代码中可以看出来,当instance = new SingleTon()发生了重排序,ThreadB在if(instance == null) 判断出false,接下来将访问instace所引用的对象,但是此时这个对象可能还没有被ThreadA初始化完毕。
时间 | ThreadA | ThreadB |
---|---|---|
t1 | A1:分配对象的内存空间 | |
t2 | A2:设置instance的执行内存空间 | |
t3 | B1:判断是否为空 | |
t4 | B2:由于instance不为null,ThreadB将访问instance引用的对象 | |
t5 | A2:初始化对象 | |
t6 | A4:访问instance引用的对象 |
上表就是对上面的流程图的一个总结。我们知道,最后ThreadB可能会返回一个为未初始化的对象。
在知道了问题的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化。
1.不允许2和3重排序。
2.允许2和3重排序,但是不允许其他线程“看到”这个重排序。
3.解决方案
前面解释了双重检查锁定问题的根源,并且列出了两种解决思路。这里,我们将对这两种思路进行展开。
(1).基于volatile的解决方案
这个解决方案是非常的简单,只需要将我们之前的那个instance变量使用volatile关键字来修饰就行了。如下:
public class SingleTon {
private volatile static SingleTon instance = null;
private SingleTon(){
}
public static SingleTon getInstance(){
if(instance == null){ //1
synchronized (SingleTon.class){
if(instance == null){
instance = new SingleTon(); //2
}
}
}
return instance;
}
}
是不是非常的简单?当然我们这里的目的当然不是简单的实现解决方案,而是详细的解释为什么需要这样做。
memory = allocate(); //1.分配对象内存空间
ctorInstance(memory); //2.初始化对象
instance = memory; //3.设置instance指向刚分配的内存地址
由于instance是volatile变量,所以上面的代码中3相当于是对volatile变量进行写的操作,也就是所谓的volatile写。根据《Java 并发编程的艺术》的P43,我们知道对于一个volatile写,编译器会在volatile写的前面加入一个StoreStore内存屏障,用来防止前面的普通写与下面的volatile写进行重排序;在volatile写的后面加入一个StoreLoad屏障,主要是防止上面的volatile写与下面的可能有的volatile读/写进行重排序。如下图:
从而我们可以得出,instance = new SingleTon()指令执行顺序图:
所以,我们可以得出,只要instance被volatile修饰了,2和3就不能重排序。可以得出新的时序图:
这样,我们通过上面解决方案中第一个方案来保证了线程安全的延迟加载。
(3).基于类初始化的解决方案
我们知道,一个类的加载主要分为4步:验证、准备、解析、初始化。为了保证类的初始化(4步中的第四步)能够成功进行,JVM在初始化阶段进行多线程同步,也就是同一个时候,只能有一个线程来对一个类进行初始化。当然,我们还知道,一个类只能被初始化一次。
基于这个特性,可以实现另一种线程安全的延迟加载的方案。代码如下:
public class SingleTon {
private static class InstanceHolder{
public static final SingleTon instance = new SingleTon();
}
private SingleTon(){
}
public static SingleTon getInstance(){
return InstanceHolder.instance;
}
}
当两个线程同时调用getInstance方法来获取instance对象时,如果此时InstanceHolder类还没有加载的话,首先会先加载这个类。
这里简单的介绍一下,一个类加载的时机:
1.首次创建该类的对象。
2.访问该类的静态属性或者静态方法。
3.使用Class.forName来加载一个类。
所以当ThreadA和ThreadB首次获取instance对象时,InstanceHolder还没有加载,此时两个线程需要加载InstanceHolder类。由于类的加载是只能被一个线程执行,其他线程只能被阻塞住,所以当一个类被一个线程加载完毕之后,instance肯定是被初始化完毕了的,所以所有的线程看到的都是同一个instance对象。
根据《Java 并发编程的艺术》一书中的介绍,类的初始化分为5个阶段:
A.第1阶段:通过在Class对象上的同步(即获取Class对象的初始化锁),来控制类或者接口的初始化。这个获取锁的线程会一直等待,知道线程能够获取这个初始化锁。
我们假设,Class对象还没有被初始化(初始化状态为state,此时被标记为state = noInitialization),并且此时有两个线程(ThreadA和ThreadB)来尝试同时初始化这个对象。执行流程如下表:
时间 | ThreadA | ThreadB |
---|---|---|
t1 | A1:尝试获取Class对象的初始化锁。这里假设ThreadA获取到了初始化锁 | B1:尝试获取Class对象的初始化锁,由于ThreadA获取到了初始化,所以ThreadB将一直在等待获取初始化锁 |
t2 | A2:ThreadA看到InstanceHolder 还没有被初始化(因为读取到state=noInitialization,ThreadA则设置state=initializing | |
t3 | A3:线程A释放初始化锁 |
B.第2阶段:ThreadA执行类的初始化,同时ThreadB在初始化锁对应的condition上等待。
时间 | ThreadA | ThreadB |
---|---|---|
t1 | A1:执行类的静态初始化和初始化类中声明的静态字段 | B1:获取到初始化锁 |
t2 | B2:读取到state=initializing | |
t3 | B3:释放初始化锁 | |
t3 | B4:在初始化的condition中等待 |
C.第3阶段:ThreadA设置state=initialized,然后唤醒在condition中等待的所有线程。
时间 | ThreadA |
---|---|
t1 | A1:获取初始化锁 |
t2 | A2:设置state=initialized |
t3 | A3:唤醒在condition中等待的所有线程 |
t4 | A4:释放初始化锁 |
t5 | A5:ThreadA的初始化处理过程完成 |
D.第4阶段:ThreadB结束类的初始化处理
时间 | ThreadB |
---|---|
t1 | B1:获取初始化锁 |
t2 | B2:读取到state=initialized |
t3 | B3:释放初始化锁 |
t4 | B4:ThreadB的类初始化处理过程完成 |
E.第5阶段:ThreadC执行类的初始化的处理。
本来执行两个线程在竞争,这里ThreadC表示的意思是当ThreadA和ThreadB执行getInstance方法完毕之后,ThreadC才来调用getInstance方法。
时间 | ThreadC |
---|---|
t1 | C1:获取初始化锁 |
t2 | C2:读取到state=initialized |
t3 | C3:释放初始化锁 |
t4 | C4:ThreadC的类初始化处理过程完成 |
这就是基于类初始化的解决方案的基本思路。从这里,我们可以看出来尽管instance = new SingleTon()被重排序,但是由于InstnaceHolder的初始化只能被一个线程执行,所以这里的重排序不会影响最终结果。当一个线程初始化操作完毕之后,其他的程线程看到instance对象肯定是一个初始化完毕的对象。