Java笔记 ·

并发学习笔记11-双重检查锁定与延迟初始化

并发学习系列以阅读《Java并发编程的艺术》一书的笔记为蓝本,汇集一些阅读过程中找到的解惑资料而成。这是一个边看边写的系列,有兴趣的也可以先自行购买此书学习。
本文首发:windCoder.com

关于双重检测锁定,了解过单例的应该不陌生,但也容易写错。这里以单例模式为例一起探索。

问题分析

首先看一下基本的懒汉模式

public class Singleton {
    private static Singleton instance;

    private Singleton(){}

    public static Singleton getInstance() {
        if(instance==null) {                // 1:A线程执行
            instance = new Singleton();    // 2:B线程执行
        }
        return instance;
    }
}

我们都知晓这种方式不是线程安全的,在多线程吓不能正常工作:当A线程执行代码1的同时,B线程执行代码2.此时,A线程可能会看到instance引用的对象还未初始化。

对此,我们可以对getInstance()方法做同步处理来实现线程安全的延迟初始化,其优化如下:

public class Singleton {
    private static Singleton instance;

    private Singleton(){}

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

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

在早期JMM中,synchronized(甚至是无竞争的synchronized)存在巨大性能问题。为了继续优化,因此人们想出了一个“聪明”的技巧,即双重检查锁定(Double-Checked Locking,简称DCL):

public class Singleton {                       // 1
    private static Singleton instance;         // 2
    private Singleton(){}

    public static Signleton getInstance() {   // 3
        if(instance == null) {               // 4:第一次检查
            synchronized(Singleton.class) {  // 5:加锁
                if(instance == null) {       // 6:第二次检查
                    instrance = new Singleton(); // 7:问题根源
                }                            
            }                                // 8
        }                                    // 9                    
        return instance;                   // 10     
    }                                      // 11 
}

上面的看起来两全其美:
- 多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。
- 在对象创建后,执行getInstance()方法将不需要获取锁,直接返回已创建好的对象。

问题根源

虽然看起来完美,但是一个错误的优化。在线程执行到第4行,代码读取到instance不为null时,instance引用的对象可能还没初始化完成。下面看一下具体根源。
实例化一个对象(如第7行的instrance=new Singleton();)要分三个步骤:
- 1.分配对象的内存空间。
- 2.初始化对象。
- 3.设置instance指向(即将内存空间分配给对应的引用)

但由于重排序,2和3可能发生重排序(在一些JIT编译器上,这种重排序是真实发生的),其过程如下:
- 1.分配对象的内存空间。
- 3.设置instance指向(即将内存空间分配给对应的引用)---注意:此时对象还未被初始化。
- 2.初始化对象。

所有线程在执行Java程序时必须要遵守intra-thread semantics。intra-thread semantics保证重排序不会改变单线程内的程序执行结果。

上面的2、3的重排序在没改变单线程程序的执行结果的前提下,可以提高程序的执行性能,故并未违反intra-thread semantics。然A线程正常执行时,B线程将看到一个还没被初始化的对象:B线程会导致第二个判断出错,instance != null,但它获得的仅是一个地址,此时A线程还未初始化,故B线程返回的instance对象是一个没有初始化的对象,如图:

知晓问题根源后,可以想到两个办法来解决:
- 1.不允许2和3重排序。
- 2.允许2和3重排序,但不允许其他线程“看到”这个重排序。

解决方案

基于volatile的解决方案

对于上面基于DCL方案只需做一点小的修改即可,亦既把instance声明为volatile型:

public class Singleton {                   
    private volatile static Singleton instance; 
    private Singleton(){}

    public static Signleton getInstance() { 
        if(instance == null) {            
            synchronized(Singleton.class) { 
                if(instance == null) {     
                    instrance = new Singleton(); 
                }                            
            }                               
        }                                                      
        return instance;                      
    }                                      
}

当instance声明为volatile后,步骤2、3的重排序在多线程环境中将会被禁止,从而解决问题。该解决方案需要JDK5及以上。

基于类初始化的解决方案

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

基于该特性,可以实现另一种线程安全的延迟初始化方案,该方案被称之为Initialization On Demand Holder idiom:

public class Singleton { 
    private static class InstanceHolder {
        public static Singleton instance = new Singleton();
    }
    private Singleton(){}

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

该方案的解决是指是:允许2和3重排序,但不允许非构造线程(此处指B线程)“看到”这个重排序。执行示意图如下:

初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态自动。根据Java语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化:

  • 1.T是一个类,而且一个T类型的实例被创建。
  • 2.T是一个类,且T中声明的一个静态方法被调用。
  • 3.T中声明的一个静态字段被赋值。
  • 4.T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
  • 5.T是一个顶级类(Top Level Class),而且一个断言语句嵌套在T内部被执行。

在Singleton中,首次执行getInstance()方法的线程将导致InstanceHolder类被初始化(符合情况4)。

  • Java语言规定,对于每个类或者接口C,都有一个唯一的初始化锁LC与之对应。
  • 从C到LC的映射,由JVM的具体实现去自由实现。
  • JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来保证这个类已经被初始化过了。

参与评论