DCL(Double-checked Locking双重校验锁)实现单例模式的原理、问题与解决方案

复制代码
好的,要深入理解DCL(Double-Checked Locking)双重校验锁的原理、问题以及解决方法。
首先,我需要回忆一下单例模式的基本概念,因为DCL通常用于实现单例模式。
单例模式确保一个类只有一个实例,并提供一个全局访问点。

接下来,DCL的目的是为了减少同步的开销。通常,如果直接在getInstance方法上加synchronized关键字,
虽然能保证线程安全,但每次获取实例时都需要同步,这在多线程环境下可能会影响性能。
而DCL通过两次检查实例是否已经创建,来避免每次调用都进行同步,只在第一次创建实例的时候同步。

现在,我需要想一下DCL的标准写法。大概是这样的:

public class Singleton {
    private static Singleton instance; // 必须使用 volatile
    
    private Singleton() {} // 私有构造函数
    
    public static Singleton getInstance() {
        if (instance == null) {                 // 第一次检查(避免不必要的同步)
            synchronized (Singleton.class) {    // 同步块
                if (instance == null) {         // 第二次检查(确保唯一实例)
                    instance = new Singleton(); // 实例化
                }
            }
        }
        return instance;
    }
}


不过,这种写法在早期的Java中存在问题,主要涉及到指令重排序的问题。
因为instance = new Singleton(); 这个操作并不是原子性的,它分为三个步骤:

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

但由于指令重排序的可能,步骤2和步骤3可能会被颠倒,导致其他线程在第一次检查时看到instance不为null,但实际上对象还没有初始化完成,从而访问到一个不完整的对象。

为了解决这个问题,Java 5之后引入了volatile关键字,可以禁止指令重排序,
确保写操作之前的操作不会被重排序到写操作之后。因此,正确的DCL实现需要将instance声明为volatile:

private static volatile Singleton instance;

这样,使用volatile修饰instance变量,就能保证在instance被初始化完成之后,
其他线程才能看到它的值,从而避免了指令重排序带来的问题。
不过,需要确认一下volatile在Java内存模型中的具体作用。volatile关键字主要有两个作用:

1. 可见性:保证变量的修改对所有线程立即可见。
2. 禁止指令重排序:通过插入内存屏障,确保编译器和处理器不会对指令进行重排序。

在DCL中,主要是利用了volatile的第二个特性,即禁止指令重排序,确保对象的初始化在赋值操作之后完成。
另外,我还需要思考其他实现单例模式的方式,
比如静态内部类的方式,或者枚举方式,这些方式是否更简单且线程安全。

静态内部类的实现方式:
public class Singleton {
    private Singleton() {}

    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }
    public static Singleton getInstance() {
        return Holder.INSTANCE; // 类加载时初始化,由 JVM 保证线程安全
    }
}

这种方式利用了类加载机制,保证在首次访问Holder.INSTANCE时才会加载Holder类,
从而初始化Singleton实例,由JVM保证线程安全。
这种方法不需要显式的同步,且实现简单,但可能无法应对反射攻击或者序列化的问题。

枚举方式:
public enum Singleton {
    INSTANCE;
}

这种方式由JVM保证实例的唯一性和线程安全,且能防止反射和序列化破坏单例,是最推荐的方式,
但可能不够灵活,比如无法继承其他类。

回到DCL,虽然它在某些情况下仍然有用,但需要正确使用volatile关键字。
还需要验证一下如果不使用volatile是否一定会出现问题,以及这种问题发生的条件。

比如,在Java 5之前,DCL确实是不安全的,因为volatile关键字的语义不够严格,
无法完全禁止指令重排序。而在Java 5之后,JSR-133增强了volatile的内存语义,确保了DCL的正确性。

另外,我还需要了解内存屏障的概念,以及volatile如何通过内存屏障来禁止重排序。
比如,在写操作之后插入StoreStore屏障,防止写操作之前的指令被重排序到写操作之后;
在读操作之前插入LoadLoad屏障,防止读操作之后的指令被重排序到读操作之前。

总结一下,DCL的正确实现需要注意以下几点:

