《Java 并发编程·性能诊断与事故》8.1 JCStress 的测试模型

从库存扣减的读改写竞态出发,拆解 JCStress 0.16 的 @JCStressTest、@State、@Actor、@Arbiter、@Outcome 与 Expect 分工,给出自定义结果类型的完整示例;并在本机 JDK 25 复现 proc=full 缺失导致的 TestList 空指针,再实跑两轮记录 FORBIDDEN 命中率。

承接的问题

前面的章节都在回答「怎么把系统跑快、怎么在出事时看见它」。这一章换一个方向:怎么证明一段并发代码没有做错事。

困难在于,错误的发生依赖一个极窄的线程交错窗口。以库存扣减为例,所谓「检查再动作」的写法只有三步:

读取 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 并让测试失败——这其实是在提醒你「测试模型漏了一个状态」。

三个容易混的点:

  1. 没命中 FORBIDDEN 不等于没有竞态。JCStress 是概率性采样,一次运行可能恰好没撞上窗口。判断「有没有问题」要看跑了多少样本、命中率多少,而不是看一次运行的红绿。
  2. 命中 FORBIDDEN 也不等于每次都能复现。同一份代码两轮运行,命中率可以从 8.25% 掉到 7.65%(本机实测,见下文)。
  3. 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%。

这里要如实说明三件事:

  1. 命中率是概率,不是常数。8.25% 与 7.65% 的差异来自线程调度和 JIT 随机化,不能把任何一个数字当成「这个 bug 的触发概率」。
  2. 一轮 15 秒是 quick 模式,每个测试在 14 个 JVM 配置下各跑一遍。本机 load average 36,这个时延只对本机这个负载有效。
  3. 修复版 0 命中,只能说明「在这 2.3 亿个样本里没出现」,不能证明代码绝对正确;证明要靠 CAS 循环的可线性化论证,JCStress 只是佐证。

在 libraryd-conc 里落地

conc-lab 里给每个有竞态风险的实现配一个 JCStress 测试,命名用「被测类 + 场景」:

测试类被测实现关键结果
StockRaceTestUnsafeLoanService 的读改写扣减stock=-1, success=2 为 FORBIDDEN
FixedStockTestAtomicLoanService 的 CAS 扣减只允许 0, 1
ReservationCountTest预约登记的双写中间态用 ACCEPTABLE_INTERESTING

报告里必须同时记录:运行模式(quick / default)、CPU 数、每轮样本数、每个 @Outcome 的命中数。只写「测试通过」没有信息量——读者无法判断这是「跑了 2 亿次没出事」还是「跑了 3 次没出事」。

常见错误与边界

错误现象正确做法
漏写 <proc>full</proc>构建成功,运行 NPE 在 TestList.getTestsJDK 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 的线性一致性检查

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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