《Java 并发编程·线程与内存模型》2.3 用 JCStress 复现计数器竞态

用 JCStress 0.16 把计数器竞态变成可重复的统计结果:完整给出 LostUpdate 与 AtomicCounter 两个测试类,解释五个注解、ACCEPTABLE/FORBIDDEN 与结果表读法,并贴出 Apple M1 Pro 上两次 quick 模式的真实输出,说明 FORBIDDEN 触发 AssertionError 是预期行为。

承接的问题

2.1 的 32 线程复现程序在本机连续 3 次都在第 1 轮复现。但这仍然是碰运气:换一台机器、换一个 JIT 时机、把临界区缩短几条指令,结果就可能变成几十轮全绿。靠 Thread.sleep 或反复重跑,只能提高碰中的概率,不能告诉你「还有哪些交错没被覆盖」。

JCStress 把这件事变成受控实验。它由 OpenJDK 维护,专门用来测小段共享状态上的并发行为:

  • 为每个测试生成大量执行实例;
  • 用多个 actor 线程同时执行指定的读写;
  • 在不同 JVM 配置、不同 JIT 编译状态下反复跑;
  • 把所有观察到的结果按直方图统计;
  • 用 @Outcome 声明哪些结果允许、哪些禁止。

它不是定理证明器,而是一个高强度的探测器。它能告诉你「某种非法结果被观察到了」,不能告诉你「所有合法交错都被覆盖了」。

JCStress 的运行模型

一个 JCStress 测试由四部分组成:

  1. @State 对象:actor 共享的状态。每个 iteration 都会新建一个实例。
  2. @Actor 方法:并发执行的操作。JCStress 负责开线程、对齐起跑线。
  3. @Arbiter 方法:所有 actor 结束后执行一次,用来读取最终状态、填充结果。
  4. @Outcome:声明结果 id 的期望。JCStress 拿实际观察到的结果去匹配。

五个注解的分工:

注解作用关键约束
@JCStressTest标记这个类是一个 JCStress 测试必须配合 @Outcome 声明结果
@State声明 actor 共享的状态对象每个 iteration 新建;不要写成 static
@Actor标记参与并发执行的方法可以有多个;方法内不要再自己开线程
@Arbiter所有 actor 结束后执行一次用来读取最终状态、写入 result
@Outcome声明某个结果 id 的期望id 要覆盖这个测试所有可能的结果

Expect 有三个常用值:

值含义观察到时
Expect.ACCEPTABLE这个结果允许出现通过
Expect.FORBIDDEN这个结果不允许出现失败,进程以 AssertionError 退出
Expect.ACCEPTABLE_INTERESTING允许,但值得记录通过,会列在 Interesting 分组

结果通过 org.openjdk.jcstress.infra.results.* 里的类型带回。一个 int 用 I_Result,字段是 r1;两个 int 用 II_Result,字段是 r1、r2。@Outcome(id = "1") 里的 "1" 就是 r1 = 1 的字符串形式。

运行模式用 -m 指定。quick 是短跑,迭代少、JVM 配置少,适合开发时看现象;默认模式会跑更久、覆盖更多编译配置。-v 输出每个 fork 的 CPU 分配、编译状态和中间采样,排查「为什么这个结果没出现」时有用。

quick 和默认模式的差别不只是时间:

模式迭代规模JVM 配置适合
-m quick小少开发时确认现象、快速回归
默认大多,含 stress 编译选项发布前验证、排查偶发结果
-m sanity最小最少确认测试能启动、注解没写错

quick 看到 forbidden 就足以说明问题存在;quick 没看到 forbidden,不能反推问题不存在。

@Arbiter 不是必须的。如果 actor 直接把结果写进 result 参数,也可以省略 arbiter;但两个 actor 都写同一个 result 字段会互相覆盖,所以「actor 只改状态、arbiter 统一读」更容易推理。本节的 LostUpdate 和 AtomicCounter 都采用这种写法。

LostUpdate:让丢失更新变成统计数字

先测最朴素的计数器。两个 actor 各做一次 counter++,arbiter 读取最终值:

package com.plume.conc.lab;

import org.openjdk.jcstress.annotations.Actor;
import org.openjdk.jcstress.annotations.Arbiter;
import org.openjdk.jcstress.annotations.Expect;
import org.openjdk.jcstress.annotations.JCStressTest;
import org.openjdk.jcstress.annotations.Outcome;
import org.openjdk.jcstress.annotations.State;
import org.openjdk.jcstress.infra.results.I_Result;

@JCStressTest
@Outcome(id = "1", expect = Expect.FORBIDDEN, desc = "丢失更新")
@Outcome(id = "2", expect = Expect.ACCEPTABLE, desc = "两次自增都生效")
@State
public class LostUpdate {
    int counter;

    @Actor
    public void actor1() {
        counter++;
    }

    @Actor
    public void actor2() {
        counter++;
    }