1. 使用双重检查来减少同步的开销。
2. 使用volatile关键字来禁止指令重排序,保证实例的可见性和初始化顺序。
3. 确保构造函数是私有的,防止外部实例化。

可能的误区是认为只要两次检查就能保证线程安全,而忽略了指令重排序的问题。
因此,volatile的使用是必不可少的。

最后,我需要思考在实际开发中,是否应该优先选择其他单例实现方式,
如枚举或静态内部类,而不是DCL,以避免潜在的复杂性和错误。
但在某些需要延迟初始化且对性能有较高要求的场景下,DCL仍然是一个合理的选择,只要正确实现。

前言:什么是单例模式

单例即单实例,只实例出来一个对象。

一般在创建一些管理器类工具类 的时候,需要用到单例模式,比如JDBCUtil类,我们只需要一个实例即可(多个实例也可以实现功能,但是增加了代码量且降低了性能)。

如何实现单例:

  • 构造方法私有化
  • 提供一个全局唯一获取该类实例的方法帮助用户获取类的实例

应用场景:

主要被用于一个全局类的对象 在多个地方被使用并且对象的状态是全局变化的场景下。

单例模式的优点:

单例模式为系统资源的优化提供了很好的思路,频繁创建和销毁对象都会增加系统的资源消耗,而单例模式保障了整个系统只有一个对象能被使用,很好地节约了资源。

单例模式的四类写法:

  • 饿汉模式
  • 懒汉模式
  • 静态内部类
  • 双重校验锁

在讲双重校验锁之前先来看一下其他模式

饿汉模式

顾名思义,饿汉模式就是加载类的时候直接new一个对象,后面直接用即可。

饿汉模式指在类中 直接定义全局的静态对象的实例初始化,然后提供一个方法获取该实例对象。

java 复制代码
public class Singleton {
    // 使用static修饰,类加载的时候new一个对象
      private static Singleton INSTANCE = new Singleton();          // 构造器私有化       private Singleton() {}              public static Singleton getInstance() {           return INSTANCE;     } }
点击并拖拽以移动

懒汉模式

顾名思义,懒汉模式就是加载类的时候只声明变量,不new对象,后面用到的时候再new对象,然后把对象赋给该变量。

定义一个私有的静态对象 INSTANCE,之所以定义INSTANCE为静态,是因为静态属性或方法是属于类的,能够很好地保障单例对象的唯一性;

然后定义一个静态方法获取该对象,如果对象为null,则 new 一个对象并将其赋值给INSTANCE。

java 复制代码
public class Singleton {
    
      private static Singleton INSTANCE;          // 构造器私有化       private Singleton() {}              public static Singleton getInstance() {           if (INSTANCE == null) {               INSTANCE = new Singleton();         }           return INSTANCE;     } }
点击并拖拽以移动

饿汉模式和懒汉模式的区别在于:

饿 汉模式是在类加载时将其实例化的,在饿汉模式下,在Class Loader完成后该类的实例便已经存在于JVM中了,即,在getInstance方法第一次被调用前该实例已经存在了,new对象的操作不在getInstance方法内。

汉模式在类中只是定义了变量但是并未实例化 ,实例化的过程是在获取单例对象的方法中实现的,即,在getInstance方法第一次被调用后该实例才会被创建 ,new对象的操作在getInstance方法内。

此外注意:

饿汉 模式的实例在类加载的时候已经存在于JVM中了,因此是线程安全的

懒汉 模式通过第一次调用getInstance才实例化,该方法不是线程安全的(后面讲怎么优化)

静态内部类

静态内部类通过在类中定义一个静态内部类,将对象实例的定义和初始化放在内部类中完成,我们在获取对象时要通过静态内部类调用其单例对象。

之所以这样设计,是因为类的静态内部类在JVM中是唯一的,这就很好地保障了单例对象的唯一性。

静态内部类的单例实现方式同样是线程安全的。

代码如下:

java 复制代码
public class Singleton {
  
