volatile
volatile 是 Java 中用于线程间状态通知和数据发布的关键字。它让共享字段的读写遵守可见性与顺序规则,适合停止标志、配置替换等场景。涉及递增、检查后更新或多个字段一起变化时,还需要判断整个操作是否具有原子性。
1. 面试核心
先记住这几条,再读正文- 可见性与顺序。 对同一
volatile字段的写,与同步顺序中后续的读建立 happens-before 关系。这条关系让线程之间能够可靠地交接状态。 - 复合操作的原子性。
volatile保证单次读写的原子性,但count++的读取、加一和写回仍可被其他线程穿插。精确递增需要原子类或锁。卡 · volatile 读写的顺序保证 - 普通数据的安全发布。 发布者先写数据,再写
volatile标志;读者先读到这次发布的标志,再读数据,才能看到发布前的普通写入。 - 引用与对象内容的区别。 把数组引用声明为
volatile,约束的是引用的读写。数组元素的并发修改仍需单独同步;整组数据可以通过只读快照发布。卡 · 引用与数组元素 - 使用范围。 停止标志和不可变配置适合用
volatile。检查并更新、多个字段共同保持一致等需求,需要原子操作、锁或整体发布方案。 - VarHandle 的访问强度。 opaque 约束同一变量的访问顺序,acquire/release 可配对发布关联数据,volatile 模式还要求所有 volatile 访问满足共同的总序。卡 · VarHandle 的访问模式
2. volatile 读写的顺序保证
一个线程通知另一个线程停止工作,需要让新标志对工作线程可见。下面用 volatile 声明停止标志,让工作线程在每轮处理后重新检查:
private volatile boolean closed;
public void close() {
closed = true;
}
public void runLoop() {
while (!closed) {
processOne();
}
}
关闭线程写入 closed = true 后,工作线程在后续检查中读到新值,就会退出循环。这里依赖的是 Java 内存模型(JMM)的 happens-before 规则:对同一 volatile 字段的写,先行于同步顺序中后续的读。JVM 可以进行编译优化和指令重排,但执行结果必须满足这项可见性保证。
这个标志要等工作线程再次检查才生效。如果 processOne() 长期阻塞,仍需中断或关闭资源等取消机制,让当前处理及时结束。
具体传递规则见 Java 内存模型。
3. 复合更新
volatile 的一次读取和一次写入各自具有原子性,合起来却未必是一个原子操作。最常见的例子是递增:
private volatile int count;
public void increment() {
count++;
}
count++ 要先读取旧值,再加一,最后写回。两个线程都读到 10 时,会分别写回 11,两次递增只留下了一次结果。精确计数可以使用 AtomicInteger.incrementAndGet(),或者用锁保护整个递增过程。
检查后执行也有同样的问题。下面的标志即使声明为 volatile,也不能让初始化只执行一次:
private volatile boolean initialized;
void initializeOnce() {
if (!initialized) {
initialize();
initialized = true;
}
}
多个线程可能同时读到 false,随后各自进入 initialize()。如果只允许初始化一次,就要用锁保护检查与初始化,或设计包含初始化过程的原子状态转换。
4. volatile 标志发布
volatile 的可见性保证还可以覆盖普通字段的写入。下面的示例中,publish 只调用一次,随后不再修改 data 指向的对象。publish 先执行 data = next,再将 ready 设为 true:
private Data data;
private volatile boolean ready;
void publish(Data next) {
data = next;
ready = true;
}
Data readIfReady() {
if (ready) {
return data;
}
return null;
}
调用 publish 前,当前线程已完成 next 指向的对象的初始化。另一线程调用 readIfReady() 时,可见性沿着下面的顺序传递:
- 写线程先初始化
next指向的对象,再调用publish,依次执行data = next和ready = true。 - 读线程在
readIfReady()中读到ready == true,这次 volatile 读与写线程对ready的写建立 happens-before 关系。 - 读线程随后读取
data,就能看到data = next的结果,以及写线程此前对该对象的初始化。
因此,data 可以是普通引用字段。关键是写线程先赋值 data、再写入 ready,读线程先读到 ready == true、再读取 data。如果读线程先读取 data 再检查 ready,或者检查另一个标志,就缺少这条可见性链路。
如果多次调用 publish,一直为 true 的 ready 无法区分每一次调用。读线程看到 true 时,无法据此确认 data 已被哪次调用更新。
反复更新配置时,可以把新配置做成不可变对象,再替换 volatile 引用。读者只读一次引用,持有该快照完成操作,就能让本次使用的字段来自同一版本。
5. 内存屏障
内存屏障用于约束屏障前后的读写顺序,使处理器执行满足 Java 的内存语义。即时编译器(JIT)会把 volatile 和 VarHandle 访问映射为编译器约束、机器指令及所需屏障。
描述屏障时,Load 表示读,Store 表示写。四种名称分别说明需要维持哪两类访问的先后关系:
| 屏障类型 | 约束的访问顺序 |
|---|---|
| LoadLoad | 屏障前的读与屏障后的读 |
| LoadStore | 屏障前的读与屏障后的写 |
| StoreStore | 屏障前的写与屏障后的写 |
| StoreLoad | 屏障前的写与屏障后的读 |
这些名称用于描述顺序约束。具体使用什么机器指令、需要哪些屏障,以及执行成本多高,取决于处理器架构与 JIT 实现。
所以,判断 Java 代码是否正确,应依赖 JMM 和访问模式,而不是某张 x86 屏障表。优化底层组件时,再结合目标架构测量屏障和缓存竞争的成本。
6. 使用场景
选择 volatile 前,要先确定维护的是单个值,还是必须一起完成的多个变化。常见需求可以按下表选择:
| 需求 | 选择 | 边界 |
|---|---|---|
| 停止标志 | volatile 布尔值 | 工作循环需重新检查 |
| 不可变配置 | volatile 引用 | 发布后保持内容不变 |
| 精确计数 | 原子类 | 需要原子读改写 |
| 检查并扣减 | CAS 或锁 | 条件与更新共同生效 |
| 多字段快照 | 不可变对象加发布协议 | 单次读取取得同一版本 |
停止标志和不可变配置只需要独立读写或整体替换,适合 volatile。某些只有单一写线程的状态机也可以使用它,但要先证明每次状态转换符合业务要求。多个字段分别可见时,读者仍会观察到新旧字段混合的状态,因此要整体发布或加锁。
7. 引用与数组元素
数组需要先区分引用替换和元素修改。引用声明为 volatile 后,替换整个引用具有 volatile 语义;直接执行 values[0] = 1,仍然是普通元素写入。
需要逐项原子操作时,可以用 AtomicIntegerArray。如果要求整组数据一致,则可以创建新数组,再发布它的引用,并让已发布数组保持只读:
private volatile int[] values = {0, 0};
void publish() {
values = new int[] {1, 2};
}
int readSum() {
int[] snapshot = values;
return snapshot[0] + snapshot[1];
}
示例中,发布后的数组保持不变,读者又只读取一次共享引用。因此,两个元素来自同一版本,求和结果为 0 或 3。
8. VarHandle 的访问模式
VarHandle 是访问字段或数组元素的句柄,也就是一个能指定读写方式的访问入口。Java 9 起,可以通过它选择不同强度的内存语义:
| 模式 | 保证重点 |
|---|---|
plain:get / set | 按普通字段的语义读写 |
opaque:getOpaque / setOpaque | 保证单次读写的原子性和同一变量的相干顺序 |
acquire / release:getAcquire / setRelease | 配对交接时约束周围访问的顺序 |
volatile:getVolatile / setVolatile | 在 acquire/release 基础上增加 volatile 总序约束 |
表中的相干顺序,是指不同线程对同一变量的访问遵守一致的次序。它只围绕这个变量,opaque 单独使用时无法保证其他关联数据也被正确发布。
setRelease 保证此前的访问保持在发布之前。读到对应发布值的 getAcquire,则保证后续访问保持在读取之后。两者配合,可以把发布前的数据交给读者。volatile 模式还要求所有 volatile 访问满足一个共同的总顺序。
降低访问模式的强度后,需要重新证明关联数据的可见性。普通业务可以直接使用 volatile 或成熟的 java.util.concurrent 组件。
9. 常见问题
9.1 volatile long 只是为了防止读写撕裂吗?
防止撕裂只是其中一项,volatile 还提供可见性与顺序保证。JLS 允许普通 long 和 double 的访问拆为两个 32 位操作。读者若读到两次写入的不同部分,就会发生撕裂。volatile long 和 double 的单次读写保证原子,因此避免了这种结果。
9.2 多个字段都加 volatile,就能保持状态一致吗?
分别加 volatile 仍会暴露中间状态。例如,两个字段分别写入时,读者可以在两次写入之间取得一个新值和一个旧值。把相关字段放进不可变对象,再整体发布引用,才能按同一版本读取。读者还应只获取一次共享引用,并用它完成整次操作。
10 · 面试题卡片
10.1 为什么 volatile 不能保证 i++ 的线程安全
出现公司:阿里巴巴、蚂蚁集团
考察重点
- 区分 volatile 对单次访问的保证与复合更新所需的原子性。
- 说明两个线程怎样读到同一个旧值,进而丢失一次更新。
- 按单变量计数或多字段约束,选择原子类或锁。
相关内容:volatile 读写的顺序保证和复合更新。
参考回答
因为 i++ 是复合操作,volatile 只保证其中单次读写的原子性。
两个线程都读到 10,再分别写回 11,就会丢失一次递增。可见性保证无法阻止这种交错执行。
独立计数可用 AtomicInteger.incrementAndGet()。涉及余额条件或多个字段时,需要用锁保护完整操作,或把相关状态作为整体进行 CAS 更新。
10.2 volatile 修饰数组引用后,元素也具有 volatile 语义吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:区分引用的读写与元素的修改,并说明怎样让多个元素来自同一快照。
相关内容:引用与数组元素。
参考回答
不会。volatile 修饰的是引用字段,数组元素的读写仍是普通读写。
替换整个数组引用与执行 values[0] = 1 是两个不同的操作。
- 逐项原子更新可以用
AtomicIntegerArray。 - 整组一致读取可以先创建新数组,再发布引用,并保持已发布数组只读。读者只获取一次共享引用,让各元素来自同一个版本。
10.3 VarHandle 的 opaque、acquire/release 与 volatile 有什么区别?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明同一变量的相干顺序、关联数据的发布顺序,以及 volatile 总序之间的区别。
相关内容:VarHandle 的访问模式。
参考回答
- opaque 只保证同一变量的单次读写和相干顺序。 它没有建立周围普通数据的发布关系,因此单独使用时不足以交接关联数据。
- acquire/release 可以配对发布关联数据。 release 约束发布前的访问;读到对应发布值的 acquire 约束读取后的访问,让读者看到发布前写好的数据。
- volatile 还要求所有 volatile 访问满足共同的总序。 它在 acquire/release 的保证之上进一步约束访问顺序。
改用较弱模式时,需要重新证明数据发布关系仍然成立。