小白都能看懂的双重检查锁定与延迟初始化深度解析

双重检查锁定(DoubleCheckLocking)实现单例模式

DCL存在问题的代码如下所示

public class Singleton {
    private Singleton() {
        
    }
    
    private static Singleton instance = null;
    
    public static Singleton getInstance() {
			if (instance == null) {					//1. 第一次检查,不为null表示存在实例,直接返回
            synchronized (Singelton.class) {		//2. 加锁,保证内部代码同步进行
                if (instance == null) {				//3. 第二次检查,不为null表示存在实例
                    instance = new Singleton();		//4. 创建单例对象,也是问题的根源
                }
            }
        }
        return instance;
    }
}



问题根源

双重检查锁定看起来似乎很完美,但是这是一个错误的优化!问题主要出现在第4步:

instance = new Singleton();

这一行代码可以拆分为下面3行伪代码:

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语言规范》所描述的,所有线程在执行Java程序时,必须要遵循intra-thread semantics。intra-thread semantics保证重排序不会改变在单线程内的程序执行结果。也就是说intra-thread semantics允许那些在单线程内,不会改变程序执行结果的重排序。

单线程情况下:
在这里插入图片描述
2和3在单线程下重排序被允许,但是在多线程情况下,就出现了问题:
在这里插入图片描述
这里B线程的在源代码中第1步时可能判断到instance并不是null,这时线程B就访问instance所引用的对象,但是这时候instance还未被线程A初始化,就会导致线程B访问到一个未被初始化的对象。

知道原因之后,我们才能着力解决这个问题,有两种方法能够实现线程安全的延迟初始化:

  1. 禁止指令重排(使用volatile关键字)
  2. 允许指令重排,但是不允许其他线程“看到”这个重排序(Holder模式)

博主在博客《一文读懂单例模式(Java)》中已经展示过代码了,这里主要详细讲解两种方法的解决问题的原理。




方法一:基于volatile关键字的解决方法

volatile解决这个问题,主要就是利用了volatile能够禁止指令重排(详见博客《深入理解Java中volatile关键字》)的特点进一步实现的。流程如下:
在这里插入图片描述




方法二:基于类初始化的解决方案

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

基于这个特性,可以实现线程安全的延迟初始化方案

public class Singleton  {
    private static class InstanceHolder {
        private static Singleton instance = new Singleton();
    }
    
    private Singleton() {
        
    }
    
    public static Singleton getInstance() {
        return InstanceHolder.instance;		//这里导致内部类InstanceHolder类被加载
    }
}

执行示意图如下:

在这里插入图片描述
这个方法的实质就是允许创建对象时那两步的重排序,但是不允许非构造线程“看到”到这个重排序。

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

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

在这里,首次执行getInstance()方法的线程将导致InstanceHolder类被初始化(符合情况4)。Java语言规范规定,对于任意一个类或接口C,都有一个唯一的初始化锁(LC)与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化阶段会获取这个初始化锁,这个初始化锁具有一个状态属性state,标志着该类的初始化状态,每个线程至少会获取一次这个初始化锁,各个线程会根据这个初始化状态来确定已经被初始化过,进而确保该类已经被初始化。




总结

通过对比基于volatile的DCL和基于类的初始化两个方案,我们可以看到基于类初始化的方案代码更加简洁。但是基于volatile的双重检查锁定的方案不仅可以对静态字段实现延迟初始化,还能对实例字段实现延迟初始化。字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问该字段的开销(因为每一次都得先获取初始化锁进行判断)。
因此,如果需要对实例字段使用线程安全的延迟初始化,则使用基于volatile的解决方案;如果需要对静态字段使用线程安全的延迟初始化,则使用基于类初始化的解决方案。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值