本节目标:把「这段代码快不快」从手感变成可复现的测量:用 JMH 1.37 跑
libraryd-benchmarks,理解 Warmup、Measurement、Fork 和 Blackhole 各自解决什么问题,并知道哪些数字不能拿来做决策。
适用版本:JDK 25(Temurin 25.0.5)、JMH 1.37(jmh-core+jmh-generator-annprocess)、Maven 3.9.12。机器为 Apple Silicon(arm64)、macOS 26.3。
承接的问题
第 7 章把 Loan.state 写成了 sealed 层次加模式匹配。团队里很快出现两种说法:
- 「record 分配多,
switch模式匹配比instanceof链慢。」 - 「批量循环比 stream 快,因为 stream 有 lambda 开销。」
这两种说法都可能是错的,原因不是结论本身,而是测量方法。手写基准通常长这样:
// 反例:没有预热、没有消费结果、没有隔离 JIT
long start = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
loan.state(today);
}
long elapsed = System.nanoTime() - start;
System.out.println(elapsed / 1_000_000.0 + " ns/op");
这段代码至少有四个问题:
- 前几万次调用跑在解释器和 C1 编译的代码上,JIT 还没把
state内联,平均值被冷启动污染。 loan.state(today)的返回值没有被使用,JIT 可能把整个调用删掉,测到的是空循环。- 循环里的常量
today和同一个loan可能被常量折叠,真实请求里不会这么理想。 - 没有独立进程,上一次测试的 JIT 状态、GC 状态、CPU 频率都会影响下一次。
JMH 不是「更复杂的计时器」,它把这些变量逐个控制住:预热轮次、测量轮次、独立 fork、Blackhole 消费返回值、@State 管理输入。代价是要写一个 Maven 模块,并且理解每个注解的含义。
核心机制一:JMH 工程与 JDK 25 的注解处理
libraryd-benchmarks 是一个独立 Maven 模块,依赖只有两个:
<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>
jmh-core 是运行期框架,jmh-generator-annprocess 是编译期注解处理器,负责为每个 @Benchmark 生成 META-INF/BenchmarkList 和桥接类。没有后者,java -jar benchmarks.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)
at org.openjdk.jmh.runner.Runner.internalRun(Runner.java:252)
at org.openjdk.jmh.runner.Runner.run(Runner.java:208)
at org.openjdk.jmh.Main.main(Main.java:71)
这个错误在 JDK 25 上比旧版本更容易出现。JDK 23 起,javac 不再默认扫描 classpath 上的注解处理器;只把 jmh-generator-annprocess 放进依赖,编译器不会自动运行它。需要在 maven-compiler-plugin 里显式声明:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<proc>full</proc>
<annotationProcessorPaths>
<path>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.37</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<proc>full</proc> 打开完整的注解处理,annotationProcessorPaths 把处理器放到独立的 processor path,不污染普通依赖。两者都写更稳:前者保证编译期运行,后者保证找得到处理器。
打包用 Shade 生成可执行 jar,Main-Class 指向 org.openjdk.jmh.Main。本机构建后产物是 2.7 MB:
-rw-r--r-- 1 user staff 2.7M target/benchmarks.jar
这个 jar 只用于跑基准,不随 libraryd 镜像发布。第 9 章的模块表里已经把 libraryd-benchmarks 标成「不发布」。
核心机制二:一个可用的基准类长什么样
FinePolicyBenchmark 比较四种写法:模式匹配 switch、instanceof 链、批量循环、stream。完整代码如下:
package com.plume.library.benchmarks;
import com.plume.library.domain.Loan;
import com.plume.library.domain.LoanState;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
import java.time.LocalDate;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(1)
@State(Scope.Thread)
public class FinePolicyBenchmark {
private LocalDate today;
private Loan[] loans;
@Setup
public void setUp() {
today = LocalDate.of(2026, 11, 10);
loans = new Loan[1_000];
for (int i = 0; i < loans.length; i++) {
LocalDate dueOn = today.minusDays(i % 40).plusDays(7);
loans[i] = new Loan(i, "978-0-13-468599-" + (i % 10), "m-" + i,
dueOn.minusDays(Loan.LOAN_DAYS), dueOn, null, 0);
}
}
@Benchmark
public long switchPattern() {
LoanState state = loans[0].state(today);
return switch (state) {
case LoanState.Active active -> 0L;
case LoanState.Overdue overdue -> overdue.days() * Loan.FINE_CENTS_PER_DAY;
case LoanState.Returned returned -> returned.fineCents();
};
}
@Benchmark
public long ifChain() {
LoanState state = loans[0].state(today);
if (state instanceof LoanState.Overdue overdue) {
return overdue.days() * Loan.FINE_CENTS_PER_DAY;
}
if (state instanceof LoanState.Returned returned) {
return returned.fineCents();
}
return 0L;
}
@Benchmark
public long batchLoop() {
long total = 0;
for (Loan loan : loans) {
total += fineOf(loan);
}
return total;
}
@Benchmark
public long streamPerLoan() {
return java.util.Arrays.stream(loans)
.mapToLong(FinePolicyBenchmark::fineOf)
.sum();
}
private static long fineOf(Loan loan) {
return switch (loan.state(LocalDate.of(2026, 11, 10))) {
case LoanState.Active active -> 0L;
case LoanState.Overdue overdue -> overdue.days() * Loan.FINE_CENTS_PER_DAY;
case LoanState.Returned returned -> returned.fineCents();
};
}
}
被测量的 Loan.state 是 libraryd-domain 里的真实方法:
// com.plume.library.domain.Loan 的实例方法
public LoanState state(LocalDate today) {
if (returnedOn != null) {
long overdue = Math.max(0, ChronoUnit.DAYS.between(dueOn, returnedOn));
return new LoanState.Returned(returnedOn, overdue * FINE_CENTS_PER_DAY);
}
if (dueOn.isBefore(today)) {
long days = ChronoUnit.DAYS.between(dueOn, today);
return new LoanState.Overdue(dueOn, days);
}
return new LoanState.Active(dueOn);
}
这个方法每次调用都可能 new 一个 LoanState 子类型。JMH 的紧循环里,JIT 可能把这些对象标量替换掉;真实批处理里不一定。这个差异在 9.3 会用 JFR 展开。
注解逐个解释:
@BenchmarkMode(Mode.AverageTime)测每次操作的平均耗时,单位由@OutputTimeUnit指定。吞吐量场景用Mode.Throughput,延迟分布用Mode.SampleTime。@Warmup(iterations = 3, time = 1)先跑 3 轮、每轮 1 秒,让 JIT 完成编译和 profiling。预热轮次的结果不计入最终分数。@Measurement(iterations = 5, time = 1)再跑 5 轮、每轮 1 秒,JMH 对每轮取平均,再给出置信区间。@Fork(1)在一个独立 JVM 里跑。每个 fork 都是干净进程,避免前一个 benchmark 的 JIT 状态污染。Fork(1)是最低配置;要做发布决策,至少 3 个 fork,看分数是否稳定。@State(Scope.Thread)表示每个测试线程一份状态。loans数组在@Setup里初始化,不进入测量循环。@Benchmark方法的返回值会被 JMH 消费。不要返回void又指望 JIT 不删代码;如果逻辑没有返回值,用Blackhole.consume。
batchLoop 和 streamPerLoan 的「一次操作」是「处理 1000 个 loan」,不是「处理 1 个 loan」。表里的 8525 ns/op 要除以 1000,才是每个 loan 约 8.5 ns。比较不同 benchmark 时,先确认 op 的粒度一致。
核心机制三:真跑一次,读结果表
构建并运行:
mvn -q -DskipTests package
java -jar target/benchmarks.jar FinePolicyBenchmark \
-wi 3 -i 5 -f 1 -w 1s -r 1s
命令行参数与注解重复,但显式写出来便于复现。本机运行约 34 秒,结果表:
Benchmark Mode Cnt Score Error Units
FinePolicyBenchmark.batchLoop avgt 5 8525.297 ± 1129.897 ns/op
FinePolicyBenchmark.ifChain avgt 5 1.979 ± 0.208 ns/op
FinePolicyBenchmark.streamPerLoan avgt 5 8760.297 ± 664.681 ns/op
FinePolicyBenchmark.switchPattern avgt 5 1.823 ± 0.067 ns/op
怎么读这张表:
Cnt=5是 5 轮测量,不是 5 次调用。每轮 1 秒,内部调用次数由 JMH 自动调整。Score是平均值,Error是 99.9% 置信区间半宽。switchPattern的区间是[1.756, 1.890],ifChain是[1.771, 2.187],两者重叠。不能因为1.823 < 1.979就宣布模式匹配更快;差异小于测量噪声。batchLoop是 8525 ns 处理 1000 个 loan,约 8.5 ns/loan;streamPerLoan是 8760 ns,约 8.76 ns/loan。两者差距 235 ns,相对于 1000 次调用的总量很小,且误差区间有重叠。stream 的 lambda 开销没有想象中那么大,JIT 会内联。switchPattern和ifChain都只有约 2 ns,说明Loan.state的分配很可能被逃逸分析消除了。这个结论在 9.3 会用 JFR 和-prof gc再验证,不能只看这一张表。
运行日志里还有一条 JDK 25 的告警:
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by org.openjdk.jmh.util.Utils (file:/private/tmp/vol3-c/jmh/target/benchmarks.jar)
WARNING: Please consider reporting this to the maintainers of class org.openjdk.jmh.util.Utils
WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
这是 JMH 1.37 内部使用 sun.misc.Unsafe 触发的,不影响基准结果,但说明 JMH 1.37 还没有完全适配 JDK 25 的弃用节奏。升级 JMH 时要留意发布说明,不要因为告警就否定结果。
核心机制四:JMH 结果不能直接外推到生产
JMH 的强项是「控制变量」,弱点是「场景太干净」。FinePolicyBenchmark 里只有一个 loan、一个固定的 today、一个紧凑的循环,JIT 可以做很多在真实服务里做不到的优化:
- 逃逸分析把
LoanState分配消除,真实请求里对象可能逃逸到响应体、日志或缓存。 - 常量折叠把
today当成常量,真实请求里LocalDate.now()每次不同,分支预测和缓存行为也不同。 - 单线程测量没有锁竞争、没有 GC 压力、没有数据库等待。
libraryd的借阅请求大部分时间花在 JDBC 和网络,不在state计算上。 Fork(1)的分数受 CPU 频率、后台进程、散热影响;同一台机器上跑两次也可能有 5% 到 10% 的波动。
所以 JMH 适合回答「A 和 B 在同等条件下谁更快、差多少」,不适合回答「生产环境能提升多少 QPS」。后者要用压测工具加真实依赖,或者用 JFR 看生产采样。第 9.2 节会看到,真实 12 秒负载里 LoanState.Overdue 分配了 272 MB,和 JMH 里「接近零分配」的结论并不矛盾,因为循环形态不同。
在 libraryd 里落地:基准模块的边界
libraryd-benchmarks 的职责很清楚:
| 做 | 不做 |
|---|---|
| 比较同一功能的不同实现 | 替代单元测试 |
| 验证优化前后的回归 | 作为 CI 必过门禁 |
| 给出可复现的机器口径 | 承诺生产 QPS |
| 暴露分配率、分支、锁的开销 | 测量数据库、网络、磁盘 |
CI 里可以跑一个「冒烟基准」:-wi 1 -i 1 -f 1 -w 100ms -r 100ms,只验证 benchmark 能启动、注解处理器正常、jar 能打包。真正的性能对比在本地或专用机器上跑,记录 CPU 型号、JDK 版本、Fork 数、Warmup/Measurement 配置。没有这些口径的数字,不要写进发布说明。
如果要比较两个版本,最稳的做法是:
- 同一台机器、同一个 JDK、同一个 fork 数。
- 两个版本的 benchmark 交替跑,避免机器状态漂移。
- 看置信区间是否重叠,重叠就说「没有显著差异」。
- 同时看
-prof gc的分配率,不要只看时间。
常见错误与边界
错误一:没有预热就下结论。 前几轮跑在解释器和 C1 上,分数可能比稳定状态高几倍。@Warmup(iterations = 3, time = 1) 是最低配置;对启动优化敏感的代码,预热轮次要更多。
错误二:返回值没有被消费。 @Benchmark 方法返回 void,循环里的计算结果没人用,JIT 可能把整个循环删掉,测到 0.1 ns/op。让方法返回累计值,或者用 Blackhole.consume。JMH 的 Blackhole 会阻止死代码消除,但不会阻止你写出一个没有实际工作的基准。
错误三:把 System.nanoTime 包在 @Benchmark 里。 JMH 已经负责计时,方法内部再计时会引入额外开销,还会把测量粒度搞乱。@Benchmark 方法只做被测工作,计时交给框架。
错误四:在 CI 共享 runner 上跑正式基准。 GitHub Actions 的 runner 有邻居噪声、CPU 配额和频率波动,分数不可复现。CI 只跑冒烟基准;正式数据在固定机器上采集,并写进报告。
错误五:只看平均时间,不看分配和尾部延迟。 平均 2 ns 的代码可能每次分配 32 字节,在高压下把 GC 打满。用 -prof gc 看 gc.alloc.rate.norm,用 Mode.SampleTime 看 p99,不要用一个平均数概括所有行为。
边界一:JMH 1.37 与 JDK 25 的兼容性。 本机可以构建和运行,但会看到 sun.misc.Unsafe 的弃用告警。JMH 的注解处理器在 JDK 23+ 需要显式 -proc:full,否则 BenchmarkList 缺失。升级 JDK 或 JMH 时,先跑一个最小 benchmark 验证工具链。
边界二:微基准的合法性。 如果被测方法依赖数据库、网络、文件系统,JMH 只能测「排除这些依赖后的 CPU 部分」。把 JDBC 调用放进 @Benchmark,测到的是网络和数据库的抖动,不是代码性能。这种场景应该用压测工具,不是 JMH。
小结
- 手写
System.nanoTime循环会被冷启动、死代码消除、常量折叠和 JIT 状态污染,不能作为性能结论。 - JMH 用
@Warmup、@Measurement、@Fork和Blackhole控制这些变量;@State把输入初始化移出测量循环。 - JDK 23+ 默认不扫描 classpath 注解处理器,
jmh-generator-annprocess要配合<proc>full</proc>和annotationProcessorPaths。 - 本机
FinePolicyBenchmark结果:switchPattern1.823 ns/op、ifChain1.979 ns/op,置信区间重叠,不能宣布谁更快。 batchLoop8525 ns 处理 1000 个 loan,约 8.5 ns/loan;streamPerLoan约 8.76 ns/loan,差距在误差范围内。- JMH 回答「同等条件下 A 与 B 的差异」,不回答「生产 QPS 提升多少」;后者要用真实依赖和 JFR。
有了「一次调用多快」的测量,下一步要回答「时间到底花在哪」。下一节用 JFR 记录真实负载,读 jdk.ExecutionSample 和 jdk.ObjectAllocationSample,把热点定位到具体方法。
阅读导航:上一节:8.3 原生互操作边界:稳定性、清理与替代方案 · 下一节:9.2 性能剖析:JFR、火焰图与热点定位 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。