volatile 这题别只答「可见性」:面试官真正想听的是这条 happens-before

很多人答 volatile,开口就是「保证可见性,禁止指令重排」。不能说错,但这两句太像背书了,面试官往下问一句「那为什么 count++ 还是不行」,很容易就断在这里。
把它换成一条可推导的规则会稳很多:对同一个 volatile 变量,一次写入 happens-before 之后任意线程对它的读取。再加上线程内的代码顺序,前面的普通写就能跟着一起被发布出去。
看一个常见的状态发布场景:
java
class TaskResult {
private int value;
private volatile boolean ready;
void publish() {
value = 42;
ready = true;
}
int awaitResult() {
while (!ready) {
Thread.onSpinWait();
}
return value;
}
}
当读线程看到 ready == true 时,它不该再看到旧的 value。理由不是「volatile 很神奇」,而是这条链:
text
value = 42
→ ready = true(同一线程内的程序顺序)
→ 读取到 ready == true(volatile 写与读)
→ return value
Java 语言规范把这个关系叫 happens-before。前一个动作对后一个动作可见,并且在内存模型的语义上排在前面。
这里有个容易漏掉的前提:读线程得先读到那个 volatile 状态。要是代码先读 value,再看 ready,这条发布链就被你自己拆开了。

为什么 count++ 还是会丢
java
class Metrics {
private volatile int count;
void record() {
count++;
}
}
count++ 不是一个不可分割的动作。它至少包含读旧值、加一、写回新值。
两个线程都读到 10,各自加一再写回 11,最后就少记了一次。volatile 让彼此更容易看到最新写入,但没有把这三个动作捆成一次。
这种场景该用 AtomicInteger.incrementAndGet(),或者用锁把「读、改、写」包起来。面试里可以直接按数据语义选:
- 只是发布一个状态或引用:volatile 合适。
- 需要一个数的原子增减:原子类合适。
- 多个字段必须一起满足约束:锁更直白。
比如库存和已售数要同时更新,哪怕两者都加 volatile 也没有意义。问题不在单个字段看不见,而在这组更新不能被别的线程看见一半。
双重检查锁为什么一定要写 volatile
单例是这题最常见的追问:
java
class Config {
private static volatile Config instance;
static Config getInstance() {
if (instance == null) {
synchronized (Config.class) {
if (instance == null) {
instance = new Config();
}
}
}
return instance;
}
}
没有 volatile 时,别只说「可能重排序」。把现象讲出来:另一个线程可能先看到一个非 null 的引用,却还没看到构造阶段写进去的完整状态。
给 instance 加 volatile 后,构造完成前的写入会随着这次 volatile 写发布;读到非 null 的线程再返回它,看到的是已经发布完成的对象状态。
这不表示 volatile 能替代 synchronized。这里的 synchronized 还在做「只能创建一次」的互斥,volatile 解决的是锁外那次读取能不能安全拿到对象。
面试时我会先问自己这三个问题
不用急着抛术语,先判断操作本身:
- 这是单次读写,还是读完还要根据旧值再写?
- 读线程有没有一个明确的状态变量,作为发布完成的信号?
- 这次修改会不会同时影响其他字段的不变量?
第一类才是 volatile 的主场。第二类决定状态发布能不能成立。第三类一旦回答「会」,基本就该往锁、原子类或并发容器上想了。
本文核对了 Java SE 21《Java Language Specification》第 17.4.4、17.4.5 节:volatile 写与后续读取的 synchronizes-with 关系,以及 happens-before 的传递规则。