    @Arbiter
    public void arbiter(I_Result r) {
        r.r1 = counter;
    }
}

两个 actor 都结束后,counter 只可能是 1 或 2:

  • 2 表示两次自增都生效;
  • 1 表示其中一次被覆盖,这就是丢失更新。

把 1 声明为 FORBIDDEN,是在告诉 JCStress:这个测试的目的就是抓它。运行:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
java -jar conc-lab/target/conc-lab-1.0-SNAPSHOT.jar -t LostUpdate -m quick

本机输出(Apple M1 Pro,10 核,32 GB,Temurin 25.0.5+8)。下面是其中一组结果:

  RESULT     SAMPLES     FREQ      EXPECT  DESCRIPTION
       1  34,107,266   46.97%   Forbidden  丢失更新
       2  38,502,545   53.03%  Acceptable  两次自增都生效

这张表要逐列读:

  • RESULT:r1 的取值,也就是最终的 counter。
  • SAMPLES:这个结果被观察到的次数。
  • FREQ:占全部样本的比例。
  • EXPECT:来自 @Outcome 的声明。
  • DESCRIPTION:@Outcome 里的描述。

但同一次运行里,JCStress 会用不同的 JIT 参数跑很多组。那次运行一共给出 15 组结果,丢失更新占比是这样的:

样本数丢失更新占比
16,2710.16%
19,2370.27%
31,3500.37%
55,9920.70%
19,820,30227.25%
23,614,93228.53%
34,107,26646.97%
40,903,93449.65%
47,634,72269.23%
244,271,17639.72%

同一个进程、同一份代码、同一天,比例从 0.16% 跨到 69.23%。样本数很少的那几组是特殊编译配置(-XX:+StressLCM、-XX:+StressGCM 之类),跑得快、样本少;样本上亿的那组最接近默认运行。

这张表要说明的核心一点:FREQ 不是概率。它随 JIT 编译状态、GC、CPU 频率、编译参数剧烈变化,唯一稳定的结论是「forbidden 结果会出现」这个方向。

AtomicCounter:把 FORBIDDEN 压到 0

把 int 换成 AtomicInteger,其余结构不变:

package com.plume.conc.lab;

import java.util.concurrent.atomic.AtomicInteger;
import org.openjdk.jcstress.annotations.Actor;
import org.openjdk.jcstress.annotations.Arbiter;
import org.openjdk.jcstress.annotations.Expect;
import org.openjdk.jcstress.annotations.JCStressTest;
import org.openjdk.jcstress.annotations.Outcome;
import org.openjdk.jcstress.annotations.State;
import org.openjdk.jcstress.infra.results.I_Result;

@JCStressTest
@Outcome(id = "1", expect = Expect.FORBIDDEN, desc = "不应出现丢失更新")
@Outcome(id = "2", expect = Expect.ACCEPTABLE, desc = "CAS 保证两次自增都生效")
@State
public class AtomicCounter {
    final AtomicInteger counter = new AtomicInteger();

    @Actor
    public void actor1() {
        counter.incrementAndGet();
    }

    @Actor
    public void actor2() {
        counter.incrementAndGet();
    }

    @Arbiter
    public void arbiter(I_Result r) {
        r.r1 = counter.get();
    }
}

运行命令多一个 -v:

java -jar conc-lab/target/conc-lab-1.0-SNAPSHOT.jar -t AtomicCounter -m quick -v

本机输出:

  RESULT     SAMPLES     FREQ      EXPECT  DESCRIPTION
       1           0    0.00%   Forbidden  不应出现丢失更新
       2  16,197,651  100.00%  Acceptable  CAS 保证两次自增都生效

incrementAndGet 内部是一个 CAS 循环:读到当前值,算出加一后的值,用 compareAndSet 尝试写回;失败就重读重试。两个 actor 的更新会线性化,最终一定是 2。1 的样本数是 0,2 是 100%。

这里的 0 有两层含义,要分开:

  • JCStress 层面: 这次运行没有观察到 1。样本数、fork 数、JIT 配置都有限,所以它是「强证据」,不是数学证明。
  • API 契约层面: AtomicInteger.incrementAndGet 保证原子性,1 在语义上不可能出现。这是 JCStress 观察不到 1 的原因。

JCStress 负责把「偶发」变成「可统计」,真正的保证仍然来自 JMM 和 API 契约。两者不能互相替代。

把两个测试并排看:

测试共享状态actor 的操作可能结果本机观察
LostUpdateint countercounter++1 / 21 在 15 组结果里出现 0.16%–69.23%
AtomicCounterAtomicInteger counterincrementAndGet()1 / 21 出现 0 次

两边的 @Outcome 声明完全一样,差别只在共享状态的类型。这正是控制变量:如果换了类型之后 forbidden 消失,原因就只能落在原子性上,而不是测试写法、线程数或结果收集。

FORBIDDEN 被观察到之后

