DLC(Double-check Locking)与volatile的详解

第一段:DCL 是什么 & 为什么要用

DCL(Double-Checked Locking,双重检查锁) ​ 是一种在单例模式中常用的懒加载优化手段。它的核心目的是在保证线程安全的前提下,尽量减少 synchronized带来的性能开销。传统的懒汉式单例每次获取实例都要加锁,而 DCL 只在实例未创建时加锁,实例创建后直接返回,从而兼顾了延迟加载高性能


第二段:DCL 的经典代码写法

标准的 DCL 单例实现如下(注意 volatile关键字):

java 复制代码
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;
    }
}

第三段:为什么要进行两次判空(Double-Check)

  • 第一次检查(外层的 if)

    目的是避免不必要的加锁。如果实例已经创建,直接返回,完全不走 synchronized,性能极高。

  • 第二次检查(内层的 if)

    目的是防止多个线程同时通过第一次检查后,重复创建对象。

    假设线程 A、B 同时发现 instance == null,A 先拿到锁创建对象,释放锁后 B 进入同步块,如果没有第二次检查,B 会再次 new Singleton(),导致实例不唯一。


第四段:为什么必须加 volatile(核心考点)

这是 DCL 面试中最重要的问题。

instance = new Singleton()在 JVM 中不是一个原子操作,它至少包含三步:

  1. 分配对象内存空间

  2. 初始化对象(调用构造方法)

  3. instance指向分配的内存地址

没有 volatile ​ 的情况下,JVM 可能会对步骤 2 和 3 进行指令重排序

如果发生重排序,执行顺序可能变成:分配内存 → 引用指向内存 → 初始化对象。

此时如果线程 A 执行到"引用指向内存"但对象还没初始化,

线程 B 在外层第一次检查时发现 instance != null,会直接返回一个未初始化完成的"半成品对象",导致程序出错。


第五段:volatile 在 DCL 中起到的作用

volatile在这里起到两个关键作用:

  1. 禁止指令重排序

    通过插入内存屏障,保证 instance = new Singleton()的初始化过程一定在引用赋值之前完成,防止返回未初始化对象。

  2. 保证可见性

    确保 instance的写操作对其他线程立即可见,避免读到旧值或过期的引用。


第六段:一句话总结(面试收尾)

DCL 通过"两次判空 + synchronized"实现高性能懒加载单例,

volatile的作用是防止对象创建过程中的指令重排序,

确保返回的对象一定是完全初始化后的实例。

我按 **"对象创建流程 → 重排序风险 → 多线程视角 → 实际后果"**​ 给你彻底拆开讲。


一、new Singleton()在 JVM 中真实发生了什么?

源码只有一行:

java 复制代码
instance = new Singleton();

但在 JVM 层面,它至少拆成 3 个步骤

复制代码
1. 分配内存空间(memory = allocate())
2. 初始化对象(ctorInstance())
3. 将引用指向内存地址(instance = memory)

二、为什么 JVM 会重排序?

1. 重排序的目的

JVM 和 CPU 为了性能:

  • 允许 不改变单线程语义​ 的情况下

  • 调整指令执行顺序

👉 只要单线程下结果正确,就可以重排


2.在没有 volatile 的情况下

JVM 认为:

复制代码
初始化对象
将引用指向对象

这两步:

  • 在单线程下结果一致

  • 所以 允许重排序


三、重排序后的执行顺序(关键)

正常顺序

复制代码
① 分配内存
② 初始化对象
③ instance 指向内存

此时:

  • instance != null

  • 对象一定已经初始化完成 ✅


重排序后(问题发生)

复制代码
① 分配内存
② instance 指向内存   ← 提前赋值
③ 初始化对象

⚠️ 注意:

  • instance已经 非 null

  • 但对象 还没初始化完成


四、多线程视角:到底哪里炸了?

线程 A(第一个进入)

java 复制代码
synchronized (Singleton.class) {
    if (instance == null) {
        instance = new Singleton(); // 发生重排序
    }
}

执行顺序:

复制代码
分配内存
instance = 内存地址   // 还没调用构造方法

此时:

  • instance != null

  • instance.someField == 0(默认值)


线程 B(并发访问)

复制代码
if (instance == null) { // 第一次检查(无锁)
    ...
}
return instance;

线程 B:

  • 没有进入 synchronized

  • 看到 instance != null

  • 直接返回一个 "半初始化对象"

👉 后果:

  • 调用对象方法

  • 使用未初始化的字段

  • 出现不可预测行为


五、为什么 synchronized 拦不住这个问题?

这是一个高频追问点

原因:

  • synchronized 只保证互斥

  • 不禁止指令重排序

    synchronized 内:
    instance = new Singleton(); // 仍然可能重排序

👉 所以:

  • 线程 A 在同步块内创建"半成品对象"

  • 线程 B 在同步块外直接拿到引用


六、volatile 是如何解决问题的?

1. volatile 写的内存屏障

复制代码
private static volatile Singleton instance;

instance = new Singleton();变成:

复制代码
StoreStore
instance = new Singleton();
StoreLoad

2. 屏障的作用

屏障 效果
StoreStore 禁止普通写与 volatile 写重排
StoreLoad 禁止 volatile 写与后续读重排

保证初始化一定在引用赋值之前完成


3. 结果

复制代码
① 分配内存
② 初始化对象
③ instance 指向内存

👉 顺序 不可被打乱


七、一句话总结(面试标准答案)

new Singleton()不是原子操作,

在缺少 volatile 的情况下,JVM 可能将"对象初始化"和"引用赋值"重排序,

导致线程在对象尚未初始化完成时拿到非 null 引用,

而 volatile 通过内存屏障禁止这种重排序,从而保证线程安全。

相关推荐
环流_5 小时前
多线程5(单例模式)
单例模式
断眉的派大星3 天前
单例模式使用
单例模式
CoderMeijun5 天前
C++ 单例模式:饿汉模式与懒汉模式
c++·单例模式·设计模式·饿汉模式·懒汉模式
A.A呐7 天前
【C++第二十八章】单例模式
c++·单例模式
沉淀粉条形变量8 天前
rust 单例模式
开发语言·单例模式·rust
Lyyaoo.9 天前
【JAVA基础面经】线程安全的单例模式
java·安全·单例模式
zhaoshuzhaoshu9 天前
设计模式之创建型设计模式详细解析(含示例)
单例模式·设计模式·架构
梦游钓鱼9 天前
c++中单例模式(局部静态变量)
开发语言·c++·单例模式
游乐码9 天前
c#单例模式
单例模式·c#