面试考察点

  • 能否区分可见性、有序性和原子性。
  • 是否理解 happens-before 不是“立刻同步”的同义词。
  • 是否知道 volatile 不适合 i++ 这类复合读改写操作。

核心答案

**一句话回答:**对 volatile 变量的写入 happens-before 后续对该变量的读取,编译器和处理器不能随意重排跨越 volatile 访问的操作;但一次读改写仍然可能被多个线程交错执行,所以不保证复合操作原子性。

private volatile boolean started;

void start() { started = true; }
void awaitStart() {
    while (!started) { /* 等待 */ }
}

一个线程写入 started 后,另一个线程最终能够观察到新值。volatile 适合状态标志、配置刷新和发布不可变对象引用等场景。

三个并发性质

可见性

一个线程修改共享变量后,其他线程能够读取到修改结果。volatile 建立了针对该变量的同步关系。

有序性

编译器和处理器可能对不影响单线程结果的指令重排。volatile 访问具有更强的排序约束,可阻止部分重排导致的跨线程观察异常。

原子性

i++ 包含读、加一、写回三个步骤。即使 i 是 volatile,多个线程仍可能读到同一个旧值,最后互相覆盖,所以结果不一定正确。

volatile 与 synchronized 的区别

volatile 不提供互斥锁,适合一个线程写、多个线程读的简单状态;synchronized 同时提供互斥、可见性和有序性,适合保护不变量和多步临界区。需要计数、扣库存等复合操作时,应使用锁或原子类。

常见误区

  • volatile 不是“轻量级 synchronized”的完全替代品。
  • volatile 只能保证对同一个变量的特殊内存语义,不会自动保护关联字段。
  • 看到 volatile 并不能推断业务逻辑整体线程安全。

参考资料

核心考点清单

  • volatile 保证可见性与相关有序性,不保证复合操作原子性。
  • volatile 写先行发生于后续对同一变量的读。
  • count++ 包含读、改、写,多线程下仍会丢失更新。
  • 它适合状态标志与安全发布,不适合多个字段的联合约束。

高频追问与参考回答

追问:双重检查单例为什么需要 volatile?

对象创建可抽象为分配、初始化、发布引用。volatile 防止引用在初始化完成前被其他线程观察,并建立正确可见性。

追问:volatile 数组能保证元素更新可见吗?

只保证数组引用本身的 volatile 读写。直接修改元素不是对该引用的写,元素并发访问仍需原子数组、锁等机制。

机制全景图

下面把「volatile 如何保证可见性和有序性?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。

flowchart LR
    A["线程执行普通写"]
    A --> B["写入 volatile 变量"]
    B --> C["刷新并发布先前状态"]
    C --> D["另一线程读取 volatile"]
    D --> E["读取线程观察已发布数据"]

完整链路:从输入到结果

沿着「线程执行普通写 → 写入 volatile 变量 → 刷新并发布先前状态 → 另一线程读取 volatile → 读取线程观察已发布数据」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。

1. 线程执行普通写

普通字段写可暂存在寄存器或缓存中,单靠时间先后不能建立跨线程可见性。

2. 写入 volatile 变量

volatile 写具有 release 语义,禁止关键重排序,并把此前操作纳入发布边界。

3. 刷新并发布先前状态

JMM 规定同一变量的 volatile 写 happens-before 后续读,这是一条语言级顺序保证。

4. 另一线程读取 volatile

volatile 读具有 acquire 语义,读取到发布值后,也能观察发布线程在写前完成的普通字段更新。

5. 读取线程观察已发布数据

可见性不等于复合操作原子性,i++ 仍包含读、计算和写三个竞争步骤。

源码与实现定位

入口 阅读重点
JLS 17.4.5 volatile 写读的 happens-before
VarHandle acquire/release 比 volatile 更细的访问模式

源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。

参数配置与可复现实验

final class ConfigHolder {
  private volatile Config current;
  void publish(Config next) { current = next; }
}