      private static class SingletonHolder {           private static final Singleton INSTANCE = new Singleton();     }          private Singleton(){}          public static final Singleton getInstance(){           return SingletonHolder.INSTANCE;     } }
点击并拖拽以移动

饿汉模式和静态 内部类实现单例模式的优点是写法简单 ,缺点是不适合复杂对象的创建

对于涉及复杂对象创建的单例模式,比较优雅的实现方式是懒汉模式,

但是懒汉模式是非线程安全的,

下面就讲一下懒汉模式的升级版 ------DCL 双重构校验锁模式(双重构校验锁是线程安全的)。

双重校验锁

饿汉模式是不需要加锁来保证单例的,而懒汉模式虽然节省了内存,但是却需要使用锁来保证单例,因此,双重校验锁就是懒汉模式的升级版本。

单线程懒汉模式实现

普通的懒汉模式在单线程场景下是线程安全的,但在多线程场景下是非线程安全的。

先来看看普通的懒汉模式实现:

java 复制代码
public class Singleton {
    
      private static Singleton INSTANCE;          private Singleton() {}              public static Singleton getInstance() {           if (INSTANCE == null) {               INSTANCE = new Singleton();         }           return INSTANCE;     } }
点击并拖拽以移动

单线程懒汉模式的问题

上面这段代码在单线程环境下没有问题,但是在多线程的情况下会产生线程安全问题。

在多个线程同时调用getInstance方法时,由于方法没有加锁,可能会出现以下情况

