在 Java 开发中,单例模式是我们最常用的设计模式之一。为了实现延迟加载并保证线程安全,双重检查锁(Double-Checked Locking,简称 DCL)是一种非常经典的写法。
然而,很多人在手写 DCL 单例时,往往只记得加上 synchronized 同步锁,却忽略了极其关键的 volatile 关键字。今天我们就来盘一盘,如果不加 volatile 会引发什么问题,以及它背后的底层原理。
1. 标准的 DCL 单例实现代码
先来看一个完全正确的 DCL 单例模式写法。这里有三个不可或缺的关键点:私有化构造方法、静态获取方法,以及最重要的 volatile 修饰实例变量。
public class Pig {
// 核心关键点:必须使用 volatile 修饰,防止指令重排
private static volatile Pig pig = null;
// 关键点:私有化构造方法,防止外部直接 new 对象
private Pig() {
}
// 关键点:提供全局访问的静态方法
public static Pig getInstance() {
// 第一重检查:如果对象已经实例化,直接返回,避免不必要的加锁开销
if (pig == null) {
// 加锁,保证同一时刻只有一个线程进入初始化逻辑
synchronized (Pig.class) {
// 第二重检查:防止多个线程同时通过了第一重检查,导致重复初始化
if (pig == null) {
pig = new Pig();
}
}
}
return pig;
}
}2. 为什么不加 volatile 会导致“半初始化”?
很多同学会有疑问:既然已经有了 synchronized 保证了同步,为什么还会出问题?
问题的根源出在 pig = new Pig(); 这行代码上。在 Java 中,创建一个对象并不是一个原子操作。在 JVM 层面,这行简单的代码大致会被拆分为以下三个汇编指令步骤:
分配内存空间(
allocate):在堆内存中为Pig对象分配一块空间。初始化对象(
init):调用Pig的构造方法,初始化成员变量。设置引用指向(
assign):将pig这个引用变量指向刚分配出的内存地址。
危险的指令重排: 在单线程环境下,这三步无论按什么顺序执行,最终结果都是一致的。因此,为了优化执行效率,编译器和 CPU 可能会对这些指令进行重排(Instruction Reordering)。实际的执行顺序很可能会变成 1 -> 3 -> 2。
高并发下的灾难现场: 假设发生了 1 -> 3 -> 2 的指令重排,现在有线程 A 和线程 B 同时调用 getInstance():
线程 A 获取到锁,执行了步骤 1 和步骤 3。此时,
pig引用已经指向了具体的内存地址,它已经不再是null了,但是真正的对象初始化(步骤 2)还未执行!线程 B 此时正好执行到第一重检查
if (pig == null),发现pig已经不为空,于是直接拿走这个还没初始化的“半成品”对象去使用。最终结果:线程 B 在访问该对象的成员变量或方法时,极大概率会获取到默认值,甚至抛出
NullPointerException异常。
3. volatile 是如何破局的?
为了解决这个致命隐患,我们需要在声明实例变量时加上 volatile 关键字。
volatile 在这里的核心作用就是禁止指令重排。它的底层实现依赖于内存屏障(Memory Barrier)技术。
简单来说,给变量加上 volatile 后,就相当于给 JVM 下了一道死命令:在对该变量进行写操作时,必须确保其前面的代码(如内存分配、对象初始化)全部执行完毕。 JVM 会在汇编指令中插入特定的屏障指令(如 StoreStore 和 StoreLoad 屏障),强行打断 CPU 的重排优化,从而保证了 1 -> 2 -> 3 的严格执行顺序。
4. 总结
在编写 DCL 单例模式时,两把锁各司其职,缺一不可:
synchronized:保证了复合操作的原子性和线程间的可见性,解决并发冲突导致的重复实例化问题。volatile:保证了指令执行的有序性,依靠内存屏障防止指令重排,彻底杜绝了高并发下拿到“半初始化”对象的系统隐患。