• 高并发下双重检测锁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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    这段代码是典型的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可以对指令进行重新排序。

    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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    在这个实例中,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 内存模型
  • 相关阅读:
    Mac M2芯片配置PHP环境
    03-视口
    java中的多态
    UEFI统一可扩展固件接口
    oh,我这个大佬盆友教我整机器学习
    第二十一条:为传诸后世而设计接口
    JEECMS安装部署方法以及使用说明教程
    Mask R-CNN复现报错无法解决
    从Google角度看:Android渲染体系设计→Flutter渲染体系设计
    jquery常用方法积累
  • 原文地址:https://blog.csdn.net/wangshuai6707/article/details/132989903