双重检查锁定与延迟初始化

在Java多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法

非线程安全的延迟初始化对象

package 双重检查锁定与延迟初始化;
//非线程安全的延迟初始化对象
public class UnsafeLazyInitialization {
    private static UnsafeLazyInitialization instance;

    public static UnsafeLazyInitialization getInstance(){
        if(instance == null)//代码1
            instance = new UnsafeLazyInitialization();//代码2
        return instance;
    }
}

在上面的类中,假设线程A执行代码1的同时,B线程执行代码2。此时,线程A可能会看到instance引用的对象还没有完成初始化。

线程安全的延迟初始化

package 双重检查锁定与延迟初始化;
//线程安全的延迟初始化,由于对getInstance方法做了同步处理,sychronized将导致性能开销
public class SafeLazyInitialization {
    private static SafeLazyInitialization instance;

    public synchronized static SafeLazyInitialization getInstance(){
        if(instance == null)
            instance = new SafeLazyInitialization();
        return instance;
    }
}

由于对getInstance方法做了同步处理,synchronized将导致性能开销。getInstance方法如果被多个线程频繁调用,将导致程序执行性能的下降。

双重检查锁定

public class DoubleCheckedLocking {
    private static DoubleCheckedLocking instance;

    public static DoubleCheckedLocking getInstance(){
        if(instance == null){//1处
            synchronized(DoubleCheckedLocking.class){
                if(instance == null)
                    instance = new DoubleCheckedLocking();//2处
            }
        }
        return instance;
    }
}

在A线程位于同步代码块2处,初始化对象(但还未初始化完成的时候),有可能有另外的线程B运行到1处。此时instance检查不为null,但instance返回的却不是一个完整的对象。

为什么instance没有被A真正初始化的时候,其指向不为null呢?原因在于编译器的指令重排序

创建一个对象的过程可由如下三步伪代码表示:

1.memory = allocate() //分配对象的内存空间

2.ctorInstance(memory) //初始化对象

3.instance = memory //设置instance指向分配的内存地址

上面的创建对象的过程是在一个单线程中完成的, JMM保证了重排序不会改变单线程内的程序执行结果。换句话说,JAVA允许那些在单线程内,不会改变单线程程序执行结果对的指令重排序。上述三个步骤在一些JIT编译器上被单线程执行的时候,为了提高程序的执行性能,有可能会被重排序且不影响单线程下的执行结果:

1.memory = allocate() //分配对象的内存空间

2.instance = memory //设置instance指向分配的内存地址

3.ctorInstance(memory) //初始化对象

这样,instance 在单线程内会先被指向内存分配地址(此时检查instance不为null),而后这个地址内才真正存放初始化完成的对象数据。这样线程A执行到步骤2,将instance指向内存地址以至于对instance作检查不为null的时候(但实际上该地址内没有初始化完成的对象数据),线程B运行到了程序的1处,对instance作了不为null的检查后,直接返回了这个“虚有其表”的instance。后续程序如果用到了这个对象,必然导致程序的错误。

解决方法

在知晓了问题的根源后,我们可以采用两个办法来实现线程安全的延迟初始化:

1. 不允许2和3重排序

2. 允许2和3重排序,但不允许其他线程“看到”这个重排序

基于volatile的解决方案

基于前面的双重检查锁定来实现延迟初始化的方案,只需要做一点小的修改(将instance声明为volatile类型),就可以实现线程安全的延迟初始化

package 双重检查锁定与延迟初始化;
public class SafeDoubleCheckedLocking {
    private volatile static SafeDoubleCheckedLocking instance;

    public static SafeDoubleCheckedLocking getInstance(){
        if(instance==null){
            synchronized (SafeDoubleCheckedLocking.class){
                if(instance == null)
                    instance = new SafeDoubleCheckedLocking();//instance为volatile,禁止了volatile变量的指令重排序,现在没有问题了。
            }
        }
        return instance;
    }
}

当声明对象引用为volatile后,前面伪代码的2和3之间的重排序在多线程环境中将被禁止,从而解决了这个问题。

基于类初始化的解决方案

JVM在类的初始化阶段(即在class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会取获取一个锁。这个锁可以同步多个线程对同一个类的初始化。。这个方案的实质是,允许伪代码中2和3的重排序,但不允许非构造线程(指线程B)“看到”这个重排序。

package 双重检查锁定与延迟初始化;
/*JVM在类的初始化阶段(即在class被加载后,且被线程使用之前),会执行类的初始化。
* 在执行类的初始化期间,JVM会取获取一个锁。这个锁可以同步多个线程对同一个类的初始化。*/
public class InstanceFactory {
    private static class InstanceHolder{
        public static InstanceFactory instance = new InstanceFactory();
    }

    public static InstanceFactory getInstance(){
        return InstanceHolder.instance;
    }
}

JAVA语言规范规定,对于每一个类或者接口C,都有唯一一个的初始化锁LC与之对应。JVM在初始化期间会获取这个初始化锁,并且每个线程至少获取了一次锁来确保这个类已经被初始化过了。

为了更好的说明类初始化过程中的同步处理机制,本书作者人为的把类初始化的处理过程分成了5个阶段:

第1阶段:通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。

假设Class对象当前还没有被初始化(初始化状态state被标记为noInitialization),且有两个线程A和B试图同时初始化这个Class对象。

2段:线A的初始化,同时线B在初始化锁对应condition上等待。

 

3段:线Astate=initialized,然后醒在condition中等待的所有线程。

4段:线B的初始化理。

线A在第2段的A1的初始化,并在第3段的A4放初始化线B在第4段的B1取同一个初始化,并在第4段的B4之后才开始访问这。根据Java内存模型范的锁规则里将存在如下的happens-before关系。happens-before关系将保线A的初始化的写入操作(的静初始化和初始化中声明的静字段),线B一定能看到 

5段:线C的初始化的理。

 

在第3段之后,完成了初始化。因此线C在第5段的初始化程相对简一些(前面的线AB初始化程都经历了两次锁获-锁释放,而线C始化理只需要经历一次锁获-锁释放)。线A在第2段的A1的初始化,并在第3段的A4线C在第5段的C1取同一个,并在在第5段的C4之后才开始访问这。根据Java内存模型范的锁规,将存在如下的happens-before关系。happens-before关系将保线A的初始化的写入操作,线C一定能看到 。

 这个讲起来有点复杂,见《JAVA并发编程的艺术》p72-p78