《Java 并发编程·性能诊断与事故》8.3 竞态复现、最小化与回归

从构造的负库存现象出发,给出把 1024 并发缩到 2 个 actor、库存缩到 1 的四步最小化路径;用本机两轮 JCStress 命中率与 StressSeed 说明概率复现的边界,并给出可阻断 CI 的回归测试、Maven profile 与 nightly 成本预算。

承接的问题

前两节给了两个发现竞态的工具:JCStress 用概率采样,Lincheck 用模型检验。但工具只负责「发现」,工程上还有三步没人替你做:

  1. 把大现场缩成小用例。线上是 1024 并发、库存 500、三个下游通知;你不可能把这一整套塞进测试。
  2. 把偶发变成可重复的证据。一次运行没命中,不代表修好了;一次命中,也不代表下次还能命中。
  3. 把用例固化成门禁。修完就删测试,是并发 bug 最常见的复发方式。

这一节用一个构造场景走完这三步。场景不冒充任何真实事故:把 libraryd-conc 的 PooledLoanService 放到 1024 并发下,库存 500,压测脚本观察到库存变成负数、成功借出数大于库存。这个现象在 1.3 的并发阶梯实验里就能复现,不需要线上流量。

症状、证据与第一刀

症状:压测结束后,库存字段为 -3,成功借出次数为 503。业务约束是 stock >= 0 且成功次数不得超过初始库存。

证据:把压测时的服务实现从 PooledLoanService 换成 AtomicLoanService,同样的并发档位不再出现负库存;换回 UnsafeLoanService 的扣减逻辑,负库存重现。这把范围缩到「库存扣减的读改写」这一小段。

根因假设:扣减是「读—判断—写」,不是原子操作。两个线程可以同时读到 1、同时通过判断、同时写回 0 和 -1。

第一刀:砍掉一切与竞态无关的东西。 仓储、通知、参数校验、指标上报全部删掉,只留下一个 AtomicInteger stock 和两个调用者。这一步的判据是:删完之后 bug 仍然出现。如果删掉某样东西后 bug 消失,那它才是根因的一部分,不能删。

最小化的目标不是「代码少」,而是让每一次运行都在回答同一个问题:这段读改写有没有窗口?仓储慢了、通知失败了、GC 暂停了,都不该影响这个问题的答案。

四把剪刀:状态、线程、循环、种子

最小化可以拆成四个独立动作,每个动作都有明确的收益和边界:

剪刀从到收益边界
缩状态库存 500、3 个会员、3 个下游库存 1、2 个 actor结果空间从上千种降到 2 种库存不能为 0,否则没有可借的书
减线程1024 并发2 个 actor交错从天文数字降到可枚举少于 2 个就没有并发
缩循环压测 10 分钟-m quick,约 7 秒/测试反馈从小时级降到秒级样本变少,命中率估计变粗
固定种子每次随机 JIT 配置记录 -XX:StressSeed失败可回放同一组编译随机化种子只管 JIT,不管线程调度

缩状态的关键是「让非法结果只有一个」。库存 500 时,负库存可能是 -1 到 -500 的任何值,每个值都要一条 @Outcome;库存 1 时,非法结果只有 stock=-1, success=2 这一种,合法结果只有 stock=0, success=1 这一种。结果空间越小,报告越好读。

减线程要抵抗「线程越多越容易复现」的直觉。两个 actor 已经能构造「读、读、写、写」的完整交错;加到 8 个 actor,只是让结果表多出许多无关组合,并不会让窗口变宽。真正需要 3 个以上 actor 的场景是「三方竞争下的 ABA」这类问题,普通的检查再动作两个就够。

缩循环用 JCStress 的模式预设。本机 8.1 的实测:-m quick -c 8 跑两个测试约 14.7 秒,平均每个测试 7 秒多。这个时间足够放进 PR 门禁;-m default 和 -m tough 的迭代更多,本节没有实测,接入 nightly 时要先用一次完整运行校准预算,不要照抄别人的分钟数。

固定种子要理解它固定的是什么。JCStress 的失败报告会打印出问题的 JVM 配置,本机真实的一行是:

com.plume.conc.lab.StockRaceTest [-XX:+StressLCM, -XX:+StressGCM, -XX:+StressIGVN, -XX:+StressCCP, -XX:StressSeed=858442236]: Observed forbidden state: stock=-1, success=2

把这一串参数通过 -jvmArgs 传给 JCStress,可以复现同一组 JIT 随机化配置。但它不控制操作系统的线程调度,所以同一个种子不保证命中同一个交错。这一点必须写进复现记录,否则下一个接手的人会以为「种子一样就一定能复现」。

从概率到确定:两条复现路径

拿到最小用例后,复现方式有两种,适用场景不同:

路径一:JCStress 多轮采样。 8.1 的两轮实测:

第一轮:FORBIDDEN 18,299,285 / 221,769,994 = 8.25%
第二轮:FORBIDDEN 16,420,052 / 214,714,634 = 7.65%

