Java 的 JMH 基准测试?
介绍用JMH进行Java微基准测试的搭建与运行方法,指出System.nanoTime()循环会因死代码消除、常量折叠和预热而失真,并建议按场景选择AverageTime、Throughput或SampleTime模式。
想知道一个方法到底有多快,答案不是 System.nanoTime() 包个循环,而是用 JMH(Java Microbenchmark Harness,OpenJDK 官方维护的 Java 微基准测试框架)。具体做法:引入 org.openjdk.jmh:jmh-core:1.37 和注解处理器 jmh-generator-annprocess:1.37,用 @Benchmark 标注方法,通过 maven-shade-plugin 打出 benchmarks.jar,再用 java -jar target/benchmarks.jar -wi 5 -i 5 -f 2 运行。手写循环测出来的纳秒级数字,九成是错的。
为什么不能用 System.nanoTime() 循环测性能?
结论:因为 JIT 会把你辛辛苦苦写的循环优化掉大半,测出来的不是代码的真实成本。
手写计时的三个致命问题:一是死代码消除,方法返回的值没人用,JIT 直接删掉整段逻辑;二是常量折叠,Math.sqrt(16) 这种输入在编译期就算完了,你测的是一个常数;三是预热问题,同一个方法在前 1 万次调用是解释执行,之后才 C2 编译,两者可能差 10 倍以上。JMH 的解法分别是 Blackhole.consume()、从 @State 字段读入参(阻止编译期常量传播)、@Warmup 预跑 5 轮各 1 秒。
JMH 怎么搭起来?最小可跑的工程长什么样
结论:一个 pom 加一个类就能跑,两处依赖都不能省。
<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>
基准类:
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
@State(Scope.Thread)
public class StringBench {
private String a = "hello", b = "world";
@Benchmark
public String concat() { return a + b; }
}
关键点:jmh-generator-annprocess 是注解处理器,它负责在编译期生成 benchmark 的驱动代码,不配这个依赖能编译但 java -jar benchmarks.jar 会报找不到 benchmark。打包必须用 maven-shade-plugin 并指定 Main-Class: org.openjdk.jmh.Main,普通 maven-jar-plugin 打出的包缺依赖跑不起来。
@BenchmarkMode 有哪几种,该怎么选?
结论:默认场景用 AverageTime,关心吞吐用 Throughput,排查毛刺用 SampleTime。
Throughput:单位时间完成多少次操作,适合压队列、缓存、并发结构。AverageTime:每次操作平均耗时,最常用,但平均值会掩盖长尾。SampleTime:采样并输出 P50/P90/P99/P99.9 分布,定位抖动和 GC 毛刺必用。SingleShotTime:只跑一次,配合@Warmup(iterations = 0)测冷启动性能。
同一个 @Benchmark 可以叠加多个 Mode,JMH 会分别跑。注意默认值会随版本微调,工程里显式写全 @Warmup、@Measurement、@Fork 比依赖默认值稳妥。
@State 和 Blackhole 是什么,少了会怎样?
结论:@State 定义基准方法共享或独享的数据载体,Blackhole 防止返回值被优化掉,两者是 JMH 正确性的地基。
@State 的 Scope 有三种:Thread 每个线程一份实例,测无竞争场景;Benchmark 所有线程共享一份,测竞争场景必须用它;Group 用于多个 @Benchmark 共享同一份数据。配合 @Setup(Level.Trial/Iteration/Invocation) 做初始化,@TearDown 做清理。
无返回值的方法要显式消费:
@Benchmark
public void parse(Blackhole bh) {
bh.consume(Integer.parseInt("12345"));
}
有返回值时直接 return 也可以,JMH 会帮你消费。另外 @Param({"10", "1000"}) 可以在一次运行里扫描多个输入规模,这比手改代码重跑靠谱。
运行参数怎么调才不白跑?
结论:正式测量至少 -f 2(两个独立 JVM 进程)、-wi 5 -i 5,看内存分配加 -prof gc。
常用参数:-wi 预热迭代数、-i 测量迭代数、-f fork 的 JVM 数、-t 线程数、-w/-r 每次迭代秒数、-prof gc 输出 gc.alloc.rate.norm(每次操作分配字节数)、-rf json -rff result.json 存结构化结果便于比对。
读结果看两件事:Score ± Error 里的误差是 99.9% 置信区间,误差占比超过 10% 说明数据不稳,优先加 -f;gc.alloc.rate.norm 能直接告诉你这个方法每次调用分配多少字节,是发现"性能没问题但 GC 压力大"的关键指标。-f 0 只在调试时用,同一进程里跑多个基准会互相污染 profile。
最容易踩的坑有哪些?
结论:90% 的"JMH 数据不准"来自基准方法本身写错了。
- 在
@Benchmark里写System.out.println或日志,IO 时间直接压过被测逻辑; - 入参写成方法内常量,被常量折叠,测出 0.3 ns 的假数据;
- 用
Scope.Benchmark测一个本该无竞争的纯计算,线程争抢把数据拉偏; - 单次操作在 1 ns 以下时,别用 AverageTime 纠结小数位,改用 Throughput 看每秒操作数;
- 忘了开
-prof gc,漏掉隐式装箱、字符串拼接产生的分配。
一句话收束:JMH 帮你排掉的是 JIT 优化和测量误差,剩下要你自己想清楚的是"这个微基准到底代表不代表线上场景"——微基准测的是方法,不是系统。参数固定成 @Warmup(5×1s) + @Measurement(5×1s) + @Fork(2),跑完先看误差条再看分数,这套流程能挡掉绝大部分误判。
转载请注明出处,版权归原作者所有。