《Java 并发编程·性能诊断与事故》附录 B JCStress/JMH/压测模板

三个可直接复制的模板:JCStress 0.16 的 Maven 工程含 proc full、可序列化 Result 与 toString 约定;JMH 1.37 的 pom 与基准类给出 -wi/-i/-w/-r/-f 参数和误差列读法;无第三方依赖的开环压测程序给 p50/p90/p99/p999 与拒绝计数。全部在本机实跑并贴真实输出。

三个模板的定位

前三章分别讲了 JCStress、JMH 和系统压测的方法,这一篇只给能直接复制的工程骨架。三个模板都在本机 Temurin 25.0.5+8、Maven 3.9.12 上跑过,命令与输出原样贴出;跑不通的地方会写明。

模板回答什么问题单次耗时依赖
JCStress交错执行下会不会出现非法结果-m quick 约 1–2 分钟jcstress-core:0.16
JMH稳定态下有多快、误差多大约 10 秒(本附录参数)jmh-core 与 jmh-generator-annprocess:1.37
开环压测给定到达率下的时延分位与拒绝率10 秒无第三方依赖

三个模板共用一条铁律:JDK 23 起 javac 不再隐式运行注解处理器,JCStress 和 JMH 都靠它生成清单,Maven 里必须写 <proc>full</proc>。构建成功不等于能跑,后面两个「构建成功、运行报错」的真实输出就是证据。

模板一:JCStress

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<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-SNAPSHOT</version>
  <packaging>jar</packaging>

  <properties>
    <maven.compiler.release>25</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.openjdk.jcstress</groupId>
      <artifactId>jcstress-core</artifactId>
      <version>0.16</version>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.13.0</version>
        <configuration><release>25</release><proc>full</proc></configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <version>3.6.0</version>
        <executions><execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <finalName>jcstress</finalName>
            <createDependencyReducedPom>false</createDependencyReducedPom>
            <transformers>
              <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>org.openjdk.jcstress.Main</mainClass>
              </transformer>
            </transformers>
          </configuration>
        </execution></executions>
      </plugin>
    </plugins>
  </build>
</project>

测试类与结果类

package com.plume.conc.lab;
import org.openjdk.jcstress.annotations.*;
@JCStressTest
@Outcome(id = "2", expect = Expect.ACCEPTABLE, desc = "两次自增都生效")
@Outcome(id = "1", expect = Expect.ACCEPTABLE_INTERESTING, desc = "丢失一次更新")
@Outcome(id = "0", expect = Expect.FORBIDDEN, desc = "actor 没有执行")
@State
public class LostUpdateTest {
    int stock;
    @Actor public void borrowerA() { stock++; }
    @Actor public void borrowerB() { stock++; }
    @Arbiter public void observe(StockResult result) { result.stock = stock; }
}
package com.plume.conc.lab;
import java.io.Serializable;
import org.openjdk.jcstress.annotations.Result;
@Result
public class StockResult implements Serializable {
    public int stock;
    @Override public boolean equals(Object o) { return o instanceof StockResult s && stock == s.stock; }
    @Override public int hashCode() { return Integer.hashCode(stock); }
    @Override public String toString() { return String.valueOf(stock); }
}

运行与真实输出

mvn -q -o package
java -jar target/jcstress.jar -m quick
.......... [OK] com.plume.conc.lab.LostUpdateTest

  RESULT      SAMPLES     FREQ       EXPECT  DESCRIPTION
       0            0    0.00%    Forbidden  actor 没有执行
       1  485,330,596   51.33%  Interesting  丢失一次更新
       2  460,202,598   48.67%   Acceptable  两次自增都生效

stock++ 是「读-改-写」三步,两个 actor 交错时约一半的样本只加了一次。把 stock 换成 AtomicInteger、把 id 1 改成 Expect.FORBIDDEN,同一套工程就能当回归测试:非法结果一旦出现,进程退出码非 0。

三个只在运行时才暴露的坑

坑一:没有 <proc>full</proc>。 构建退出码 0,运行直接 NPE:

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)

坑二:@Result 类没有 implements Serializable。 预检阶段抛 java.io.NotSerializableException: com.plume.conc.lab.StockResult,一条结果都跑不出来。

坑三:没有重写 toString。 只补 equals/hashCode 而不补 toString 时,结果列显示成 StockResult@20 这种身份哈希,@Outcome(id = "1") 永远匹配不上。JCStress 用 String.valueOf(result) 当 outcome id;三个方法都缺时,计数器把每个结果对象当独立 key,几秒内 OutOfMemoryError: Java heap space。