两轮合计约 4.36 亿个样本,命中率相差 0.6 个百分点。这说明两件事:窗口确实存在,且触发频率不是常数。报告里要写「两轮、每轮约 2.1 亿样本、命中率 7.65%–8.25%」,而不是「触发概率 8%」。

如果两轮都没命中,不能直接判「无竞态」。可行的做法是:先确认测试类真的被 TestList 收录(8.1 的 <proc>full</proc> 问题),再逐步提高 -iters 或换 -m default,并记录总共跑了多少样本。样本数不够的「没复现」是无效结论。

路径二:Lincheck 的最小场景。 8.2 的模型检验会直接给出「线程 1 borrow(): 0、线程 2 borrow(): 0」这样一个确定的失败序列。它不依赖概率,适合做「修没修好」的判据;代价是需要 Kotlin 工具链,本节所在环境没有执行过。

两条路径的分工可以记成一句话:JCStress 证明「在真实硬件上会发生」,Lincheck 证明「在某个合法交错下必然发生」。 回归测试优先用后者做判据,用前者做补充证据。

固化成回归测试

回归测试的第一条要求是「失败必须让构建失败」。JCStress 的退出码已经满足:出现 FORBIDDEN 时进程退出码为 1(本机实测),全部通过时为 0。所以不需要自己解析报告,直接把命令接进 CI 即可。

先给一个完整的、可直接提交的测试类。它把 8.1 的读改写与修复版放在同一个文件里,用两个 @JCStressTest 类分别验证「反例必须失败、修复必须通过」:

package com.plume.conc.lab;

import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
import java.util.concurrent.atomic.AtomicInteger;

public final class StockRegression {

    @JCStressTest
    @Outcome(id = "0, 1", expect = Expect.ACCEPTABLE,
             desc = "库存 1 只允许成功借出一次")
    @Outcome(id = "-1, 2", expect = Expect.FORBIDDEN,
             desc = "两位会员借到同一本书")
    @Outcome(expect = Expect.ACCEPTABLE_INTERESTING, desc = "其他中间态")
    @State
    public static class Unsafe {
        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 arbiter(II_Result r) {
            r.r1 = stock.get();
            r.r2 = success.get();
        }
    }

    @JCStressTest
    @Outcome(id = "0, 1", expect = Expect.ACCEPTABLE,
             desc = "CAS 版本只允许成功借出一次")
    @Outcome(id = "-1, 2", expect = Expect.FORBIDDEN,
             desc = "CAS 版本不应超卖")
    @Outcome(expect = Expect.ACCEPTABLE_INTERESTING, desc = "其他中间态")
    @State
    public static class CasFixed {
        final AtomicInteger stock = new AtomicInteger(1);
        final AtomicInteger success = new AtomicInteger();

        @Actor
        public void alice() {
            borrow();
        }

        @Actor
        public void bob() {
            borrow();
        }

        private void borrow() {
            int cur;
            do {
                cur = stock.get();
                if (cur == 0) {
                    return;
                }
            } while (!stock.compareAndSet(cur, cur - 1));
            success.incrementAndGet();
        }

        @Arbiter
        public void arbiter(II_Result r) {
            r.r1 = stock.get();
            r.r2 = success.get();
        }
    }
}

注意两个 @State 都是 public static 嵌套类。JCStress 要求状态类不是 final、且是 public;嵌套类写成 private 会在注解处理阶段报错。文件名 StockRegression.java 与公开类名一致,javac 才会生成正确的 class 文件。

第二块是把命令接进 Maven。用 exec-maven-plugin 在 verify 阶段跑 org.openjdk.jcstress.Main:

<profile>
  <id>jcstress</id>
  <build>
    <plugins>
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>3.5.0</version>
        <executions>
          <execution>
            <id>jcstress-quick</id>
            <phase>verify</phase>
            <goals><goal>java</goal></goals>
            <configuration>
              <mainClass>org.openjdk.jcstress.Main</mainClass>
              <arguments>
                <argument>-m</argument>
                <argument>quick</argument>
                <argument>-c</argument>
                <argument>4</argument>
                <argument>-t</argument>
                <argument>CasFixed</argument>
              </arguments>
              <classpathScope>test</classpathScope>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</profile>

这段配置没有在本机以 Maven 插件方式执行过,但同一份 StockRegression 类用等价的命令行实跑过:

java -cp "$CP" org.openjdk.jcstress.Main -m quick -c 8 -t 'StockRegression'

本机结果:14.6 秒;Unsafe 在 253,913,354 个样本中命中 22,807,168 次 FORBIDDEN(8.98%),CasFixed 通过。整体退出码是 1——因为反例按设计失败。这条命令验证了两件事:嵌套的 @JCStressTest 类能被 -t 选择器匹配到,正例和反例在同一个文件里可以各自独立判定。

第一次接入时,先用 mvn verify -Pjcstress 单独确认两件事:插件能启动 JCStress、失败时 Maven 退出码非零。不要直接把它塞进主干流水线。

CI 脚本里再加一层保险:记录时间、JDK 版本、样本数,并把报告目录作为构建产物保留。

#!/bin/sh
set -eu

report="target/jcstress-$(date +%Y%m%d-%H%M%S)"
{
  date
  uptime
  java -version 2>&1
} > "${report}-env.txt"

