承接的问题
前面的章节都在回答「怎么把系统跑快、怎么在出事时看见它」。这一章换一个方向:怎么证明一段并发代码没有做错事。
困难在于,错误的发生依赖一个极窄的线程交错窗口。以库存扣减为例,所谓「检查再动作」的写法只有三步:
读取 stock → 判断 stock > 0 → 写回 stock - 1
两个线程如果按「读、读、写、写」的顺序交错,就会同时通过判断,把库存 1 扣成 -1。这个窗口可能只有几十纳秒,用普通的 for 循环加线程池跑一万次,往往一次都碰不上;而线上流量一大,它又必然发生。JCStress(Java Concurrency Stress Tests)就是为这种「必须发生、但难以稳定触发」的场景准备的:它把 actor 线程对齐到同一个起点,反复执行极短的状态机,并用结果分级告诉框架「哪些结果合法、哪些结果一旦出现就是 bug」。
这一节先讲模型(注解各管什么、结果怎么分级),再给一个自定义结果类型的完整例子,最后在本机把「注解处理没开」和「跑出 FORBIDDEN」两件事都真实跑一遍。
六个注解的分工
JCStress 的一个测试类由六个元素拼成。名字很像,职责完全不同:
| 元素 | 加在哪 | 职责 | 写错的后果 |
|---|---|---|---|
@JCStressTest | 类 | 声明这是一个压力测试,并选择 Mode.Continuous 或 Mode.Termination | 注解处理器不生成 TestList,运行时找不到测试 |
@State | 类 | 声明被多个 actor 共享的状态对象;框架按 fork 创建实例 | 状态被当成普通参数,编译期报错 |
@Actor | 方法 | 一个并发参与者;每个 actor 在独立线程里执行一次 | 少于两个 actor 就没有并发交错 |
@Arbiter | 方法 | 所有 actor 结束后执行一次,读取终态并写入结果对象 | 没有它,框架不知道该观察什么 |
@Outcome | 类(可重复) | 把某个结果字符串映射到一种期望等级 | id 与 toString() 不一致时永远匹配不上 |
Expect | @Outcome 的属性 | 结果等级的枚举:ACCEPTABLE、ACCEPTABLE_INTERESTING、FORBIDDEN、UNKNOWN | 写成 INTERESTING 编译直接失败 |
有两个细节必须提前说清,否则照着旧文章抄会立刻卡住。
第一,Expect 在 0.16 里是枚举,不是注解,常量只有四个:ACCEPTABLE、ACCEPTABLE_INTERESTING、FORBIDDEN、UNKNOWN。网上常见的「INTERESTING」并不存在。本机实测写成 Expect.INTERESTING,javac 报:
错误: 找不到符号
符号: 变量 INTERESTING
位置: 类 org.openjdk.jcstress.annotations.Expect
第二,@Outcome(id = ...) 的 id 是按结果对象的 toString() 精确匹配的,不做任何语义解析。II_Result(两个 int 的内置结果类)的 toString() 是 "0, 1",不是 "stock=0, success=1"。本机第一轮就踩了这条:修复版把合法结果的 id 写成 stock=0, success=1,结果 234,815,754 个样本全部落进兜底的 ACCEPTABLE_INTERESTING,报告里合法结果的出现频率是 0%。
@Actor 与 @Arbiter 的执行顺序是固定的:框架先让所有 actor 在各自线程里跑完,再调用一次 arbiter。actor 之间没有任何顺序保证,这正是要被测试的部分。@State 对象在每个 fork 里新建,所以测试之间不会互相污染。
结果分级:四种颜色的含义
@Outcome 的四个等级对应四种处置方式:
| 等级 | 含义 | JCStress 的反应 |
|---|---|---|
ACCEPTABLE | 合法结果,允许出现 | 计入通过 |
ACCEPTABLE_INTERESTING | 合法但值得记录(比如中间态) | 计入通过,报告里单独列出 |
FORBIDDEN | 不该出现的结果,出现即 bug | 测试失败,进程以非零码退出 |
UNKNOWN | 没有声明过、也没被兜底覆盖的结果 | 视为测试配置错误 |
兜底写法是不写 id 的 @Outcome:
@Outcome(expect = Expect.ACCEPTABLE_INTERESTING, desc = "其他中间状态")
它会接住所有没有被前面 id 匹配到的结果。兜底不是可选项:如果某个交错产生了你没想到的结果,而它又没被任何 @Outcome 覆盖,JCStress 会把它标成 UNKNOWN 并让测试失败——这其实是在提醒你「测试模型漏了一个状态」。
三个容易混的点:
- 没命中 FORBIDDEN 不等于没有竞态。JCStress 是概率性采样,一次运行可能恰好没撞上窗口。判断「有没有问题」要看跑了多少样本、命中率多少,而不是看一次运行的红绿。
- 命中 FORBIDDEN 也不等于每次都能复现。同一份代码两轮运行,命中率可以从 8.25% 掉到 7.65%(本机实测,见下文)。
ACCEPTABLE_INTERESTING不是「警告」。它不会让构建失败,适合记录「合法但需要知道」的中间状态;把真正的 bug 放进去,等于把门禁关掉。
一个自定义结果类型
内置结果类只覆盖 int/long/String 的固定组合。要观察「库存 + 成功次数」这种业务语义,就得自定义结果类。JCStress 对结果类有硬性约束,全部由注解处理器在编译期检查:
- 类必须是
public,且直接继承Object; - 所有字段必须是
public,类型必须是基本类型或String; - 必须有无参构造器,因为框架会复用并清零对象;
- 必须实现
equals/hashCode,并提供唯一的toString()(@Outcome的 id 靠它匹配); - 不能有
final字段——框架会在复用前给每个字段赋零值,final字段连这一步都通不过。
下面这个 StockReport 满足全部条件。注意不要加 serialVersionUID:框架生成的子类会给它赋 0,private static final 会直接编译失败(本机实测报「serialVersionUID 在 StockReport 中是 private 访问控制」)。
package com.plume.conc.lab;
import org.openjdk.jcstress.annotations.Result;
import java.io.Serializable;
import java.util.Objects;
@Result
public class StockReport implements Serializable {
public int stock;
public int success;
public StockReport() {
}
@Override
public boolean equals(Object o) {
if (!(o instanceof StockReport other)) {
return false;
}
return stock == other.stock && success == other.success;
}
@Override
public int hashCode() {
return Objects.hash(stock, success);
}
@Override
public String toString() {
return "stock=" + stock + ", success=" + success;
}
}
结果类只有作为 @Actor / @Arbiter 方法的参数出现,才会被注解处理器识别。写成 @Arbiter public StockReport report() 的返回类型不行,编译期会报:
@JCStressTest defines no @Result to work with
这个报错的本意不是「你没写结果类」,而是「你没把它放到方法参数里」。
测试本体如下。库存初始为 1,两个 actor 各自执行「读—判断—写」,arbiter 把终态写进 StockReport:
package com.plume.conc.lab;
import org.openjdk.jcstress.annotations.*;
import java.util.concurrent.atomic.AtomicInteger;
@JCStressTest
@Outcome(id = "stock=0, success=1", expect = Expect.ACCEPTABLE,
desc = "一本书只被一位会员借走,库存归零")
@Outcome(id = "stock=-1, success=2", expect = Expect.FORBIDDEN,
desc = "两位会员都借到了同一本库存为 1 的书")
@Outcome(expect = Expect.ACCEPTABLE_INTERESTING, desc = "其他中间状态")
@State
public class StockRaceTest {
final AtomicInteger stock = new AtomicInteger(1);
final AtomicInteger success = new AtomicInteger();
@Actor
public void alice() {
if (stock.get() > 0) { // 读
stock.decrementAndGet(); // 写
success.incrementAndGet();
}
}
@Actor
public void bob() {
if (stock.get() > 0) {
stock.decrementAndGet();
success.incrementAndGet();
}
}
@Arbiter
public void report(StockReport r) {
r.stock = stock.get();
r.success = success.get();
}
}
对照修复版,把「读—判断—写」换成一个 CAS 循环,FORBIDDEN 就应当永远不出现:
private void borrow() {
int cur;
do {
cur = stock.get();
if (cur == 0) {
return;
}
} while (!stock.compareAndSet(cur, cur - 1));
success.incrementAndGet();
}
这段代码放在另一个测试类里,两个 @Actor 都调用 borrow(),结果类仍用两个 int 的内置 II_Result,合法 id 是 "0, 1"。
本机实跑:从 TestList 空指针到 8.25%
工程只需要一个依赖。pom.xml 的关键不是依赖,而是 <proc>full</proc>:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.plume.conc</groupId>
<artifactId>conc-lab-jcstress</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<jcstress.version>0.16</jcstress.version>
</properties>
<dependencies>
<dependency>
<groupId>org.openjdk.jcstress</groupId>
<artifactId>jcstress-core</artifactId>
<version>${jcstress.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
<configuration>
<release>25</release>
<proc>full</proc>
</configuration>
</plugin>
</plugins>
</build>
</project>
jcstress-core:0.16 的运行时传递依赖是 net.sf.jopt-simple:jopt-simple:4.6、net.java.dev.jna:jna:5.8.0、net.java.dev.jna:jna-platform:5.8.0。用 dependency:build-classpath 一次取全,再启动 org.openjdk.jcstress.Main:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
mvn -q -DskipTests package
mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt
CP="target/classes:$(cat cp.txt)"
java -cp "$CP" org.openjdk.jcstress.Main -m quick -c 8 \
-t 'StockRaceTest|FixedStockTest'
先验证 <proc>full</proc> 缺失的后果。 删掉这一行后重新 mvn package,构建退出码是 0,target/classes/META-INF/TestList 根本不存在;再启动 JCStress,退出码 1,堆栈是:
Exception in thread "main" java.lang.NullPointerException
at java.base/java.io.Reader.<init>(Reader.java:286)
at java.base/java.io.InputStreamReader.<init>(InputStreamReader.java:82)
at org.openjdk.jcstress.infra.runners.TestList.getTests(TestList.java:51)
at org.openjdk.jcstress.infra.runners.TestList.tests(TestList.java:104)
at org.openjdk.jcstress.JCStress.getTests(JCStress.java:200)
把 <proc>full</proc> 加回去,TestList 重新生成,-m quick 正常跑完。构建成功不等于测试可运行,这条与 2.1 的 JMH BenchmarkList 是同一类问题。
本机结果(Apple M1 Pro 10 核 32 GB,Temurin 25.0.5+8,load average 36,-m quick -c 8,两轮各约 15 秒):
Failed tests: 1 matching test results.
...... [FAILED] com.plume.conc.lab.StockRaceTest
Results across all configurations:
RESULT SAMPLES FREQ EXPECT DESCRIPTION
stock=-1, success=2 18,299,285 8.25% Forbidden 两位会员都借到了同一本库存为 1 的书
stock=0, success=1 203,470,709 91.75% Acceptable 一本书只被一位会员借走,库存归零
第二轮同一命令:
stock=-1, success=2 16,420,052 7.65% Forbidden 两位会员都借到了同一本库存为 1 的书
stock=0, success=1 198,294,582 92.35% Acceptable 一本书只被一位会员借走,库存归零
两轮合计样本约 4.36 亿次,FORBIDDEN 命中率分别是 8.25% 与 7.65%。CAS 修复版单独用 -v 跑了一轮,234,815,754 个样本、0 次 FORBIDDEN、合法结果占 100%。
这里要如实说明三件事:
- 命中率是概率,不是常数。8.25% 与 7.65% 的差异来自线程调度和 JIT 随机化,不能把任何一个数字当成「这个 bug 的触发概率」。
- 一轮 15 秒是 quick 模式,每个测试在 14 个 JVM 配置下各跑一遍。本机 load average 36,这个时延只对本机这个负载有效。
- 修复版 0 命中,只能说明「在这 2.3 亿个样本里没出现」,不能证明代码绝对正确;证明要靠 CAS 循环的可线性化论证,JCStress 只是佐证。
在 libraryd-conc 里落地
conc-lab 里给每个有竞态风险的实现配一个 JCStress 测试,命名用「被测类 + 场景」:
| 测试类 | 被测实现 | 关键结果 |
|---|---|---|
StockRaceTest | UnsafeLoanService 的读改写扣减 | stock=-1, success=2 为 FORBIDDEN |
FixedStockTest | AtomicLoanService 的 CAS 扣减 | 只允许 0, 1 |
ReservationCountTest | 预约登记的双写 | 中间态用 ACCEPTABLE_INTERESTING |
报告里必须同时记录:运行模式(quick / default)、CPU 数、每轮样本数、每个 @Outcome 的命中数。只写「测试通过」没有信息量——读者无法判断这是「跑了 2 亿次没出事」还是「跑了 3 次没出事」。
常见错误与边界
| 错误 | 现象 | 正确做法 |
|---|---|---|
漏写 <proc>full</proc> | 构建成功,运行 NPE 在 TestList.getTests | JDK 23+ 显式声明 |
把结果类写成 @Arbiter 的返回值 | 编译报 defines no @Result | 放进方法参数 |
给结果类加 private final 字段或 serialVersionUID | 生成的子类无法赋值 | 全 public、可变、无 serialVersionUID |
@Outcome 的 id 与 toString() 不一致 | 合法结果全落进兜底,频率 0% | 先跑一次看真实字符串再写 id |
| 只跑一轮没命中就宣布无竞态 | 把概率当确定性 | 记录样本数与多轮命中率 |
| actor 里做 IO、加锁、睡眠 | 交错被拉长,测的不再是原语 | actor 只做内存操作 |
什么时候不要用 JCStress:当你想验证的是「服务在 200 QPS 下库存会不会超卖」时。JCStress 的 actor 是内存里的两个线程,没有网络、没有数据库、没有重试;它能证明「这段读改写有窗口」,但不能证明「线上一定会超卖」。系统级验证要靠 2.3 的压测和 15.3 的验收。
小结
JCStress 的模型可以压成一句话:用 @State 定义共享内存,用 @Actor 制造交错,用 @Arbiter 观察终态,用 @Outcome 声明什么算错。它的输出不是「通过/失败」二值,而是一张「结果 × 样本数 × 期望等级」的表;读这张表时,样本数和命中率比颜色更重要。
本机这组数据里,真正可复用的结论只有两条:<proc>full</proc> 不能省,@Outcome 的 id 必须和 toString() 逐字一致。至于 8.25% 这个数字,它只说明「这个窗口很容易撞上」,不是这段代码的固有属性。
下一节换一个工具:JCStress 靠随机采样碰交错,Lincheck 则用模型检验系统地枚举交错,并能给出一个最小化的失败场景。
阅读导航:上一节 7.3 混合栈的边界与调试 | 下一节 8.2 Lincheck 的线性一致性检查
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。