三个模板的定位
前三章分别讲了 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,绝对值只能当量级参考;模板可以直接复制,换成自己的机器再跑一遍,才是能写进报告的读数。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。