不加 volatile 会导致其他线程看到未初始化完成的对象,引发 NullPointerException 或逻辑错误;因 JVM 允许构造函数与引用赋值重排序,volatile 禁止重排并保证可见性。

双重检查锁里不加 volatile 会发生什么
在多线程环境下,getInstance() 方法用双重检查锁(Double-Checked Locking)创建单例时,如果不给单例引用加 volatile,其他线程可能看到一个「已分配内存但未完成构造」的对象。这不是概率极低的巧合,而是 JVM 允许的合法重排结果。
根本原因在于:对象创建不是原子操作,它包含三步:
- 分配内存空间(
memory = allocate()) - 调用构造函数初始化对象(
ctor(memory)) - 将引用赋值给静态变量(
instance = memory)
JVM 和 CPU 可能将第 2 步和第 3 步重排序 —— 即先执行 instance = memory,再执行 ctor(memory)。此时另一个线程进入第一个 if (instance == null) 判断,发现 instance != null,直接返回这个未初始化完毕的对象,接着调用其方法就会触发 NullPointerException 或更隐蔽的逻辑错误。
volatile 如何阻止重排并保证可见性
volatile 在这里起两个作用:
立即学习“Java免费学习笔记(深入)”;
- 禁止指令重排:JVM 保证对
volatile字段的写操作不会与它之前的任何读/写操作重排序;也不会与它之后的任何读/写操作重排序 —— 这就锁死了ctor()必须在instance = ...之前完成 - 强制刷新主内存:写入
volatile变量时,当前线程的工作内存会立即刷回主内存;读取时,会强制从主内存重新加载,避免线程使用过期缓存值
注意:仅靠 synchronized 块不能解决这个问题。因为第一次判空发生在同步块外,而同步块只保障内部临界区的原子性和可见性,不约束同步块外的读操作是否能看到「构造完成」这一语义。
正确写法与常见错误对比
正确实现(Java 5+):
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(); // 这一行必须是 volatile 写入
}
}
}
return instance;
}
}
常见错误写法包括:
- 去掉
volatile:看似能编译运行,但高并发下必现偶发异常 - 把
volatile加在构造函数或方法上:语法错误,volatile只能修饰字段 - 用
final字段替代volatile:final能防止重排,但只适用于对象内部状态不可变的场景,且不能解决发布时的可见性问题(仍需volatile或其他同步机制)
为什么 Java 5 是分水岭
Java 5 之前,JVM 内存模型(JMM)未明确定义 volatile 的重排语义,不同 JVM 实现行为不一致,导致双重检查锁在某些虚拟机上“碰巧”能工作,另一些则崩溃。Java 5 修订了 JMM,正式赋予 volatile 「禁止相关重排 + 强制主内存读写」的语义,这才让双重检查锁成为可移植、可依赖的模式。
如果你还在维护 Java 4 或更老的系统,不要尝试修复这个问题 —— 直接改用静态内部类或枚举单例更安全。现代 JDK 下,漏掉 volatile 是典型的「能跑但不对」型 bug,很难复现,却会在压测或上线后突然爆发。










