volatile、屏障与可见性
volatile 适合表示一个可以独立读写的共享变量,或者充当状态发布信号。它为读写提供可见性和顺序保证,但不为多步操作或多个字段的不变量提供互斥。
1. volatile 写与后续读建立顺序
private volatile boolean closed;
public void close() {
closed = true;
}
public void runLoop() {
while (!closed) {
processOne();
}
}
对 closed 的 volatile 写 happens-before 另一个线程随后观察到该写的 volatile 读。JVM 不能把相关访问重排到破坏这条关系的位置,读线程也必须按规范观察值。
它适合状态标记、不可变配置引用和满足单写者等条件的状态机。
2. volatile 不保护复合更新
private volatile int count;
public void increment() {
count++;
}
count++ 是读取、加一、写回。每次读和写具有 volatile 语义,整个组合仍可被其他线程穿插,因此会丢失更新。需要精确计数时使用 AtomicInteger.incrementAndGet() 或锁。
同样,下面的检查后执行也不安全:
if (!initialized) {
initialize();
initialized = true;
}
多个线程可能同时通过检查。volatile 不能代替互斥。
3. 发布的是一条有方向的边
private Data data;
private volatile boolean ready;
void publish(Data next) {
data = next;
ready = true;
}
消费线程先读到 ready == true,再读取 data,可以利用传递的 happens-before 看到发布前的普通写。若先读 data、后读 ready,或者读的是另一个标志,就没有这条同样的证明。
数组引用声明为 volatile 也只约束引用本身,普通数组元素不会因此全部获得 volatile 语义。需要原子数组时使用 AtomicIntegerArray 等类型。
4. 内存屏障是实现手段
JIT 会根据目标处理器把 volatile 与 VarHandle 的访问模式映射为相应的编译器约束、机器指令和屏障。不同架构的成本与具体屏障组合并不相同。
工程代码应围绕 acquire/release 和 happens-before 表达需求,不要把某张 x86 屏障表当成 Java 规范。需要更细粒度语义的底层组件可以使用 VarHandle 的 plain、opaque、acquire、release 和 volatile 模式,但普通业务代码很少需要主动降低保证。
5. 典型使用边界
| 需求 | volatile 是否合适 | 原因 |
|---|---|---|
| 关闭标记 | 通常合适 | 单个状态读写 |
| 发布不可变配置 | 合适 | 引用切换和初始化写建立顺序 |
| 多线程精确累加 | 不合适 | 需要原子读改写 |
| 余额检查并扣减 | 不合适 | 多步不变量需要互斥或 CAS 协议 |
| 两个字段必须同时一致 | 通常不合适 | 独立 volatile 写会暴露中间组合 |
6. 常见问题
6.1 volatile long 的意义只是防止撕裂吗
不只是。现代 Java 对单次 volatile 读写还规定了内存顺序和可见性语义。将它理解成“64 位原子写”会漏掉最重要的 happens-before 保证。
6.2 volatile 变量越多,线程安全性越高吗
不会。多个变量分别可见,仍不能保证组合状态一致。应先写出不变量,再确定同步边界。
7. 面试题
7.1 为什么 volatile 不能保证 i++ 的线程安全
出现公司:腾讯、阿里巴巴、蚂蚁集团
考察重点
- 单次 volatile 访问与复合操作的区别。
- 丢失更新的交错过程。
- 合适的原子类或锁方案。
相关内容:第 1 节“volatile 写与后续读建立顺序”和第 2 节“volatile 不保护复合更新”。
参考回答
volatile 读写各自有可见性和顺序保证,但 i++ 包含读、计算、写三个步骤。两个线程可以读到同一个旧值,再分别写入相同的新值,所以一次更新丢失。
只维护单个计数时可以用 AtomicInteger.incrementAndGet();如果更新还依赖其他字段或前置条件,则要把完整不变量放进锁或设计正确的 CAS 循环。