用 jcstress 编写发布测试,错误版本先写 ready 再写 data,正确版本发布不可变对象;统计禁止结果是否出现。

验证步骤与预期结果

1. 固定输入和基线

先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「jcstress forbidden」为主基线,记录值应满足「必须 0」;同时保存 旧值命中样本、CAS 或锁竞争次数,使后续变化能够回到同一时间轴比较。

2. 从实现入口确认路径

在「JLS 17.4.5」确认请求确实进入「volatile 写读的 happens-before」对应的实现,再沿「VarHandle acquire/release」观察「比 volatile 更细的访问模式」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。

3. 注入本文特有的失败模式

优先复现「用 volatile 修饰计数器后仍丢更新」,并把单一变量逐级放大,直到「jcstress forbidden」越过「出现 1 次即失败」。随后再分别验证「先发布标志再初始化数据」和「双重检查遗漏 volatile 导致半初始化观察」,三类故障分开执行,避免多个变量同时变化而无法归因。

4. 执行止损和根因修复

第一轮只应用「把多字段封装为不可变快照一次发布」,确认它能控制影响范围;第二轮应用「读方法只读取一次 volatile 引用」,验证核心链路恢复;最后落实「计数器改 Atomic/LongAdder」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。

5. 通过退出条件

实验只有同时满足三项才算通过:「jcstress forbidden」回到「必须 0」、「旧配置窗口」回到「一次 volatile 读后为 0」、「volatile 写频率」回到「低频发布」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。

量化基线

指标 样例基线/口径 风险线 结论
jcstress forbidden 必须 0 出现 1 次即失败 缺同步边
旧配置窗口 一次 volatile 读后为 0 跨调用混读 未固定快照
volatile 写频率 低频发布 成为 CPU 热点 模型选错

这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。

事故复盘:配置开关已更新但工作线程仍用旧参数

代码只把 ready 标志设为 volatile,却在标志之后才写配置字段,读线程可能看到 ready=true 但配置仍旧。把所有配置写放在 volatile 发布之前,或直接发布一个不可变配置对象,才能利用正确的 happens-before 关系。

失败模式 首要证据 第一处置动作
用 volatile 修饰计数器后仍丢更新 旧值命中样本 把多字段封装为不可变快照一次发布
先发布标志再初始化数据 CAS 或锁竞争次数 读方法只读取一次 volatile 引用
双重检查遗漏 volatile 导致半初始化观察 线程状态与 CPU 使用 计数器改 Atomic/LongAdder

发布与回滚检查点

  • 发布前:确认「JLS 17.4.5」对应实现和上述配置在目标版本仍然有效,并保存「jcstress forbidden」基线。
  • 灰度中:同时观察 旧值命中样本、CAS 或锁竞争次数、线程状态与 CPU 使用;任一指标越过表中风险线,就停止继续扩量。
  • 回滚时:先执行「把多字段封装为不可变快照一次发布」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
  • 发布后:至少覆盖一个完整峰值周期,确认「用 volatile 修饰计数器后仍丢更新」没有再次出现,才关闭变更观察窗口。

方案对比与选型

方案 更适合的场景 主要收益 代价与边界
volatile 状态/引用 单写多读、独立状态发布 低开销可见性与顺序性 不能保护跨变量复合不变量
synchronized/Lock 读改写与多个状态必须原子变化 互斥加可见性,语义完整 存在阻塞与竞争成本
Atomic 类 单变量原子更新和 CAS 循环 无锁进展、API 明确 复杂状态组合难表达

选型至少带上 并发线程数、临界区长度、阻塞比例和任务到达速率,并用上面的量化基线验证;未知数据应明确为待测假设。

设计边界与工程取舍

volatile 适合表达状态发布与独立标志;只要正确性条件需要“检查 A 后同时修改 B、C”,就必须重新寻找原子边界。

工程落地遵循:先建立 happens-before 与所有权边界,再谈吞吐和无锁优化。回答时直接引用「JLS 17.4.5」、配置实验和事故数据,比复述固定模板更有说服力。