电商比价服务同时向多个商家询价。每完成一次请求,就给统计计数加一;拿到报价后,再通知汇总线程读取结果。两个动作看起来都只是修改变量,却面对不同的问题:计数可能丢失,结果可能没有被正确发布。把字段统一加上 volatile,并不能同时解决这两类问题。
这是“Java 并发工程”的第一篇。我们先建立判断共享变量是否安全的方法,再进入 CAS、锁、线程协作和异步任务。实验以 Java 21 为基线,重点是验证机制,不测量生产吞吐,也不要求某种竞争错误在每台机器上必然出现。
先把共享对象和不变量写出来
一个变量被多个线程访问,并不自动意味着程序有问题。只读数据、线程启动前构造完成的数据,以及由明确同步协议保护的数据,都可以共享。真正需要检查的是:哪些操作可能同时发生,是否包含写入,业务要求哪些关系始终成立。
报价统计的不变量是“每个已完成请求对应一次有效累加”。报价发布的不变量是“读者看到完成标记后,读取到对应的价格和商家信息”。前者要求一次读改写不可被其他更新打断,后者要求一组写入通过合适的同步关系传递给读者。先写出这两个要求,工具选择才有依据。
还要确认对象身份。两个线程各自更新自己的计数器,没有共享同一个字段;两个请求各自创建一把锁,也不能保护同一个全局计数器。调试并发问题时,应画出线程与实际对象的引用关系,而不是只搜索代码里有没有某个关键字。
三个概念分别回答什么
原子性关心一个操作能否作为不可分割的整体观察。可见性关心一个线程的写入通过什么规则被另一个线程读到。有序性关心程序允许呈现哪些执行结果,不能直接理解成所有 CPU 指令都按源代码顺序执行。
例如,读一次 int 字段和写一次 int 字段各自具有原子性,不代表先读、加一、再写的组合也是原子的。即使两个线程每次都读到一个完整的整数,它们仍可能依据同一个旧值生成相同的新值,导致一次更新覆盖另一次更新。
相反,保证更新不可交错,也需要考虑其他访问是否遵守同一协议。如果写者在 synchronized 中更新价格,读者却完全不使用相同锁,也没有其他同步关系,就不能仅凭写者加锁推出读者的读取安全。保护协议必须覆盖需要参与一致性的访问路径。
JMM 是程序语义,不是硬件接线图
Java 内存模型描述多线程程序允许观察到哪些行为。它为编译器和处理器保留优化空间,同时要求最终结果满足语言规范。主内存、工作内存等教学图有助于建立直觉,但不能据此断言每次 volatile 写都必须直接写入物理内存,或者线程一定持有某种一一对应的缓存副本。
CPU 缓存一致性也不能代替完整的并发推理。即使缓存之间能够协调一个存储位置,复合业务操作仍可能交错;编译器还可能合并、移动或消除访问。应用代码应先依赖 Java 层面的同步规则,只有研究某个具体实现时,才进一步讨论机器指令与屏障。
因此,本系列把三层证据分开:语言规范规定允许行为,固定版本的 HotSpot 源码解释实现,本机实验记录一次实际观察。实现优化不是语言保证,一次运行结果也不能穷尽规范允许的所有结果。
用 happens-before 建立证据链
happens-before 是推理可见性与顺序约束的关系。常用的连接点包括同一线程的程序顺序、同一监视器的解锁与后续加锁、volatile 写与后续读、线程启动,以及对线程终止的成功检测。关系还具有传递性,可以把多个连接点串成一条证据链。
例如,主线程先初始化报价配置,再启动工作线程,初始化动作可以通过 start 建立的关系传给工作线程。工作线程写入普通结果字段并结束,主线程成功 join 后再读取,也有相应保证。这说明普通字段并非必须一律改成 volatile,关键在于访问之间是否已有充分的同步关系。
不要把“日志显示 A 比 B 早”当成 happens-before。墙上时钟、执行耗时和观察到的先后顺序,不能代替规范定义的同步。两个线程之间单纯等待一段时间,不会自动建立共享字段的发布协议。JLS 第 17 章是本篇语义依据。
volatile 发布的不只是一个布尔值
考虑一个只发布一次的报价对象:价格是普通字段,完成标记是 volatile。写者先写价格,再把标记改为 true;读者观察到这个标记后才读取价格。
final class Quote {
int price;
volatile boolean ready;
}
// 写者
quote.price = 42;
quote.ready = true;
// 读者:完整实验另有超时保护
while (!quote.ready) {
Thread.onSpinWait();
}
int price = quote.price;
证据链是价格写入先于标记写入,标记写入通过 volatile 与读者的相应读取连接,读者再读取价格。由传递性可以解释为什么这里应读到四十二。示例没有在发布前后再插入一个传递价格的队列或锁,避免把另一种同步的效果误算到 volatile 头上。
这个协议只描述一次发布。若写者在 ready 已经为 true 后继续修改多个价格字段,读者不一定得到同一批次的快照。需要反复替换结果时,可以发布一个构造完成的不可变对象引用,或者用锁保护整个更新与读取过程;不能只保留一个永远为真的完成标记。
为什么 volatile 自增仍然会丢更新
假设计数初始为零。线程甲读取零,线程乙也读取零,两者各自算出一,最后都写入一。每次读写都可以满足 volatile 语义,最后的结果却只有一次增加。问题出在两次访问之间缺少对整个读改写操作的协调。
实验提供两种观察。第一种把自增显式拆成读取和写入,通过屏障让两个线程都读完再写,稳定得到一。这是人为控制交错的教学模型,能够说明复合操作为什么不足,却不能当作原始 i++ 指令调度的实测轨迹。
第二种让四个工作线程各执行两万次真实的 volatile 自增,记录最终值。预期串行总数是八万,但实验不把“必须小于八万”设为断言。某次运行恰好得到八万,只说明这次没有观察到丢失,不能据此证明写法安全。修复版本使用 AtomicInteger,任务全部结束后断言精确等于八万。
运行五个最小实验
下载实验源码包,按环境说明准备 JDK 21。在解压目录运行:
python3 run.py --work /tmp/java-concurrency-run --group JmmLab
五个用例分别检查显式拆分自增、真实 volatile 自增观察、volatile 发布、普通字段在线程结束后的读取,以及原子自增。本机 Java 21.0.1、macOS arm64 实际运行五项通过;固定结果为拆分模型的一、发布价格四十二和原子计数八万。真实竞争观察值保存在实测摘要,读者重新运行可能不同。
读取线程带有截止时间,避免错误版本无限占用 CPU。测试线程和线程池在结束时被清理,异步任务抛出的异常会被主线程读取。这样可以把“断言通过”和“后台线程悄悄失败”区分开,防止测试框架只检查主线程是否退出。
测试本身也可能改变并发关系
给循环加打印、在写后 countDown、在读前 await,都可能引入额外同步或改变调度。若原问题是“普通字段是否被正确发布”,再加入一条覆盖写读的同步链,实验验证的就已经是另一个程序。分析结果前应把测试辅助操作也画进关系图。
本实验中的屏障仅用于第一项显式交错模型;发布实验通过 volatile 标记传递价格,终止检测实验则明确研究 join 的保证。不同实验分别命名,避免拿一个看起来统一的测试框架掩盖不同的同步来源。
同样,设置超时只是让测试有限结束,不是为目标字段提供可见性。循环中的时间读取和 onSpinWait 也不应被描述为修复方案。普通字段的数据竞争示例可以作为反例讲解,但本轮没有用高频压力结果代替规范证明,也没有引入专门的弱内存测试框架。
工程中如何选择保护方式
对独立的开关状态,可以评估 volatile;对单个数值的读改写,可以使用原子类;对价格、库存和版本等多个字段之间的不变量,通常需要锁或不可变快照。工具的轻重不是首要判断标准,保护范围与业务不变量一致才是。
final 字段有专门的初始化安全语义,但它不会让被引用的整个对象图自动变成不可变。构造函数中泄露 this,也会让构造过程与其他线程访问发生交错。共享配置可以采用完成构造后发布、内部字段不再修改的设计,减少需要协调的状态数量。
读代码时可以逐一问:哪个对象被共享,哪个操作必须不可分割,读者凭什么观察到写者的结果,更新后还会不会继续修改。能把这四件事说清楚,再选择关键字或并发类,通常比背诵“volatile 轻量、锁重量”更有帮助。
一次发布与重复更新要采用不同协议
把报价对象发布给汇总线程后,如果商家价格又发生变化,原来的安全发布只解释第一次构造内容如何可见。它不会冻结这个对象,也不会替后续修改建立新的关系。读者可以看到正确构造的对象,同时又与写者对后续字段修改发生数据竞争,这两件事并不矛盾。
一种容易推理的设计是让每个报价快照包含价格、时间和版本,构造后不再修改,通过 volatile 引用一次替换。读者先把该引用保存到局部变量,再读取同一个快照里的字段。如果每读一个字段都重新读取共享引用,即使各快照自身不可变,也可能把两个版本的字段拼在一起。
另一个常见误区是把数组引用设为 volatile,就认为每个元素都获得相同的访问语义。修饰引用主要约束对引用本身的访问,后续直接修改数组元素仍需自己的同步协议。容器引用、容器内部状态与元素对象,应分别检查保护范围。用一张对象关系图标出可变位置,比只统计 volatile 关键字数量更可靠。
面试归纳与下一篇
回答 JMM 问题时,可以从报价例子开始:volatile 能建立发布关系,但不能把计数自增变成原子操作;happens-before 用于解释允许的可见结果,不能等同于真实机器指令的固定执行顺序;一次压力测试没出错不足以证明线程安全。
下一篇把计数修复向前推进,解释 CAS 如何把比较与更新组成原子步骤、重试为什么会有代价,以及为什么原子引用可以承载一个由多个字段组成的不可变状态。