volatile 关键字
问题
volatile 的作用是什么?它如何保证可见性和有序性?为什么不能保证原子性?DCL 单例中为什么要用 volatile?
答案
volatile 的两大作用
- 保证可见性:一个线程修改 volatile 变量后,其他线程立即可见
- 禁止指令重排序:通过内存屏障阻止编译器和 CPU 对 volatile 变量相关的指令进行重排
volatile 只保证单次读/写的原子性,不保证复合操作(如 i++)的原子性。
可见性问题
没有 volatile 时,线程可能读到其他线程修改前的旧值:
public class VisibilityDemo {
// 没有 volatile,线程 B 可能永远看不到 flag 的变化
private boolean flag = false;
// 线程 A
public void writer() {
flag = true; // 修改 flag
}
// 线程 B
public void reader() {
while (!flag) {
// 可能死循环!因为线程 B 看不到 flag 的变化
// 线程 B 工作内存中 flag 一直是 false
}
System.out.println("flag 变为 true");
}
}
原因:根据 Java 内存模型(JMM),每个线程有自己的工作内存(CPU 缓存),变量修改可能只写入工作内存而未同步到主内存。
加上 volatile 后:
private volatile boolean flag = false;
// 线程 A 写 flag = true 时,会立即刷新到主内存
// 线程 B 读 flag 时,会从主内存重新读取
指令重排序问题
编译器和 CPU 会对指令进行重排序以优化性能,但重排可能导致多线程问题:
int a = 0;
boolean flag = false;
// 线程 A
a = 1; // ①
flag = true; // ②
// 可能被重排为 ② → ①
// 线程 B
if (flag) { // ③
int b = a; // ④ —— 可能读到 a = 0(如果 ② 在 ① 之前执行)
}
volatile 通过内存屏障禁止特定类型的重排序:
| 屏障类型 | 规则 | 效果 |
|---|---|---|
| LoadLoad | volatile 读之后的读不能重排到之前 | 保证读到最新值后再进行后续读 |
| LoadStore | volatile 读之后的写不能重排到之前 | 保证读到最新值后再进行后续写 |
| StoreStore | volatile 写之前的写不能重排到之后 | 保证之前的写都完成后才写 volatile |
| StoreLoad | volatile 写之后的读不能重排到之前 | 保证写完 volatile 后再进行后续读 |
volatile 写操作前插入 StoreStore 屏障,后插入 StoreLoad 屏障;volatile 读操作后插入 LoadLoad 和 LoadStore 屏障。
volatile 不保证原子性
private volatile int count = 0;
// 多线程执行 increment(),最终 count 不一定等于预期值
public void increment() {
count++; // 不是原子操作!
// 实际分为三步:
// 1. 读取 count 的值(volatile 读)
// 2. count + 1(普通计算)
// 3. 写回 count(volatile 写)
// 在步骤 1 和 3 之间,其他线程可能已经修改了 count
}
解决方案:
- 使用
AtomicInteger(CAS) - 使用
synchronized或Lock
详见 CAS 与原子类。
经典应用:DCL 单例模式
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;
}
}
为什么必须用 volatile?
new Singleton() 在 JVM 层面分为三步:
- 分配内存空间
- 初始化对象(执行构造函数)
- 将引用指向内存地址(instance = 引用)
步骤 ② 和 ③ 可能被重排序为 ① → ③ → ②:
加上 volatile 后,禁止 ② 和 ③ 的重排序,保证其他线程拿到的 instance 一定是初始化完成的。
volatile 的适用场景
| 场景 | 说明 |
|---|---|
| 状态标志 | volatile boolean running = true |
| DCL 单例 | 防止指令重排 |
| 轻量级读写 | 一写多读场景 |
| happens-before 传递 | 配合其他同步机制使用 |
- 对变量的写操作不依赖当前值(否则不能保证原子性)
- 变量没有包含在其他变量的不变式中
- 只需要可见性和有序性保证,不需要原子性
常见面试问题
Q1: volatile 能保证原子性吗?
答案:
不能。volatile 只能保证单次读或单次写的原子性(long/double 在 32 位 JVM 上的原子性),但不能保证复合操作(如 i++、check-then-act)的原子性。需要原子操作应使用 AtomicXxx 类或加锁。
Q2: volatile 和 synchronized 的区别?
答案:
| 对比 | volatile | synchronized |
|---|---|---|
| 本质 | 轻量级同步 | 互斥锁 |
| 原子性 | 不保证复合操作原子性 | 保证 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证 | 保证 |
| 阻塞 | 不会阻塞 | 可能阻塞 |
| 编译器优化 | 禁止部分优化 | 禁止部分优化 |
| 适用范围 | 变量 | 代码块、方法 |
volatile 是 synchronized 的轻量级替代,适用于"一写多读"场景。
Q3: DCL 单例中不加 volatile 会怎样?
答案:
不加 volatile,new Singleton() 可能发生指令重排序(先赋值引用再初始化对象),导致其他线程拿到未初始化完成的对象,使用时出现 NPE 或数据错误。volatile 通过 StoreStore 屏障禁止了这种重排序。
Q4: volatile 底层是如何实现的?
答案:
- 编译器层面:编译器不会将 volatile 变量缓存在寄存器中,每次读写都直接操作主内存
- CPU 层面:volatile 写操作后会插入 StoreLoad 屏障(对应 x86 的
lock前缀指令),该指令会:- 将当前 CPU 缓存行的数据写回主内存
- 使其他 CPU 缓存中该数据的缓存行失效(MESI 协议)
Q5: volatile 的 happens-before 规则是什么?
答案:
volatile 变量规则:对一个 volatile 变量的写操作 happens-before 后续对该变量的读操作。
结合传递性,volatile 可以实现轻量级的同步:
int a = 0;
volatile boolean flag = false;
// 线程 A
a = 42; // ① 普通写
flag = true; // ② volatile 写
// 线程 B
if (flag) { // ③ volatile 读
// 由于 ② hb ③,且 ① hb ②(程序顺序),根据传递性 ① hb ③
// 所以线程 B 一定能看到 a = 42
assert a == 42; // ✅ 成立
}
详见 Java 内存模型(JMM)。
Q6: long 和 double 的原子性问题?
答案:
JMM 规定,对于 64 位的 long 和 double 类型,在 32 位 JVM 上的读写操作不保证原子性(可能分为两次 32 位操作)。加上 volatile 后可以保证其单次读写的原子性。
不过在 64 位 JVM(目前主流)上,即使不加 volatile,long/double 的读写也是原子的。
相关链接
- Java Language Specification - volatile
- Java 内存模型(JMM) - happens-before、内存屏障
- CAS 与原子类 - volatile + CAS 实现原子类
- synchronized 关键字 - volatile vs synchronized
- 单例模式 - DCL 单例完整实现