模板二:JMH

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.plume.conc</groupId>
  <artifactId>conc-lab-jmh</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>jar</packaging>

  <properties>
    <maven.compiler.release>25</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.openjdk.jmh</groupId>
      <artifactId>jmh-core</artifactId>
      <version>1.37</version>
    </dependency>
    <dependency>
      <groupId>org.openjdk.jmh</groupId>
      <artifactId>jmh-generator-annprocess</artifactId>
      <version>1.37</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.13.0</version>
        <configuration><release>25</release><proc>full</proc></configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <version>3.6.0</version>
        <executions><execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <finalName>benchmarks</finalName>
            <createDependencyReducedPom>false</createDependencyReducedPom>
            <transformers>
              <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>org.openjdk.jmh.Main</mainClass>
              </transformer>
            </transformers>
          </configuration>
        </execution></executions>
      </plugin>
    </plugins>
  </build>
</project>

基准类

package com.plume.conc.lab;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import org.openjdk.jmh.annotations.*;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 3, time = 1)
@Fork(1)
@Threads(4)
@State(Scope.Benchmark)
public class CounterBench {
    private final AtomicLong counter = new AtomicLong();
    @Benchmark
    public long atomicIncrement() {
        return counter.incrementAndGet();
    }
}

运行与真实输出

mvn -q -o package
java -jar target/benchmarks.jar -wi 2 -i 3 -w 1s -r 1s -f 1
# Warmup Iteration   1: 141.695 ±(99.9%) 44.507 ns/op
# Warmup Iteration   2: 140.136 ±(99.9%) 35.623 ns/op
Iteration   1: 130.697 ±(99.9%) 42.271 ns/op
Iteration   2: 141.358 ±(99.9%) 63.174 ns/op
Iteration   3: 153.187 ±(99.9%) 49.593 ns/op

Result "com.plume.conc.lab.CounterBench.atomicIncrement":
  141.747 ±(99.9%) 205.250 ns/op [Average]
  (min, avg, max) = (130.697, 141.747, 153.187), stdev = 11.250
  CI (99.9%): [≈ 0, 346.998] (assumes normal distribution)

怎么读:141.747 是平均单次耗时,±205.250 是 99.9% 置信区间半宽,不是标准差;(min, avg, max) 是三次迭代的最小/平均/最大,stdev = 11.250 才是迭代间离散度。这次 stdev 只有均值的 8%,置信区间却比均值还宽、下界被算到 0,因为 -i 3 只给了 3 个样本,t 分布的分位数很大。误差宽到这个程度时,不要拿 130 与 153 比大小:要么加 -i 10,要么加 -f 3 换 fork,要么换一台空闲机器——本机写作期间 load average 是 86,这个数字只能当量级参考。

参数:-wi 预热迭代数,-i 测量迭代数,-w/-r 每次迭代秒数,-f fork 数。命令行会覆盖注解同名设置,-f 1 把 @Fork(1) 固定成单进程。

没有 <proc>full</proc> 时,mvn package 退出码 0、jar 也能生成,但运行报:

Exception in thread "main" java.lang.RuntimeException: ERROR: Unable to find the resource: /META-INF/BenchmarkList
	at org.openjdk.jmh.runner.AbstractResourceReader.getReaders(AbstractResourceReader.java:98)

另外,JMH 1.37 自己会调用 sun.misc.Unsafe,运行时打印 WARNING: A terminally deprecated method in sun.misc.Unsafe has been called。这条警告来自 JMH 的 org.openjdk.jmh.util.Utils,不是你的基准代码,升级 JMH 前可以忽略。

模板三:开环压测

微基准回答「一次操作多快」,压测回答「给定到达率下系统什么表现」。下面这个程序没有第三方依赖,单文件可跑:派发线程按固定间隔投递,有界队列 + AbortPolicy 计拒绝,记录「计划到达时刻 → 完成时刻」的时延并算分位。

