错误的双重检查锁
在Java多线程的程序中,为了降低初始化类和创建对象的开销,经常会使用延迟初始化的方式,延迟初始化就会用到双重检查锁来实现,但很多双重检查锁的用法是错误的,这里介绍一下错误的原因和怎么实现正确的双重检查锁。
双重检查锁的由来
延迟初始化同步问题
public class UnSafeLazyInit {
private static Instance instance;
public static Instance getInstance(){
if (Objects.isNull(instance)){ //1.A线程执行
instance = new Instance(); //2.B线程支持
}
return instance;
}
}
从图中可以看到,如果线程A执行到1,线程B执行到2 线程A可能看到instance还没有初始化,这种情况会重复初始化
使用synchronized优化
因此我们考虑使用加锁来处理多线程问题做同步处理
public class UnSafeLazyInit {
private static Instance instance;
public synchronized static Instance getInstance(){
if (Objects.isNull(instance)){ //1.A线程执行
instance = new Instance(); //2.B线程支持
}
return instance;
}
}
但是如果getInstance()方法被多线程频繁调用,synchronized锁带来的性能开销将会很大,如果不被频繁调用,这个结果还是可以接受的
使用双重检查锁优化
public class UnSafeLazyInit {
private static Instance instance;
public static Instance getInstance(){
if (Objects.isNull(instance)){ //第一次检查
synchronized (UnSafeLazyInit.class){ //加锁
if (Objects.isNull(instance)){ //第二次检查
instance = new Instance(); //问题出现的位置
}
}
}
return instance;
}
}
这里在方法内部先进行一次是否为空判断,这一步骤可以降低在instance已经初始化完毕的情况,还进行加锁操作的性能开销(synchronized方法的缺点),只有确定对象为空,才去竞争锁,去做对象的初始化,这种解决方案看起来已经很完美了
错误的原因
instance = new Instance();
这段代码在多线程执行的时候,会出现问题,原因是因为,如果线程A执行到第一次检查的时候,可能判断instance已经不为空了,但是这个时候instance还没初始化完毕,原因就在于这段代码可以分为三步
memory = allocate(); //分配内存空间
initObject(); //初始化对象
instance = memory; //将instance指向分配的内存空间地址
这段代码可能发生重排序,重排序之后的顺序如下
memory = allocate(); //分配内存空间
instance = memory; //将instance指向分配的内存空间地址
initObject(); //初始化对象
Java语言规范中表明:允许在单线程中,不会改变单线程执行结果的重排序
在单线程中,上面的重排序并不会影响执行结果,所以这部分在单线程是没有问题的,但是Java语言规范并没有说多线程也适用。看下图更能清楚的理解
基于volatile的解决方案
volatile的作用主要用两个:
1、保证volatile修饰的变量对所有线程的可见性。
2、禁止指令重排序优化。
public class SafeLazyInit {
private static volatile Instance instance;
public static Instance getInstance(){
if (Objects.isNull(instance)){ //第一次检查
synchronized (SafeLazyInit.class){ //加锁
if (Objects.isNull(instance)){ //第二次检查
instance = new Instance(); //禁止指令重排序
}
}
}
return instance;
}
}
将instance声明为volatile之后,问题出现的位置的三步执行就不能再重排序,这样就保证了多线程情况下的问题
基于类初始化的解决方案
JVM会在类初始化阶段(Class被加载后,且被线程使用前),会执行初始化,JVM会首先获取锁,这个锁可以同步多线程对同一个类的初始化,基于这种特点,我们可以使用另一种解决方案
public class InstanceFactory{
private static class InstanceHolder{
public static Instance instance = new Instance();
}
public static Instance getInstance(){
return InstanceHolder.instance;
}
}
初始化流程
1.线程A执行初始化,获取锁成功,判断是否已经初始化,如果已经初始化就释放锁,访问对象
2.如果线程A执行初始化,发现对象还未初始化,就执行初始化流程,并将初始化对象设置为初始化中状态,这时线程B也要执行,发现对象已经在初始化中,就会循环等待获取锁
3.A线程初始化完毕,B线程获取锁,发现已经初始化完毕,就立即释放锁,退出初始化
孰优孰略
对比两种方式我们发现,基于类的初始化更加简洁简单,但是基于volatile的方案,更加灵活,它不仅可以对静态字段实现延迟初始化,也可以对普通字段实现延迟初始化
写到最后
基于延迟的初始化降低了类初始化和创建实例的开销,但是也增加了访问延迟初始化字段的开销。大多数时候,正常初始化要优于延迟初始化,但是如果确实需要延迟初始化,请参考上面两种模式,选择其一。
参考书籍:Java并发编程的艺术