mvn -q -DskipTests package
mvn -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt
CP="target/classes:$(cat target/cp.txt)"

java -cp "$CP" org.openjdk.jcstress.Main \
  -m quick -c 4 -t 'CasFixed' \
  -r "$report"

-r 让 JCStress 把 HTML 报告写到指定目录,失败时可以直接把目录上传为 CI 产物。脚本本身没有做容错,set -eu 会让 JCStress 的非零退出码直接终止流水线——这正是我们要的「失败即阻断」。

CI 的成本控制

并发测试的成本不是线性增长的:线程数每加一,交错数就上一个量级。CI 里要按「反馈速度」和「覆盖强度」分两层:

层级触发模式测试范围预算判据
PR 门禁每次提交quick每个竞态测试一个最小用例单测试约 7 秒,10 个测试约 1–2 分钟任何 FORBIDDEN 即失败
nightly每天一次default / tough全部 JCStress + Lincheck 场景本节未实测,需单独校准命中率趋势、样本数、种子
发布前手动quick + 压测回归集 + 15.3 验收按发布窗口双重通过

三条成本纪律:

  1. PR 只跑最小用例。最小用例的状态空间已经被剪刀剪过,quick 模式下 7 秒能跑完;不要把压测脚本搬进 PR。
  2. 种子和样本数必须进日志。失败报告里的 -XX:StressSeed、总样本数、每轮命中数,缺一样就无法判断「这次失败是新问题还是老问题」。
  3. nightly 跑全量,但不阻塞 PR。全量跑得慢是正常的,它的价值是发现「PR 用例没覆盖到的交错」,失败后第二天再补最小用例。

关于 flaky:并发测试的「偶发失败」不能按普通测试的方式重跑三次就放过。正确做法是保留失败报告、记录样本数、在同一台机器上用同一命令复跑,并把复跑结果一并写进 issue。一次 FORBIDDEN 就是一次真实的执行历史,它已经证明存在一条非法交错,不管下一次能不能再撞上。

验证修复

修复的判据分两层:

第一层是可线性化论证。 把 borrow() 改成 CAS 循环后,线性化点明确落在「CAS 成功」那一瞬间;CAS 失败意味着操作没有生效,重试不会产生额外副作用。这一段论证不依赖测试工具。

第二层是测试证据。 本机 FixedStockTest 用 -v 跑了一轮:

RESULT      SAMPLES     FREQ       EXPECT  DESCRIPTION
 -1, 2            0    0.00%    Forbidden  CAS 版本不应出现超卖
  0, 1  234,815,754  100.00%   Acceptable  一本书只被一位会员借走,库存归零

2.35 亿个样本、0 次 FORBIDDEN、合法结果 100%。这个数字的正确读法是「在这台机器、这个负载、这个模式下没出现」,不是「绝对正确」。它的价值在于:如果哪天有人把 CAS 改回读改写,同一个测试会在几秒内重新变红。

修复后还要做一件事:把反例测试保留下来,但不要让它进 PR 门禁。StockRegression 里的 Unsafe 类按设计必然失败,它的用途是演示和教学;门禁用 CasFixed 或单独的 FixedStockTest。把「必然失败的反例」和「必须通过的正例」混在同一个 -t 选择器里,流水线会永远红着。

常见错误与边界

错误后果正确做法
最小化时把库存改成 0没有操作能执行,测试永远通过库存保持 1,正好允许一次成功
只留 1 个 actor没有交错,测不出竞态至少 2 个
把 StressSeed 当成完整复现换一台机器仍不复现种子只固定 JIT,交错仍靠采样
一轮没命中就判定修复把概率当确定性记录样本数,多轮或换模式
反例测试放进 PR 门禁流水线永远失败反例只做教学,门禁用正例
-t 选择器写类名简写匹配不到,0 个测试却退出 0用全限定名或先 -l 确认

什么时候不要做最小化:当症状本身依赖外部系统时,比如「数据库连接池耗尽后线程池排队」。这类问题的根因不在某一段读改写,硬缩成两个线程会得到一个「通过」的假象。此时应该用 12.1 的指标和 2.3 的压测,而不是 JCStress。

小结

竞态处理可以压成一条流水线:从大现场里切出最小状态,用两个 actor 制造交错,用 quick 模式在秒级拿到命中率,用种子和样本数记录证据,最后把正例放进 CI 门禁、把全量留给 nightly。

本机的数字只说明这条流水线跑得通:两轮约 4.36 亿样本、命中率 7.65%–8.25%,修复版 2.35 亿样本、0 命中。它们不是这段代码的固有参数,换机器、换负载、换 JDK 都会变。真正可复用的,是「样本数 + 命中率 + 种子 + 退出码」这四件套——它让一次偶发失败变成一条可交接的证据。

第 8 章到此结束。下一章回到事故现场,从「过期读与无限循环」开始,看可见性问题如何在没有明显竞态的情况下让服务卡死。

阅读导航:上一节 8.2 Lincheck 的线性一致性检查 | 下一节 9.1 过期读与无限循环

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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