import java.util.Arrays;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.concurrent.locks.LockSupport;
public final class OpenLoopLoadTest {
    private static final AtomicInteger rejected = new AtomicInteger();
    private static final AtomicLong completed = new AtomicLong();
    public static void main(String[] args) throws Exception {
        int rate = args.length > 0 ? Integer.parseInt(args[0]) : 8_000;
        int seconds = args.length > 1 ? Integer.parseInt(args[1]) : 10;
        int workers = args.length > 2 ? Integer.parseInt(args[2]) : 32;
        int capacity = args.length > 3 ? Integer.parseInt(args[3]) : 512;
        long serviceMillis = args.length > 4 ? Long.parseLong(args[4]) : 5;
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                workers, workers, 0L, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(capacity),
                r -> new Thread(r, "borrow-worker"),
                new ThreadPoolExecutor.AbortPolicy());
        int total = rate * seconds;
        long[] latencyNanos = new long[total];
        long intervalNanos = 1_000_000_000L / rate;
        long start = System.nanoTime();
        for (int i = 0; i < total; i++) {
            long arrival = start + i * intervalNanos;
            long wait = arrival - System.nanoTime();
            if (wait > 0) LockSupport.parkNanos(wait);
            try {
                pool.execute(() -> {
                    LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(serviceMillis));
                    int slot = (int) completed.getAndIncrement();
                    latencyNanos[slot] = System.nanoTime() - arrival;
                });
            } catch (RejectedExecutionException e) {
                rejected.incrementAndGet();
            }
        }
        pool.shutdown();
        pool.awaitTermination(30, TimeUnit.SECONDS);
        long elapsed = System.nanoTime() - start;
        int accepted = (int) completed.get();
        long[] samples = Arrays.copyOf(latencyNanos, accepted);
        Arrays.sort(samples);
        System.out.printf("offered = %d in %.2f s, accepted = %d, rejected = %d (%.2f%%)%n",
                total, elapsed / 1e9, accepted, rejected.get(), 100.0 * rejected.get() / total);
        System.out.printf("achieved = %.0f req/s, p50 = %.2f ms%n",
                accepted / (elapsed / 1e9), pct(samples, 0.50));
        System.out.printf("p90 = %.2f ms, p99 = %.2f ms, p999 = %.2f ms, max = %.2f ms%n",
                pct(samples, 0.90), pct(samples, 0.99), pct(samples, 0.999), pct(samples, 1.0));
    }
    private static double pct(long[] sorted, double p) {
        if (sorted.length == 0) return Double.NaN;
        int idx = (int) Math.min(sorted.length - 1L, Math.round(p * (sorted.length - 1)));
        return sorted[idx] / 1_000_000.0;
    }
}
javac -d out OpenLoopLoadTest.java
java -cp out OpenLoopLoadTest 4000 10 32 512 5
java -cp out OpenLoopLoadTest 8000 10 32 512 5

低于容量时(4 000/s,32 worker,服务 5 ms):

offered = 40000 in 10.01 s, accepted = 40000, rejected = 0 (0.00%)
achieved = 3997 req/s, p50 = 6.36 ms
p90 = 7.64 ms, p99 = 13.69 ms, p999 = 19.01 ms, max = 22.36 ms

超过容量时(8 000/s,同一套 worker):

offered = 80000 in 10.12 s, accepted = 53142, rejected = 26858 (33.57%)
achieved = 5252 req/s, p50 = 102.68 ms
p90 = 106.17 ms, p99 = 109.05 ms, p999 = 116.02 ms, max = 120.48 ms

怎么读:

  • 时延数组按完成顺序占位(completed.getAndIncrement()),不是按提交下标。按提交下标写、拒绝时留空洞,排序会把一堆 0 混进 p50,分位数整体偏移。
  • 第一组 p50 = 6.36 ms 对服务时间 5 ms,多出的约 1.4 ms 是调度与队列开销,这是这套机器的「底噪」;p99 = 13.69 ms 是这台负载 86 的机器上的尾部长相。
  • 第二组 rejected = 33.57%、achieved 只有 5 252/s:队列满后 AbortPolicy 直接抛,实际接受速率低于理论容量。
  • 时延从计划到达时刻算,不是从入队时刻算。这是开环压测与「线程池里 sleep 再计时」最大的区别:排队时间必须计入,否则拒绝了 36% 还会显示 p99 只有 10 ms。
  • p999 至少要有 1 000 个样本;10 秒 × 4 000/s 有 4 万个,够用。样本不足时它会退化成 max。
  • 线程数按契约上限 256,本模板默认 32;压测机自己先过载,分位数就失去意义。

三个模板的共同坑

现象根因处理
构建成功、运行找不到测试/基准JDK 25 默认不跑注解处理器compiler 插件加 <proc>full</proc>
JCStress 结果列是 XxxResult@1a2b@Result 类没重写 toString与 @Outcome(id=...) 对齐
JCStress 预检 NotSerializableException@Result 类没实现 Serializable加 implements Serializable
JMH 置信区间比均值还宽迭代数太少或机器过载加 -i、加 -f,或换空闲机器
压测 p99 漂亮但拒绝率很高时延从入队时刻算从计划到达时刻算,并报拒绝数

小结

三个模板解决三个层次:JCStress 找「会不会错」,JMH 量「一次多快、误差多大」,开环压测评「给定负载下怎么退化」。三者的共同前提是构建配置正确——<proc>full</proc> 漏掉,前两个都会在运行时才报错。所有数字来自本机单次运行、load average 86,绝对值只能当量级参考;模板可以直接复制,换成自己的机器再跑一遍,才是能写进报告的读数。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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