承接的问题
1.3 把吞吐、时延与并发摆成了三角,但三角上的每一个点都要由一个具体数字填进去:「扣一次库存」在这台机器上到底要多少纳秒?这个数字没法靠读代码得到,也没法靠 System.nanoTime() 包一层 for 循环得到——后者测到的往往是 JIT 优化后的、被死代码消除过的、被操作系统调度污染的混合物。
JMH(Java Microbenchmark Harness)是 OpenJDK 官方维护的微基准框架,它做的事只有一件:把你写的「被测方法」放进一个受控的测量框架里,给出均值、误差区间和可复现的原始数据。这一节用一个完整的基准回答三个问题:怎么写、怎么跑、结果怎么读。至于基准本身可能错在哪里,2.2 专门讲。
构造场景:libraryd-conc 的库存扣减有三种写法——synchronized 块、AtomicInteger、VarHandle。业务上它们都能把 stock 减一,团队想知道 4 线程并发时哪个更快。这一节就测这件事。
一个能跑起来的基准
先给完整的工程。pom.xml 只有两个依赖和一个必须显式声明的编译器参数:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.plume.conc</groupId>
<artifactId>conc-lab-bench</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<jmh.version>1.37</jmh.version>
</properties>
<dependencies>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
<scope>provided</scope>
</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>
<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>
<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>
<proc>full</proc> 不是可选装饰。JDK 23 起 javac 默认不再隐式运行注解处理器,JMH 的 META-INF/BenchmarkList 就没人生成。本机实测:删掉这一行,mvn package 依然退出码 0,java -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)
at org.openjdk.jmh.runner.BenchmarkList.find(BenchmarkList.java:124)
「编译成功、运行时才发现没有基准」是 JDK 23 之后最容易踩的一脚。
基准类只测原语,不含库存校验与仓储调用,这样测出来的差额才能归因到同步方式本身:
package com.plume.conc.lab;
import org.openjdk.jmh.annotations.*;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 3, time = 1)
@Fork(1)
@Threads(4)
public class ReserveCounterBench {
static final VarHandle COUNT;
static {
try {
COUNT = MethodHandles.lookup().findVarHandle(Mutable.class, "count", int.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
public static final class Mutable {
int count;
final AtomicInteger atomic = new AtomicInteger();
}
@State(Scope.Benchmark)
public static class Shared {
public final Mutable mutable = new Mutable();
@Setup(Level.Iteration)
public void reset() {
mutable.count = 0;
mutable.atomic.set(0);
}
}
@Benchmark
public int synchronizedDecrement(Shared s) {
synchronized (s.mutable) {
return --s.mutable.count;
}
}
@Benchmark
public int atomicDecrement(Shared s) {
return s.mutable.atomic.decrementAndGet();
}
@Benchmark
public int varHandleDecrement(Shared s) {
return (int) COUNT.getAndAdd(s.mutable, -1) - 1;
}
}
两种跑法都能得到同一份结果。第一种走 shade 打出的可执行 jar:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
mvn -q -DskipTests package
java -jar target/benchmarks.jar -wi 2 -i 3 -w 1s -r 1s -f 1
第二种不打包,直接用 classpath 启动 org.openjdk.jmh.Main,适合 CI 里不想跑 shade 的场合(本机已验证可列出全部基准):
CP="target/classes:$HOME/.m2/repository/org/openjdk/jmh/jmh-core/1.37/jmh-core-1.37.jar"
CP="$CP:$HOME/.m2/repository/net/sf/jopt-simple/jopt-simple/4.6/jopt-simple-4.6.jar"
CP="$CP:$HOME/.m2/repository/org/apache/commons/commons-math3/3.6.1/commons-math3-3.6.1.jar"
java -cp "$CP" org.openjdk.jmh.Main -wi 2 -i 3 -w 1s -r 1s -f 1
@State 的三种作用域
@State 决定了状态对象「一个 fork 里创建几份、几个线程共享一份」。三种作用域的区别不是风格问题,它直接决定你测的是「无竞争」还是「有竞争」:
| 作用域 | 实例数 | 线程可见性 | 典型用途 |
|---|---|---|---|
Scope.Benchmark | 每个 fork 一份 | 所有线程共享 | 共享计数器、连接池、缓存 |
Scope.Thread | 每个线程一份 | 线程私有 | 每线程缓冲区、随机数、局部累加 |
Scope.Group | 每个 group 一份 | 组内线程共享 | 生产者/消费者配对、伪共享实验 |
同一段自增代码,放进 Scope.Benchmark 是 4 个线程抢一个 AtomicLong,放进 Scope.Thread 是各抢各的。本机实测(4 线程,load average 105–132):
Benchmark Mode Cnt Score Error Units
StateScopeBench.perThreadIncrement thrpt 3 541355379.269 ± 53299991.137 ops/s
StateScopeBench.sharedIncrement thrpt 3 33823999.270 ± 234667506.446 ops/s
每线程一份是 5.41 亿 ops/s(约 1.8 ns 一次),共享一份是 3382 万,差 16 倍。这个倍数不是「AtomicLong 慢」,而是「同一个缓存行被 4 个核轮流改写」。如果基准里本该共享的状态写成了 Scope.Thread,你会测出一个漂亮但毫无意义的数字——这是 2.2 里「伪共享未隔离」的另一面。
Scope.Group 用来强制几个方法同时运行,@GroupThreads 指定每个方法分几个线程。2.2 的伪共享实验就靠它把两个线程绑到同一个 group 上。
@Fork 与 @BenchmarkMode
@Fork(1) 的意思是「每个基准方法单开一个 JVM 进程跑」。不 fork 会怎样?本机把 -f 1 换成 -f 0,同一份代码、同一组参数:
# -f 0(不隔离,全部基准在 JMH 启动器进程里顺序跑)
ReserveCounterBench.atomicDecrement thrpt 3 49105355.597 ± 162557270.260 ops/s
ReserveCounterBench.synchronizedDecrement thrpt 3 19616881.866 ± 69762815.711 ops/s
ReserveCounterBench.varHandleDecrement thrpt 3 49132552.141 ± 55314509.497 ops/s
对照 -f 1 的 2648 万 / 733 万 / 2466 万,三种写法分别虚高 1.9、2.7、2.0 倍。原因很朴素:不 fork 时,前一个基准的 JIT 编译结果、堆状态、类加载都留在同一个 JVM 里,后面的基准一上来就在「热」的代码缓存上跑。跨基准的数字一旦共享 JVM 就不可比,-f 0 只适合调试命令行参数,不适合出结论。
@BenchmarkMode 决定「一次迭代产出什么统计量」:
| 模式 | 产出 | 什么时候用 |
|---|---|---|
Throughput | 每秒多少次操作 | 比吞吐、比原语快慢 |
AverageTime | 每次操作多少纳秒 | 讲「一次调用多久」 |
SampleTime | 每次操作的采样分布 | 看尾延迟、看抖动 |
SingleShotTime | 只跑一次的总耗时 | 冷启动、类加载 |
All | 以上全部 | 探索阶段 |
Throughput 只有一个均值,看不见尾部。把同一个基准改成 -bm sample -tu ns,本机输出(244,371 个样本,4 线程争锁):
ReserveCounterBench.synchronizedDecrement sample 244371 3080.141 ± 815.511 ns/op
ReserveCounterBench.synchronizedDecrement:p0.50 sample 250.000 ns/op
ReserveCounterBench.synchronizedDecrement:p0.90 sample 1208.000 ns/op
ReserveCounterBench.synchronizedDecrement:p0.99 sample 3124.000 ns/op
ReserveCounterBench.synchronizedDecrement:p0.999 sample 130256.384 ns/op
ReserveCounterBench.synchronizedDecrement:p0.9999 sample 6371852.288 ns/op
ReserveCounterBench.synchronizedDecrement:p1.00 sample 13926400.000 ns/op
均值 3080 ns 是中位数 250 ns 的 12 倍,差额主要来自 P99 以上的长尾;最大值 13.9 ms 更是中位数的 5.5 万倍。只报 Throughput 的基准,会把这条尾巴完全藏起来——这也是 1.3 强调分位数的原因。
误差列怎么读
JMH 的 ±(99.9%) 不是「估计值可能偏这么多」,它的定义是:
error = t(0.999, n-1) × stdev / sqrt(n)
n = 3 时 t 分位数约 31.6,所以哪怕样本很稳,误差项也会被放大到标准差的 18 倍。用本机 Blackhole 基准的真实数字核对:stdev = 6480.418,31.6 × 6480.418 / sqrt(3) ≈ 118,226,与输出的 ±118226.941 一致。
于是读法有三条:
- 先看误差项和均值的比例。本机 ReserveCounter 的完整结果(load average 105–132,
-wi 2 -i 3 -w 1s -r 1s -f 1):
Benchmark Mode Cnt Score Error Units
BlackholeBench.sumBlackholed thrpt 3 742461.401 ± 118226.941 ops/s
BlackholeBench.sumDropped thrpt 3 1311185160.469 ± 1493707963.588 ops/s
ReserveCounterBench.atomicDecrement thrpt 3 26479812.839 ± 31407350.217 ops/s
ReserveCounterBench.synchronizedDecrement thrpt 3 7330331.392 ± 43258540.788 ops/s
ReserveCounterBench.varHandleDecrement thrpt 3 24664301.731 ± 10620923.611 ops/s
StateScopeBench.perThreadIncrement thrpt 3 541355379.269 ± 53299991.137 ops/s
StateScopeBench.sharedIncrement thrpt 3 33823999.270 ± 234667506.446 ops/s
置信区间重叠就别说谁快。
atomicDecrement的区间是[≈0, 5789 万],synchronizedDecrement是[≈0, 5059 万]——两条区间大面积重叠,只能得出「都在千万量级」。varHandle的区间[1404 万, 3529 万]虽然不含 0,但atomic的下界是 0,两者仍然重叠,同样排不出先后。区间下界为 0 时,方向不可信。
synchronizedDecrement的三次测量是 481 万 → 765 万 → 952 万,单调上升,说明两次预热迭代根本没让它进入稳态;均值 733 万只能当下界看,误差项 4325 万比均值本身大 6 倍。
本机 load average 常年 70–170,这些区间宽是真实环境的如实反映,不是 JMH 的缺陷。要在这样的机器上分出胜负,唯一诚实的说法是「分不出」,或者换一台隔离的机器重跑。
Blackhole 的正确用法
JMH 用 Blackhole 防止被测代码的结果被优化掉:方法有返回值时,JMH 会把返回值消费掉;方法返回 void 时,你必须自己消费。两种写法:
@Benchmark
public int withReturn(Shared s) {
return s.mutable.atomic.incrementAndGet();
}
@Benchmark
public void withBlackhole(Shared s, Blackhole bh) {
int v = s.mutable.atomic.incrementAndGet();
bh.consume(v);
}
Blackhole 以方法参数的形式注入,同一个 @Benchmark 方法里可以有多个被消费的值。它内部有编译器可见的消费语义,JIT 不会把被 consume 的值当作死代码删掉。
反过来,如果一个「计算型」基准忘了消费结果,你会得到一个好看到不真实的数字。本机同一个求和循环:带 bh.consume(sum) 是 742,461 ops/s,不消费是 1,311,185,160 ops/s,差 1766 倍——后者整个循环被删掉了。完整对照与判定方法放在 2.2。
在 libraryd-conc 里落地
基准要能被复核,报告里必须同时记录五件事,缺一件数字就不可引用:
| 项 | 本机示例 | 为什么必须 |
|---|---|---|
| 机器与 JDK | Apple M1 Pro 10 核 32 GB,Temurin 25.0.5+8 | 换机器排序可能翻转 |
| 负载 | uptime,如 load averages: 105.07 117.61 117.14 | 负载决定误差项宽度 |
| JMH 参数 | -wi 2 -i 3 -w 1s -r 1s -f 1 | 迭代数决定 t 分位数 |
| 完整输出 | 均值 + 误差 + 每次迭代原始值 | 只看均值会漏掉趋势 |
| 结论强度 | 「分不出」也是结论 | 防止把噪声写进设计文档 |
conc-lab 里给基准加一个启动脚本,把 uptime 和 java -version 一起写进结果文件:
#!/bin/sh
set -eu
out="bench-$(date +%Y%m%d-%H%M%S).txt"
{
date
uptime
java -version 2>&1
java -jar conc-lab-bench/target/benchmarks.jar -wi 3 -i 5 -w 1s -r 1s -f 1
} | tee "$out"
参数上给一个经验口径:正式报告用 -wi 3 -i 5,探索阶段用 -wi 1 -i 2。前者在 10 核机器上单个基准约 8 秒,50 个基准约 7 分钟,可以对每个 PR 跑;后者只用来确认基准本身跑得通。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
漏写 <proc>full</proc> | 编译通过、运行时报 Unable to find /META-INF/BenchmarkList | JDK 23+ 显式声明 |
用 -f 0 出结论 | 数字虚高约 2 倍,基准间互相污染 | 至少 -f 1 |
共享状态写成 Scope.Thread | 测出无竞争的理想值 | 按业务是否共享来选作用域 |
返回 void 且不消费结果 | 整个计算被死代码消除 | 返回值或 bh.consume |
| 只看均值不看误差 | 把 3 倍以内的差异当结论 | 区间重叠就说分不出 |
| 在 CI 共享 runner 上跑微基准 | 误差项宽到没有信息量 | 固定机器、记录 load |
把 SampleTime 的 p99 当业务 P99 | 微基准没有网络与排队 | 系统级压测另做(2.3) |
什么时候不要用 JMH:当你真正想知道的是「这套服务在 200 QPS 下的 P99 是多少」时。JMH 的调用栈里没有网络、没有连接池、没有队列、没有 GC 与业务对象的生命周期,它只能回答「这段代码本身多快」。把 JMH 的数字直接换算成容量,是 2.3 要拆的错误。
小结
JMH 的价值不在于它算得快,而在于它把「不可比」变成「可比」:@State 定义了共享边界,@Fork 切断了基准之间的污染,@BenchmarkMode 决定了你看均值还是看分布,±(99.9%) 则诚实地告诉你「这次测量有多不确定」。本机这组数字里,只有「每线程状态比共享状态快 16 倍」这一条方向明确;三种扣减写法之间的差异,被宽到 0 的置信区间吞掉了。承认这一点,比编一个 3.2 倍的加速比有用得多。
下一节把这套框架拆开:基准写得对,不代表测的是你以为的那件事。
阅读导航:上一节 1.3 吞吐、时延与并发的三角 | 下一节 2.2 微基准的陷阱
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。