Java双重检查锁定问题DCL

双重检查锁定

双重检查锁定,即Double-Checked Lock问题,是并发编程中由于指令重排和不正确同步导致的经典并发问题

延迟初始化

在介绍本文重点双重检查锁定前,必须要先了解双重检查锁定问题是由何而来的。

学习过Spring的同学一定都听过懒加载(lazy-init),延迟初始化与懒加载是同类型的思想,都是为了避免、推迟不必要的高性能开销操作或降低程序的启动时间。而本文要讨论的双重检查锁定就是为了延迟初始化服务的,详见下文。

单线程环境下的延迟初始化

在假定的单线程环境下,下列代码就可以很好的实现延迟初始化这一操作

// 单线程延迟初始化
public class Test {
    
    private static Object instance;
    
    public static Object getInstance(){
        if(instance == null){
            instance = new Object();
        }
        return instance;
    } 
}

多线程环境下的延迟初始化

而在多线程环境下,显而易见的上述代码是线程不安全的延迟初始化实现方式,例如在经典的设计模式之一单例模式中,在目标类还未被实例化时,有两个线程同时尝试获取实例,就有可能出现下图情况:(若干情况之一)
在这里插入图片描述
可见有两个实例被创建,单例的唯一性被破坏,因此需要采取某些手段来保证线程安全的延迟初始化操作。

保证线程安全

使用synchronized同步处理

出现线程安全问题是,易想到使用synchronized关键字对目标方法进行同步处理一保证线程安全,如下列代码所示:

//使用synchronized关键字做同步处理保证线程安全
public class Test {

    private static Object instance;

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

对getInstance()方法进行同步处理后,的确能够实现线程安全的延迟初始化操作,在目标实例不会被频繁使用的场景下,此种解决方法也许能够很好的满足需求。

但如果目标实例会被频繁的使用,那么使用synchronized带来的性能开销将令人难以忍受,因为事实上只有getInstance()方法被初次调用时,synchronized才会起作用,同时也完成了全部职责,后续的全部调用是单纯的降低性能。在此基础上,双重检查锁定诞生了。

错误的双重检查锁定

双重检查锁定的目的就是替代synchronized在高频繁使用场景下的使用,降低不必要部分的性能开销,最初的尝试代码如下:

//最初的双重检查锁定尝试
public class Test {
    
    private static Object instance;

    public static Object getInstance(){
        if(instance == null){
            synchronized (Test.class){
                if(instance == null) {
                    instance = new Object();
                }
            }
        }
        return instance;
    }
}

在上述代码中,如果

  • 多条线程同时尝试创建对象,则通过synchronized加锁来保证只有一个线程能创建对象。
  • 对象已经创建完成时,则不再需要加锁,直接返回已经被创建好的对象。

这是代码理想的执行情况,同时解决了线程安全和性能开销的问题,但正如小标题所示,这是一种错误的优化方式,上述代码实际上仍然不能保证线程安全,问题见下。

最初的双重检查锁定问题所在

问题的根源其实是指令重排序

重排序

编译器和处理器为了优化程序性能,有时会对指令进行重新排序。举一个本场景下的例子,如创建对象的语句instance = new Object(),该语句可以实际分解为以下三个步骤

  1. 为对象分配内存空间
  2. 初始化对象
  3. 为instance设置所指向的内存地址

而上述三个步骤就有可能发生指令重排,实际执行顺序有可能改变为132,此种情况下,在刚刚执行完为instance设置指向时,instance已经不等于null,但实际上对象还未被初始化,这就意味着在多线程的环境下,有线程有可能获取到一个未经初始化的对象,这是一种十分糟糕的情况。

重排序引发的双重检查锁定问题

仍假设有两个线程近乎同时访问getInstance()方法,此时instance为null,线程A先于线程B访问。在不考虑指令重排序的情况下,线程A会按顺序执行如下操作

  1. 判断instance是否为空
  2. instance为空,为对象分配内存空间
  3. 初始化对象
  4. 设置instance指向的内存地址
  5. 返回instance

但经历可能的指令重排后,操作3和操作4可能会调换位置,即可能出现如下表格情况:

时间线程A线程B
t1A1:判断instance是否为null
t2A2:由于instance为null,为对象分配内存空间
t3A4:设置instance指向的内存地址
t4B1:判断instance是否为null
t5B2:由于instance不为null,返回instance
t6A3:初始化对象
t7A5:返回instance

由上执行时序表格可见,在t5时间点,线程B拿到了一个尚未初始化的对象,这显然是不应该发生且危险的情况。

双重检查锁定的改进方法

使用volatile保证可见性

首先可以对双重检查锁定进行优化,即使用volatile关键字对变量进行修饰,这样可以保证被修饰变量的可见性,即对于一个volatile变量的读,总能保证看到任意线程对这个volatile变量最后的写入。其底层实现依靠的是禁止部分指令重排。

修改后代码如下:

//使用volatile关键字修饰instance变量以(禁止指令重排)保证可见性
public class Test02 {

    private volatile static Object instance;

    public static Object getInstance(){
        if(instance == null){
            synchronized (Test02.class){
                if(instance == null) {
                    instance = new Object();
                }
            }
        }
        return instance;
    }
}
  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

7rulyL1ar

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值