在java多线程程序中,有时候需要采用延迟初始化的方法,来降低初始化类或者创建对象的开销。双重检查锁定是常见的延迟初始化技术,但是他是一个错误的用法。
一、双重检查锁定的由来
在java程序中,有时候可能需要推迟一些高开销的初始化操作,并且只有在使用这些对象的时候在进行初始化。此时,程序员可能会采用延迟初始化的操作。但是,要实现线程安全的延迟初始化动作,可能需要 一些技巧,否则很容易出现问题。比如下面就是非线程安全的延迟初始化操作。
public class UnsafeLazyInitialization {
private static Instance instance;
public static Instance getInstance(){
if (instance==null){ //1.线程A执行
instance=new Instance(); //2.线程B执行
}
return instance;
}
}
对于上面的代码,当线程A执行代码1时,线程B正在执行代码2,此时线程A可能看到instance引用的对象还未完成初始化。(原因在二、问题的根源中)
对于上面UnsafeLazyInitialization类中出现的问题,我们可以在方法上增加synchronized来做同步处理来实现线程安全的延迟初始化操作。示例代码如下:
public class SafeLazyInitialization {
private static Instance instance;
public static synchronized Instance getInstance(){
if (instance==null){
instance=new Instance();
}
return instance;
}
}
由于对getInstance方法做了同步处理,synchronized将导致性能开销。如果getInstance方法被多线程频繁调用,将会导致程序执行性能的下降。反之,如果getInstance方法不被多个线程频繁的调用,那么这个延迟初始化方案将能提供令人满意的性能。
但是在早期的JVM中,synchronized(甚至是无竞争的synchronized)存在巨大的性能开销,因此,人们想出了一个"聪明"的方法:双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定的方法来降低同步的开销。以下是双重检查锁定的代码示例:
public class DoubleCheckedLocking { //1
private static Instance instance; //2
public static Instance getInstance(){ //3
if (instance==null){ //4:第一次检查
synchronized (DoubleCheckedLocking.class){ //5:加锁
if (instance==null){ //6:第二次检查
instance=new Instance(); //7:问题出现的根源
} //8
} //9
} //10
return instance; //11
}
}
对于上面的代码,当第一次检查instance不为空时,那么就不需要执行下面的加锁和初始化操作。因此可以大幅度降低synchronized带来的性能开销。上面的代码看起来似乎两全其美。
- 多线程试图在同一时间创建对象时,会通过加锁来保证其中只有一个线程能创建对象。
- 在对象创建好之后,执行getInstance方法将不需要获取锁,直接返回已经创建好的对象。
双重检查锁定,看起来似乎是很完美,但是这是一个错误的优化!在线程执行到第四行,代码读取到的instance不为null时,此时instance引用的对象可能还未完成初始化操作。
二、以上问题出现的根源
对于出现这种情况的原因在于instance=new Instance():
当创建一个对象时,其步骤可以分为3步伪代码:
memory=allocate(); //1:分配内存
ctrorInstance(memory); //2:初始化对象
instance=memory; //3:设置instance指向刚分配的内存地址
由于对象的创建不是原子操作的,对于上面对象创建涉及到的伪代码2,3来说可能会被重排序,变成
memory=allocate(); //1:分配内存
instance=memory; //3:设置instance指向刚分配的内存地址
// 注意此时对象还未完成初始化
ctrorInstance(memory); //2:初始化对象
在java语言规范中,规定所有线程在执行java代码时,必须遵守,intra-thread semantics。intra-thread semantics保证重排序不会改变单线程内的程序执行结果。也就是说,intra-thread semantics允许那些在单线程内,不会改变程序执行结果的重排序。上面的3行伪代码中的2和3虽然被重排序成了3,2但是这个重排序并没有违反intra-thread semantics。这个重排序并没有改变上面代码在单线程程序内的执行结果。
为了更好理解intra-thread semantics ,看下图(假设一个线程创建对象后,立即访问这个对象):
只要保证2在4前面,就不会违反intra-thread semantics协议。
下面看下多线程并发执行的情况:
由于单线程内要遵守intra-thread semantics协议,从而能保证A线程的执行结果不变。但是当线程A和B按照上图时序执行时,B线程将看到一个还没有初始化完成的对象。
回到本文,DoubleCheckedLocking示例代码在执行到第七行时(instance=new Instance()),如果发生重排序,另一个并发执行的线程B就有可能在第4行判断instance是否为null,判断为不为null,线程B接下来就访问instance引用的对象,但此时这个对象尚未被完全初始化。其具体的执行时序如下:
时间 | 线程A | 线程B |
---|---|---|
t1 | A1:分配对象内存空间 | |
t2 | A3:设置instance指向内存空间 | |
t3 | B1:判断instance书否为null | |
t4 | B2:由于判断instance不为null,线程B将访问instance引用的对象 | |
t5 | A2:初始化对象 | |
t6 | A4:线程A访问instance引用的对象 |
三、解决方案
3.1、基于volatile的解决方案
对于双重检查锁定来实现延迟初始化方案,只需要修改一点就可以实现多线程安全的延迟初始化方案。示例代码如下:
public class SafeDoubleCheckLocking {
private volatile static Instance instance;
public static Instance getInstance(){
if (instance==null){
synchronized (SafeDoubleCheckLocking.class){
if (instance==null){
instance=new Instance();
}
}
}
return instance;
}
}
在JMM中的happens-before规则保证了多线程程序下,线程间的内存可见性。其有一条规则规定:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
所以我们使用volatile修饰变量可以实现线程安全的延迟初始化方案。
3.2、基于类初始化方案解决
JVM在类的初始化阶段(也就是Class被加载后,且被线程使用之前),会执行类的初始化。在执行初始化期间,JVM会尝试获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
基于这个特性可以说实现另一种线程安全的延迟初始化方案。示例代码如下:
public class InstanceInitializationFactory {
private static class InstanceHolder{
public static Instance instance=new Instance();
}
public Instance getInstance(){
return InstanceHolder.instance;
}
}
假设两个线程同时并发执行getInstance()方法,下面是执行的示意图:
初始化一个类包括,执行这个类的静态初始化,和初始化这个类中定义的静态字段。根据Java语言规范,发生以下几种情况时,一个类或者接口类型T会被立即初始化。
- T是一个类,且一个T类型的实例被创建
- T是一个类,且T中的一个静态变量被调用。
- T中声明的一个静态字段被赋值。
- T中声明的一个静态字段被使用,且这个字段不是常量字段。
- T是一个顶级类,且一个断言语句嵌套在T内部被使用。
在InstanceInitializationFactory类中,首次执行getInstance方法的线程将导致InstanceHolder类被初始化(符合情况4)
由于Java是多线程的,这就有可能有多个线程在同一时刻初始化同一个类或者接口,因此Java在初始化时,需要做一个细致的同步处理。
Java语言规范规定,对于每一个类或者接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM具体的实现去自由实现。JVM在初始化期间会获取这个锁,并且每个线程至少获取一次锁来确保这个类已经被初始化了。
在《Java并发编程的艺术》一书中,作者将初始化过程分成5个阶段。
第一阶段:通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。
假设,类还未被初始化(初始化状态位state,此时被标记为state=noIniaialization),且两个想成A,B视图同时初始化这个Class对象。示意图如下:
第一阶段执行时序表如下:
时间 | 线程A | 线程B |
---|---|---|
t1 | A1:尝试获取Class对象的初始化锁。这里假设线程A获取到了Class对象的初始化锁。 | B1:尝试获取Class对象的初始化锁,由于A线程已经获取到了,线程B将一直等待获取初始化锁。 |
t2 | A2:线程A看到对象还未被初始化(因为state=noInitialization),线程设置state=Initializing | |
t3 | A3:线程A释放初始化锁 |
第二阶段:线程A执行类的初始化,同时线程B在初始化锁对应的Condition上等待。
第二阶段示意图如下:
第二阶段时序图如下:
时间 | 线程A | 线程B |
---|---|---|
t1 | A1:执行类的静态初始化和初始化类中声明的静态变量。 | B1:获取到初始化锁 |
t2 | B2:读取到state=Initializing | |
t3 | B3:释放初始化锁 | |
t4 | B4:在初始化锁Condition等待区等待 |
第三阶段:线程A设置state=Initialized,然后唤醒在Condition中等待的所有线程 。示例图如下:
第三阶段时序图如下:
时间 | 线程A | 线程B |
---|---|---|
t1 | A1:获取初始化锁 | |
t2 | A2:设置state=initialized | |
t3 | A3:唤醒初始化锁Condition等待的所有线程 | |
t4 | A4:释方初始化锁 | |
t5 | A5:线程A结束类初始化处理过程 |
第四阶段:线程B结束类的初始化处理 。示例图如下:
第四阶段时序图如下:
时间 | 线程B |
---|---|
t1 | B1:获取初始化锁 |
t2 | B2:读取到state=initialized |
t3 | B3:释放初始化锁 |
t4 | B4:线程B的类初始化处理过程完毕 |
第五阶段:线程C执行类的初始化的处理 ,示例图如下:
第五阶段时序图如下:
时间 | 线程C |
---|---|
t1 | C1:获取初始化锁 |
t2 | C2:读取到state=initialized |
t3 | C3:释放初始化锁 |
t4 | C4:线程B的类初始化处理过程完毕 |
PS:这其中condition和state标记是作者虚构出来的。Java语言规范并未硬性规定这里要使用condition和state。JVM具体的实现只要类似即可。
3.3、总结
通过对比基于volatile的双重检查锁定方案和基于类初始化的方案,我们发现类初始化方案的实现代码更加简洁。但基于volatile的双重检查锁定方案有一个额外的优势:除了可以对静态字段实现延迟初始化外,还可以对实例实现字段实现延迟加载。