第一段: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 中不是一个原子操作,它至少包含三步:
-
分配对象内存空间
-
初始化对象(调用构造方法)
-
将
instance指向分配的内存地址
在 没有 volatile 的情况下,JVM 可能会对步骤 2 和 3 进行指令重排序。
如果发生重排序,执行顺序可能变成:分配内存 → 引用指向内存 → 初始化对象。
此时如果线程 A 执行到"引用指向内存"但对象还没初始化,
线程 B 在外层第一次检查时发现 instance != null,会直接返回一个未初始化完成的"半成品对象",导致程序出错。
第五段:volatile 在 DCL 中起到的作用
volatile在这里起到两个关键作用:
-
禁止指令重排序
通过插入内存屏障,保证
instance = new Singleton()的初始化过程一定在引用赋值之前完成,防止返回未初始化对象。 -
保证可见性
确保
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 通过内存屏障禁止这种重排序,从而保证线程安全。