高并发下双重检测锁DCL指令重排问题剖析


在这里插入图片描述

一、引言

1.1 双重检查锁定(Double-Checked Locking,简称DCL)定义介绍

双重检查锁定(Double-Checked Locking)是一种并发设计模式,该模式减少了同步的开销,提高了执行效率。该模式通过两次检查锁定,确保被检查的代码的线程安全性。在第一次检查中,如果发现变量不满足条件,才进行加锁操作。然后在锁定的区块内再进行一次检查,如果仍不满足条件,才进行相关操作。

1.2 高并发环境下DCL的应用和优势

在高并发环境下,DCL可以显著提高性能。在使用单例模式时,如果没有并发考虑,可能每次访问单例对象时都需要获取同步锁,这会大大影响程序的执行效率。而DCL模式可以避免这个问题,它只在第一次实例化时加锁,之后的访问都不需要获取锁,这大大降低了锁的开销,提高了程序的执行效率。但要注意,由于JVM的指令重排优化,DCL在某些情况下可能会失效,需要慎重使用。

二、DCL存在的问题

2.1 DCL的代码示例

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

这段代码是典型的DCL实现单例模式的例子。在getInstance()方法中,先检查instance是否为null,如果为null,才对Singleton.class对象加锁,然后在锁定区域内再次检查instance是否为null,如果还是null,就创建一个Singleton实例。

2.2 指令重排的定义和工作原理

指令重排是为了提高处理器性能,允许编译器和处理器调整指令的执行顺序。一旦保证最终执行结果与代码顺序执行的结果一致,即使没有按照代码原有的顺序执行也不影响。

2.3 指令重排导致DCL失效的情况分析

在上述DCL代码示例中,instance = new Singleton();这行代码实际上涉及到三个操作:

1)为Singleton分配内存空间;
2)调用Singleton的构造函数,初始化成员字段;
3)将instance对象指向分配的内存空间。

但由于JVM的指令重排优化,执行顺序可能变成1-3-2。也就是说,先为Singleton分配内存空间,然后将instance指向该内存空间,最后调用Singleton的构造函数。在多线程环境下,如果一个线程执行到3,另一个线程刚好执行到第一次检查,发现instance不为null,就直接返回instance,此时得到的Singleton实例其实是未初始化的。这就是JVM的指令重排导致DCL失效的情况。

三、深入分析指令重排和DCL的问题

3.1 示例代码中Singleton对象创建过程的指令重排可能性

在我们的示例代码中,创建Singleton对象的过程,原本的执行顺序是1-2-3,但是由于JVM优化,可能被重新排序为1-3-2。

这种指令的重排,并不是随机的,JVM采用的是"as-if-serial"语义,也就是说,在不改变单线程程序执行结果的前提下,JVM可以对指令进行重新排序。

扫描二维码关注公众号,回复: 16834978 查看本文章

3.2 执行顺序的变化导致DCL无法正确工作的剖析

由于JVM的指令重排优化,如果执行顺序变为1-3-2,虽然在单线程环境下程序的结果并未改变,但是在多线程环境下,可能导致DCL无法正确工作。

具体来说,当一个线程正在执行到步骤3,也就是将instance指向分配的内存空间,但是还没有执行到步骤2,即初始化Singleton对象。此时,如果另一个线程执行到第一次检查instance是否为null,由于instance已经指向了一个内存空间,所以检查结果不为null,于是直接返回instance。但此时返回的Singleton对象其实还没有被初始化,就会出现问题。

3.3 多线程环境下由于指令重排导致的数据不一致

在多线程环境下,由于指令重排,可能导致数据的不一致。因为指令重排会改变代码的执行顺序,而在多线程环境下,线程之间是并发执行的,对于共享变量的操作顺序,可能会出现预期之外的结果。

例如,在上述例子中,由于指令重排,导致Singleton对象在被一个线程使用前,其实还没有被完全初始化,这就是一个典型的由于指令重排导致的数据不一致的问题。

四、解决方案探究

4.1 volatile关键字的介绍和应用

volatile是Java提供的一种轻量级的同步机制。它有两个主要的特性:保证可见性和禁止指令重排。保证可见性指的是当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去主存中读取新值。而禁止指令重排则是通过插入内存屏障来实现的。

4.2 利用volatile关键字解决DCL问题的示例和分析

我们可以通过给instance变量添加volatile关键字来解决DCL的问题。代码如下:

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

在这个实例中,volatile会强制将对instance的写操作刷新到主存,这样当其他线程去读取instance的时候,将总是读取到最新的值。同时,volatile也可以防止JVM对指令进行重新排序,从而避免出现我们之前提到的问题。

4.3 其他解决DCL问题的方案及其优缺点比较

  1. 急切初始化:这种方式是在类加载时就马上创建实例,优点是实现简单,线程安全,但是缺点是如果这个实例很少被使用,那么这种方式就显得有些浪费资源。
  2. 使用静态内部类:利用了Java的类加载机制来保证初始化instance时只有一个线程,这种方式既实现了线程安全,也达到了懒加载的效果,是一种比较推荐的方式。
  3. 使用枚举:这是《Effective Java》作者Josh Bloch 提倡的方式,不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化,是一种更简洁、高效的方式。

5. 参考资料

  1. 《深入理解Java虚拟机:JVM高级特性与最佳实践》周志明。
  2. 《Effective Java》
  3. java 内存模型

猜你喜欢

转载自blog.csdn.net/wangshuai6707/article/details/132989903