  • ① 这些线程 可能会创建多个对象
  • ② 某个线程可能会得到一个未完全初始化的对

为什么会出现以上问题?对于 ① 的情况解释如下:

java 复制代码
public static Singleton getInstance() {
    if (INSTANCE == null) { /** * 由于没有加锁,当线程A刚执行完if判断INSTANCE为null后还没来得及执行INSTANCE = new Singleton() * 此时线程B进来,if判断后INSTANCE为null,且执行完INSTANCE = new Singleton() * 然后,线程A接着执行,由于之前if判断INSTANCE为null,于是执行INSTANCE = new Singleton()重复创建了对象 */ INSTANCE = new Singleton(); } return INSTANCE; } 
点击并拖拽以移动

对于 ② 的情况解释如下:

java 复制代码
public static Singleton getInstance() {
    if (INSTANCE == null) { /** * 由于没有加锁,当线程A刚执行完if判断INSTANCE为null后开始执行 INSTANCE = new Singleton() * 但是注意,new Singleton()这个操作在JVM层面不是一个原子操作 * *(具体由三步组成:1.为INSTANCE分配内存空间;2.初始化INSTANCE;3.将INSTANCE指向分配的内存空间, * 且这三步在JVM层面有可能发生指令重排,导致实际执行顺序可能为1-3-2) * * 因为new操作不是原子化操作,因此,可能会出现线程A执行new Singleton()时发生指令重排的情况, * 导致实际执行顺序变为1-3-2,当执行完1-3还没来及执行2时(虽然还没执行2,但是对象的引用已经有了, * 只不过引用的是一个还没初始化的对象),此时线程B进来进行if判断后INSTANCE不为null, * 然后直接把线程A new到一半的对象返回了 */ INSTANCE = new Singleton(); } return INSTANCE; } 
点击并拖拽以移动

解决问题:加锁

为了解决问题 ①,我们可以对 getInstance() 这个方法加锁。

java 复制代码
public class Singleton {
  	private static Singleton INSTANCE; private Singleton() {} public static synchronized Singleton getInstance() { // 加锁 if (INSTANCE == null) { INSTANCE = new Singleton(); } return INSTANCE; } } 
点击并拖拽以移动

仔细看,这里是粗暴地对整个 getInstance() 方法加锁,这样做代价很大,因为,只有当第一次调用 getInstance() 时才需要同步创建对象,创建之后再次调用 getInstance() 时就只是简单的返回成员变量,而这里是无需同步的,所以没必要对整个方法加锁。

由于同步一个方法会降低上百倍甚至更高的性能, 每次调用获取和释放锁的开销似乎是可以避免的:一旦初始化完成,获取和释放锁就显得很不必要。所以可以只对方法的部分代码加锁!!

java 复制代码
public class Lock2Singleton {
  	private static Lock2Singleton INSTANCE; private Lock2Singleton() {} public static Lock2Singleton getSingleton() { // 因为INSTANCE是静态变量,所以给Lock2Singleton的Claa对象上锁 synchronized(Lock2Singleton.class) { // 加 synchronized if (INSTANCE == null) { INSTANCE = new Lock2Singleton(); } } return INSTANCE; } } 
点击并拖拽以移动

优化后的代码选择了对 if (INSTANCE == null) 和 INSTANCE = new Lock2Singleton()加锁

这样,每个线程进到这个方法中之后先加锁 ,这样就保证了 if (INSTANCE == null) 和 INSTANCE = new Lock2Singleton() 这两行代码被同一个线程执行时不会有另外一个线程进来,由此保证了创建的对象是唯一的

对象的唯一性保证了,也就是解决了问题①,同时也解决了问题②。

为什么说也解决了问题②呢?synchronized不是不能禁止指令重排序吗?

其实当我们对INSTANCE == null和INSTANCE = new Lock2Singleton();加锁时,也就表示只有一个线程能进来,尽管发生了指令重排序,也只是在持有锁的期间发生了指令重排序,当该线程创建完对象释放锁时,new出来的已经是一个完整的对象。

如此,我们仿佛完美地解决了问题 ① 和 ② ,然而你以为这就结束了吗?NO!这段代码从功能层面来讲确实是已经结束了,但是性能方面呢?是不是还有可以优化的地方?

答案是:有!!

值得优化的地方就在于 synchronized代码块这里。每个线程进来,不管三七二十一,都要先进入同步代码块再说,如果说现在 INSTANCE 已经不为null了,那么,此时当一个线程进来,先获得锁,然后才会执行 if 判断。我们知道加锁是非常影响效率的,所以,如果 INSTANCE 已经不为null,是不是就可以先判断,再进入 synchronized 代码块。如下

java 复制代码
public class Lock2Singleton {

  	private static Lock2Singleton INSTANCE; private Lock2Singleton() {} public static Lock2Singleton getSingleton() { if (INSTANCE == null) { // 双重校验:第一次校验 synchronized(Lock2Singleton.class) { // 加 synchronized if (INSTANCE == null) { // 双重校验:第二次校验 INSTANCE = new Lock2Singleton(); } } } return INSTANCE; } } 
点击并拖拽以移动

在 synchronized 代码块之外再加一个 if 判断,这样,当 INSTANCE 已经存在时,线程先判断不为null,然后直接返回,避免了进入 synchronized 同步代码块

那么可能又有人问,好了,我明白了在synchronized 代码块外加一个 if 判断,是不是就意味着里面的那个 if 判断可以去掉?

当然不可以!!

如果把里面的 if 判断去掉,就相当于只对 INSTANCE = new Lock2Singleton() 这一行代码加了个锁,只对一行代码加锁,那你岂不是加了个寂寞(加锁的目的 就是防止在第二个if判断和new操作之间有别的线程进来!!),结果还是会引起问题①。

所以,两次校验,一次都不能少!!

但是,问题又来了,由于我们在外层又加了一层if (INSTANCE == null)的判断,导致原本被我们解决的问题② (即指令重排序问题)又出现了!

比如:线程A拿到锁后刚走到INSTANCE = new Lock2Singleton(),但是还没执行完,因为new Lock2Singleton()不是原子操作,且发生了指令重排序,那么此时INSTANCE就是一个不完整的对象,恰巧此时,线程B来到第一个if (INSTANCE == null)判断,由于INSTANCE不为null,结果获取到一个不完整的对象。

那么怎么解决呢?

答案是加 volatile 关键字,volatile可以禁止指令重排序

java 复制代码
public class Lock2Singleton {
  	private volatile static Lock2Singleton INSTANCE; // 加 volatile private Lock2Singleton() {} public static Lock2Singleton getSingleton() { if (INSTANCE == null) { // 双重校验:第一次校验 synchronized(Lock2Singleton.class) { // 加 synchronized if (INSTANCE == null) { // 双重校验:第二次校验 INSTANCE = new Lock2Singleton(); } } } return INSTANCE; } } 
点击并拖拽以移动

一、DCL 的基本实现

DCL(Double-Checked Locking)旨在减少同步开销,仅在首次创建实例时使用同步,同时保证线程安全。

标准代码模板

复制代码
public class Singleton {
    private static volatile Singleton instance; // 必须使用 volatile
    
    private Singleton() {} // 私有构造函数
    
    public static Singleton getInstance() {
        if (instance == null) {                 // 第一次检查(避免不必要的同步)
            synchronized (Singleton.class) {    // 同步块
                if (instance == null) {         // 第二次检查(确保唯一实例)
                    instance = new Singleton(); // 实例化
                }
            }
        }
        return instance;
    }
}
点击并拖拽以移动

二、DCL 的核心问题

1. 指令重排序问题

  • 实例化操作的非原子性
    instance = new Singleton() 分解为三步:

    1. 分配内存空间

    2. 初始化对象

    3. 将引用指向内存地址

  • 可能的指令重排序

    若步骤2和3被重排序,其他线程可能访问到未初始化的对象(导致空指针异常)。

2. 可见性问题

未使用 volatile 时,一个线程的写操作可能对其他线程不可见。


三、解决方案:volatile 关键字

volatile 的作用

  1. 禁止指令重排序

    • 通过内存屏障(Memory Barrier)确保:

      • 操作前的指令不会被重排序到操作之后。

      • 操作后的指令不会被重排序到操作之前。

  2. 保证可见性

    • 修改 volatile 变量后,强制刷新到主内存

    • 其他线程读取时直接从主内存加载。


四、DCL 的演进与 JVM 版本兼容性

Java 版本 DCL 安全性 原因
Java 1.4 及之前 不安全 volatile 语义不完整
Java 5(JSR-133)及之后 安全 volatile 增强内存屏障语义

五、替代单例实现方案

1. 静态内部类(Holder 模式)

复制代码
public class Singleton {
    private Singleton() {}
    
    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }
    
    public static Singleton getInstance() {
        return Holder.INSTANCE; // 类加载时初始化,由 JVM 保证线程安全
    }
}
点击并拖拽以移动
  • 优点:无锁、线程安全、延迟加载。

  • 缺点:无法防止反射或反序列化破坏单例。

2. 枚举单例(推荐)

复制代码
public enum Singleton {
    INSTANCE; // 由 JVM 保证唯一性
    
    public void doSomething() {
        // 方法实现
    }
}
点击并拖拽以移动
  • 优点

    • 线程安全。

    • 天然防反射和反序列化破坏。

  • 缺点:无法继承其他类。


六、DCL 的正确使用场景

  • 延迟初始化:仅在需要时创建实例。

  • 性能敏感:避免每次调用同步的开销。

  • 兼容性要求:需支持 Java 5 及以上版本。


七、常见误区与验证

1. 错误:省略 volatile

复制代码
private static Singleton instance; // 缺少 volatile
点击并拖拽以移动
  • 后果:可能返回未完全初始化的对象(指令重排序导致)。

2. 错误:单次检查

复制代码
public static Singleton getInstance() {
    if (instance == null) { // 单次检查
        synchronized (Singleton.class) {
            instance = new Singleton();
        }
    }
    return instance;
}
点击并拖拽以移动
  • 后果:多线程环境下可能创建多个实例。

八、内存屏障与 JVM 实现细节

  • 写操作屏障:

    • StoreStore 屏障:禁止普通写与 volatile 写重排序。

    • StoreLoad 屏障:强制刷新写缓存到主内存。

  • 读操作屏障:

    • LoadLoad 屏障:禁止 volatile 读与后续普通读重排序。

    • LoadStore 屏障:禁止 volatile 读与后续普通写重排序。


九、总结

  • DCL 要点

    • 双重检查减少同步开销。

    • volatile 禁止指令重排序,保证可见性。

  • 适用场景:需要延迟初始化且对性能有要求的单例实现。

  • 替代方案:优先考虑枚举或静态内部类实现单例。

正确实现 DCL 需严格遵循代码模板,避免遗漏 volatile 关键字,以确保线程安全和对象初始化的正确性。

相关推荐
知识分享小能手5 个月前
Java学习教程,从入门到精通,Java 变量命名规则(12)
java·大数据·开发语言·python·学习·java学习·java后端开发
nshkfhwr1 年前
Java之妙:最好的开始
java·学习方法·快速入门·java学习·最丝滑的开始·少女之妙·理解与解构