承接的问题
2.1 的 32 线程复现程序在本机连续 3 次都在第 1 轮复现。但这仍然是碰运气:换一台机器、换一个 JIT 时机、把临界区缩短几条指令,结果就可能变成几十轮全绿。靠 Thread.sleep 或反复重跑,只能提高碰中的概率,不能告诉你「还有哪些交错没被覆盖」。
JCStress 把这件事变成受控实验。它由 OpenJDK 维护,专门用来测小段共享状态上的并发行为:
- 为每个测试生成大量执行实例;
- 用多个 actor 线程同时执行指定的读写;
- 在不同 JVM 配置、不同 JIT 编译状态下反复跑;
- 把所有观察到的结果按直方图统计;
- 用
@Outcome声明哪些结果允许、哪些禁止。
它不是定理证明器,而是一个高强度的探测器。它能告诉你「某种非法结果被观察到了」,不能告诉你「所有合法交错都被覆盖了」。
JCStress 的运行模型
一个 JCStress 测试由四部分组成:
@State对象:actor 共享的状态。每个 iteration 都会新建一个实例。@Actor方法:并发执行的操作。JCStress 负责开线程、对齐起跑线。@Arbiter方法:所有 actor 结束后执行一次,用来读取最终状态、填充结果。@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,271 | 0.16% |
| 19,237 | 0.27% |
| 31,350 | 0.37% |
| 55,992 | 0.70% |
| 19,820,302 | 27.25% |
| 23,614,932 | 28.53% |
| 34,107,266 | 46.97% |
| 40,903,934 | 49.65% |
| 47,634,722 | 69.23% |
| 244,271,176 | 39.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 的操作 | 可能结果 | 本机观察 |
|---|---|---|---|---|
LostUpdate | int counter | counter++ | 1 / 2 | 1 在 15 组结果里出现 0.16%–69.23% |
AtomicCounter | AtomicInteger counter | incrementAndGet() | 1 / 2 | 1 出现 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 重排序、可见性与有序性
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。