跳到主要内容

volatile 关键字

问题

volatile 的作用是什么?它如何保证可见性和有序性?为什么不能保证原子性?DCL 单例中为什么要用 volatile?

答案

volatile 的两大作用

  1. 保证可见性:一个线程修改 volatile 变量后,其他线程立即可见
  2. 禁止指令重排序:通过内存屏障阻止编译器和 CPU 对 volatile 变量相关的指令进行重排
volatile 不保证原子性

volatile 只保证单次读/写的原子性,不保证复合操作(如 i++)的原子性。

可见性问题

没有 volatile 时,线程可能读到其他线程修改前的旧值:

VisibilityProblem.java
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 会对指令进行重排序以优化性能,但重排可能导致多线程问题:

ReorderProblem.java
int a = 0;
boolean flag = false;

// 线程 A
a = 1; // ①
flag = true; // ②
// 可能被重排为 ② → ①

// 线程 B
if (flag) { // ③
int b = a; // ④ —— 可能读到 a = 0(如果 ② 在 ① 之前执行)
}

volatile 通过内存屏障禁止特定类型的重排序:

屏障类型规则效果
LoadLoadvolatile 读之后的读不能重排到之前保证读到最新值后再进行后续读
LoadStorevolatile 读之后的写不能重排到之前保证读到最新值后再进行后续写
StoreStorevolatile 写之前的写不能重排到之后保证之前的写都完成后才写 volatile
StoreLoadvolatile 写之后的读不能重排到之前保证写完 volatile 后再进行后续读

volatile 写操作前插入 StoreStore 屏障,后插入 StoreLoad 屏障;volatile 读操作后插入 LoadLoad 和 LoadStore 屏障。

volatile 不保证原子性

NonAtomicVolatile.java
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)
  • 使用 synchronizedLock

详见 CAS 与原子类

经典应用:DCL 单例模式

DoubleCheckedLocking.java
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 层面分为三步:

  1. 分配内存空间
  2. 初始化对象(执行构造函数)
  3. 将引用指向内存地址(instance = 引用)

步骤 ② 和 ③ 可能被重排序为 ① → ③ → ②:

加上 volatile 后,禁止 ② 和 ③ 的重排序,保证其他线程拿到的 instance 一定是初始化完成的。

volatile 的适用场景

场景说明
状态标志volatile boolean running = true
DCL 单例防止指令重排
轻量级读写一写多读场景
happens-before 传递配合其他同步机制使用
volatile 使用原则
  1. 对变量的写操作不依赖当前值(否则不能保证原子性)
  2. 变量没有包含在其他变量的不变式中
  3. 只需要可见性和有序性保证,不需要原子性

常见面试问题

Q1: volatile 能保证原子性吗?

答案

不能。volatile 只能保证单次读或单次写的原子性(long/double 在 32 位 JVM 上的原子性),但不能保证复合操作(如 i++check-then-act)的原子性。需要原子操作应使用 AtomicXxx 类或加锁。

Q2: volatile 和 synchronized 的区别?

答案

对比volatilesynchronized
本质轻量级同步互斥锁
原子性不保证复合操作原子性保证
可见性保证保证
有序性保证保证
阻塞不会阻塞可能阻塞
编译器优化禁止部分优化禁止部分优化
适用范围变量代码块、方法

volatile 是 synchronized 的轻量级替代,适用于"一写多读"场景。

Q3: DCL 单例中不加 volatile 会怎样?

答案

不加 volatile,new Singleton() 可能发生指令重排序(先赋值引用再初始化对象),导致其他线程拿到未初始化完成的对象,使用时出现 NPE 或数据错误。volatile 通过 StoreStore 屏障禁止了这种重排序。

Q4: volatile 底层是如何实现的?

答案

  1. 编译器层面:编译器不会将 volatile 变量缓存在寄存器中,每次读写都直接操作主内存
  2. 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 的读写也是原子的。

相关链接