LostUpdate 的进程不会以 0 退出。看到 forbidden 结果后,JCStress 会抛出 AssertionError:

Exception in thread "main" java.lang.AssertionError: TEST FAILURES:
com.plume.conc.lab.LostUpdate []: Observed forbidden state: 1 (丢失更新)
com.plume.conc.lab.LostUpdate []: Observed forbidden state: 1 (丢失更新)
	at org.openjdk.jcstress.infra.grading.ExceptionReportPrinter.work(ExceptionReportPrinter.java:63)
	at org.openjdk.jcstress.JCStress.parseResults(JCStress.java:140)
	at org.openjdk.jcstress.JCStress.run(JCStress.java:107)
	at org.openjdk.jcstress.Main.main(Main.java:60)

这是预期行为,不是 JCStress 的 bug。FORBIDDEN 的语义就是「观察到即失败」,失败通过非零退出码和 AssertionError 表达。CI 可以直接用退出码判断:

java -jar conc-lab/target/conc-lab-1.0-SNAPSHOT.jar -t LostUpdate -m quick
echo "exit=$?"

在 CI 里放 LostUpdate 有两种用法:

  • 当作反面测试:断言退出码非 0,证明竞态仍然可复现。这种测试适合放在实验流水线,不适合和正向门禁混在一起。
  • 当作正向门禁:只跑 AtomicCounter,要求退出码为 0。LostUpdate 只作为本地教学实验。

不要为了让 CI 变绿而删掉 @Outcome(id = "1", expect = Expect.FORBIDDEN)。删掉之后测试确实不红了,但它也不再检查任何东西。

启动时还会看到下面的输出:

----- [N/A] Checking for biased locking support
Unrecognized VM option 'UseBiasedLocking'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.

前两行是 JCStress 在探测偏向锁。JDK 25 已经移除偏向锁,-XX:+UseBiasedLocking 变成无法识别的选项,所以探测失败并标记 [N/A]。这是探测子进程的失败,不影响主测试进程。后面两行 Error: Could not create the Java Virtual Machine 也是这个探测子进程的输出,不是你的测试挂了。看到 [N/A] 继续看结果即可。前言里已经给过 annotationProcessorPaths 的配置和 TestList 空指针的排查方法,这里不再重复。

JCStress 不能替代什么

  • 它不能证明「没有竞态」。样本再多,也只是对有限交错的观察。
  • 它不覆盖阻塞、超时、线程池耗尽、死锁这类跨组件的时序问题。
  • 它不告诉你业务不变量是什么。@Outcome 里的 1 和 2 是人定义的,定义错了,测试也会跟着错。

常见错误与边界

  • quick 跑绿就认为安全。 quick 的样本量和 JVM 配置都有限。它适合看现象,不适合作为「没有竞态」的证明。
  • 把 FREQ 当成概率。 它只是本次运行的观察频率,换机器、换 JIT 状态都会变。书里引用时保留原始数字,不为了让比例好看而重跑挑选。
  • 把 @State 写成 static。 状态会跨 iteration 复用,上一轮的残留值会污染下一轮,结果不可解释。
  • 在 @Actor 里自己开线程或 sleep。 JCStress 已经负责并发调度;自己开线程会绕过它的控制,sleep 只会改变交错,不会修复竞态。
  • 只声明「好结果」。 没有被任何 @Outcome 覆盖的结果会进入 Interesting 分组。它不代表通过,通常说明你还没想清楚这个测试可能产出什么。
  • 在单核 CI 上跑。 并发交错会变少,某些 forbidden 结果可能观察不到。JCStress 会打印 CPU 分配信息,看到核数不对要警惕。
  • 用 JCStress 测 IO、超时、线程池。 它面向的是小段共享状态的读写交错,不是集成测试。线程池行为、阻塞唤醒、时序问题要用别的手段。
  • 把 AtomicCounter 的 0 当成「永远不会发生」。 更准确的说法是「API 保证它不会发生,这次运行也没观察到」。换成 LongAdder 或别的类,结论要重新验证。

小结

  • JCStress 用 @State、@Actor、@Arbiter、@Outcome 把并发交错变成可统计的结果表。
  • ACCEPTABLE 是允许出现的结果,FORBIDDEN 是不允许出现的结果;观察到 FORBIDDEN 时进程以 AssertionError 退出,这是设计行为。
  • LostUpdate 在 quick 模式下 15 组结果的丢失更新占比从 0.16% 到 69.23%,方向稳定、比例不稳定;AtomicCounter 的 forbidden 样本为 0。
  • 结果表的 SAMPLES 和 FREQ 是观察值,不是概率证明;真正的保证来自 JMM 和 API 契约。
  • 下一节离开「多线程同时写」的现象,进入 JMM:重排序、可见性与有序性。

阅读导航:上一节 2.2 线程安全的四类策略 | 下一节 3.1 重排序、可见性与有序性

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收