《Java 高级编程》9.1 基准测试:JMH 的正确用法与常见陷阱

用 System.nanoTime 包一个循环,测出来的往往是 JIT 删除后的空操作。本节用 JMH 1.37 真跑 FinePolicyBenchmark,给出 Warmup、Measurement、Fork 配置和真实结果表,解释 JDK 25 下注解处理器默认关闭导致的 BenchmarkList 缺失,以及为什么 1.8 ns 的差异不能直接当成结论。

本节目标:把「这段代码快不快」从手感变成可复现的测量:用 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");

这段代码至少有四个问题:

  1. 前几万次调用跑在解释器和 C1 编译的代码上,JIT 还没把 state 内联,平均值被冷启动污染。
  2. loan.state(today) 的返回值没有被使用,JIT 可能把整个调用删掉,测到的是空循环。
  3. 循环里的常量 today 和同一个 loan 可能被常量折叠,真实请求里不会这么理想。
  4. 没有独立进程,上一次测试的 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 配置。没有这些口径的数字,不要写进发布说明。

如果要比较两个版本,最稳的做法是:

  1. 同一台机器、同一个 JDK、同一个 fork 数。
  2. 两个版本的 benchmark 交替跑,避免机器状态漂移。
  3. 看置信区间是否重叠,重叠就说「没有显著差异」。
  4. 同时看 -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 结果:switchPattern 1.823 ns/op、ifChain 1.979 ns/op,置信区间重叠,不能宣布谁更快。
  • batchLoop 8525 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、火焰图与热点定位 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发