为什么需要 JMH?手写 for 循环计时的坑
JMH(Java Microbenchmark Harness)是 OpenJDK 官方出品的 Java 微基准测试框架,专门用来精确测量一小段代码的性能。很多人习惯用 System.nanoTime() 包一层 for 循环就敢下结论,但 JVM 的 JIT 编译、方法内联、死代码消除、常量折叠会在你毫无察觉时把“测试结果”变成“幻觉”。要可信地量化 Java 代码性能差异,JMH 几乎是事实标准。
典型的手写基准有三个致命问题:一是没有预热(warmup),测到的全是解释执行或未优化版本;二是被测计算过程的结果没有被使用,JIT 直接把整段代码优化没了;三是循环变量被当成常量折叠。JMH 通过注解和独立的 Fork 进程把这些坑逐一堵上。很多团队上线前用 System.currentTimeMillis() 粗略对比两个实现,得出的“快 20%”往往只是测量噪声,真正上线后毫无区别,反而误导了技术选型。
快速开始:引入依赖与第一个 @Benchmark
在 Maven 项目里引入 jmh-core 与注解处理器 jmh-generator-annprocess。注意 scope 用 provided 即可,它只在编译期生成基准 runner。
<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>
写第一个基准类,用 @Benchmark 标注被测方法,main 里用 org.openjdk.jmh.runner.Runner 启动。JMH 会自动完成预热、测量、多 Fork。
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
public class StringConcatBenchmark {
@Benchmark
@OutputTimeUnit(TimeUnit.MICROSECONDS)
public String testStringBuilder() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10; i++) {
sb.append(i);
}
return sb.toString();
}
public static void main(String[] args) throws Exception {
org.openjdk.jmh.runner.Runner runner =
new org.openjdk.jmh.runner.Runner(new OptionsBuilder()
.include(StringConcatBenchmark.class.getSimpleName())
.forks(1)
.build());
runner.run();
}
}
核心注解一次说清
JMH 的能力几乎都靠注解声明。@State 定义基准的状态对象(数据从哪来),@Setup 在每次迭代或调用前准备数据,@BenchmarkMode 指定测量维度,@Warmup 与 @Measurement 控制迭代轮数。下面是一段带状态与准备逻辑的例子。
@State(Scope.Benchmark)
public class MapBenchmark {
private Map<Integer, Integer> map;
@Setup(Level.Trial)
public void setup() {
map = new HashMap<>();
for (int i = 0; i < 10000; i++) {
map.put(i, i);
}
}
@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public Integer testGet() {
return map.get(5000);
}
}
@State 的 Scope 有三个级别:Benchmark 所有线程共享一个实例,Thread 每线程一个,Group 同一线程组内共享。并发基准里选错 Scope,测出来的其实是“共享变量竞争”而不是你想要的“单操作耗时”,这是最常见的误读来源之一。
四大基准模式与输出时间单位
@BenchmarkMode 决定 JMH 报告什么指标。不同模式适合不同的性能问题:吞吐看“单位时间能做多少”,平均时间看“单次多慢”,采样时间能看到分布长尾,单次模式适合测冷启动。
| 模式 | 含义 | 适用场景 |
|---|---|---|
| Throughput | 单位时间内的操作次数(ops/s) | 比较两种实现谁更快 |
| AverageTime | 单次调用的平均耗时 | 看典型延迟 |
| SampleTime | 采样每次调用耗时,给出分位数 | 排查长尾毛刺(P99/P999) |
| SingleShotTime | 只跑一次,测冷启动/单次开销 | 框架初始化、对象创建 |
结合 @OutputTimeUnit 指定单位(纳秒/微秒/毫秒/秒),再配合 @Warmup(iterations=3, time=1) 与 @Measurement(iterations=5, time=1),一个可信的基准就成型了。想了解 JVM 怎么为这些代码做运行时优化,可以参考我们讲 Java 21 虚拟线程 时提到的运行时模型。
避坑一:死代码消除与 Blackhole
如果你的基准方法返回一个值但调用方(JMH 框架)没有使用它,JIT 可能判定“这个结果没人要”而把整个计算删掉。正确做法是把返回值交给 Blackhole.consume(),让 JIT 无法证明它能消除。
@Benchmark
public int testComputeWrong() {
return heavyCompute(1024); // 危险:结果未使用,可能被优化掉
}
@Benchmark
public void testComputeRight(Blackhole bh) {
bh.consume(heavyCompute(1024)); // 安全:强制保留计算
}
避坑二:常量折叠与循环展开
把循环上界写成字面量常量(如 for (int i = 0; i < 1024; i++)),JIT 可能把循环完全展开成顺序指令,甚至把可静态求值的部分直接算成常量——这根本不是你线上“输入不确定”的真实路径。务必用 @State 里的非 final 字段或 @Param 把输入变成运行时值。
@State(Scope.Benchmark)
public class LoopBenchmark {
@Param({"16", "256", "1024"})
public int size; // 运行时注入,避免常量折叠
private int[] data;
@Setup
public void setup() {
data = new int[size];
for (int i = 0; i < size; i++) data[i] = i;
}
@Benchmark
public long testSum(Blackhole bh) {
long sum = 0;
for (int v : data) sum += v;
bh.consume(sum);
return sum;
}
}
用 @Param 做参数化基准
@Param 让同一方法在不同参数组合下自动跑多组基准,非常适合对比集合初始容量、线程数、数据规模对性能的影响。配合 @CompilerControl 还能控制方法是否内联,定位“内联导致的性能差异”。
@State(Scope.Benchmark)
public class CollBenchmark {
@Param({"16", "128", "1024"})
public int capacity;
@Benchmark
public Map<Integer, Integer> testHashMap() {
Map<Integer, Integer> m = new HashMap<>(capacity);
for (int i = 0; i < capacity; i++) m.put(i, i);
return m;
}
}
运行方式与结果解读
打成可执行的 benchmark jar 后用 java -jar 运行最干净(独立进程避免类加载干扰)。输出里重点关注 Score(指标值)、Error(置信区间)和单位。组内多行代表不同参数组合,CI 误差带越小结果越可信。
# 打包(maven-shade-plugin 把 jmh 依赖打进 fat jar)
mvn clean package
java -jar target/benchmarks.jar -wi 3 -i 5 -f 1
# 只跑某个类、输出 JSON 便于做回归对比
java -jar target/benchmarks.jar MyBenchmark -rf json
结果解读铁律:只比较“误差带不重叠”的差异。JMH 输出里两个 Score 的 Error 区间一旦重合,就说明这次测不出显著差别,别硬下“快 3%”的结论。把基准跑在 GitHub Actions 上做性能回归门禁,比在本机随手跑更有价值。
与 Spring Boot 项目集成
把 JMH 放进独立的测试/基准模块,用 test scope 的 JMH 依赖,避免污染生产 classpath。可以在 @Setup 里用 Spring 的 ApplicationContext 取出 Bean 再测其真实调用耗时,注意 Benchmark 类本身不要被 Spring 托管,否则 Fork 进程会重复启动容器拖慢测试。
@State(Scope.Benchmark)
public class RepoBenchmark {
private MyRepository repo;
@Setup(Level.Trial)
public void setup() {
AnnotationConfigApplicationContext ctx =
new AnnotationConfigApplicationContext(AppConfig.class);
repo = ctx.getBean(MyRepository.class);
}
@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public List<Order> testQuery() {
return repo.findRecentOrders(100);
}
}
若项目是 Spring Boot 3 技术栈,注意 Jakarta 命名空间与 Java 17+ 基线,JMH 1.36+ 完全兼容,无需额外配置。
JMH 与手写计时器的本质区别
同样是“跑 N 次取平均”,为什么 JMH 的结论可信而手写 for 循环不可信?核心差异在于 JMH 在 JVM 层面做了控制:它在独立进程里预热、按统一规则迭代、用 Blackhole 锁住结果,还把 GC、类加载、JIT 编译阶段对测量的污染降到最低。手写计时把这些因素全部混在一起,平均值根本分不清“冷启动慢”还是“真实代码慢”。
| 维度 | 手写 System.nanoTime | JMH |
|---|---|---|
| 预热 | 无,首轮最慢 | @Warmup 多轮,JIT 稳定后才测 |
| 死代码 | 结果未使用易被删 | Blackhole 强制保留 |
| 常量折叠 | 循环上界写死易触发 | @Param 注入运行时值 |
| 进程隔离 | 与业务同进程,类加载干扰 | 独立 Fork 进程 |
| 误差 | 无置信区间 | 输出 Score ± Error |
一句话:手写计时适合“大概感受一下有没有数量级差异”,而任何要写进方案评审、要据此替换核心实现的结论,都必须用 JMH 这类受控基准来支撑。
统计显著性与两个常见误区
误区一:在 @Benchmark 里调用 System.gc() 想“清干净再测”。这恰恰会引入一次不可控的全堆回收,让你的分数剧烈抖动,JMH 官方明确建议不要在基准里手动 GC。误区二:只看 Score 数字大小就宣布胜负。正确做法是看两个参数组合的 Error(95% 置信区间)是否重叠——重叠就说明差异不在测量能力之内,应判定为“无显著差异”。把基准固定在同样的机器规格、同样的 JVM 版本、同样的 -Xmx 上跑,才能保证跨次结果可比较。
生产落地检查清单
| 检查项 | 为什么 |
|---|---|
| 结果必须交给 Blackhole 或返回 | 防止死代码消除 |
| 输入用 @Param / 非 final 字段 | 避免常量折叠与循环展开 |
| 至少 3 次 warmup + 5 次 measurement | 等 JIT 稳定后再测量 |
| forks ≥ 1,最好独立 jar 运行 | 隔离类加载与 GC 干扰 |
| 只看误差带不重叠的差异 | 避免把噪声当结论 |
| 固定 JVM 参数(-Xms=-Xmx、关闭 Tiered) | 保证可比性 |
把这份清单当成发布前的性能 PR 检查项,配合 CI 跑 JSON 结果做前后版本 diff,就能让“这段代码更快”从拍脑袋变成有数据支撑的结论。关于 JVM 运行时的其它细节,我们也写过 Java 模式匹配 与 Java 21 虚拟线程,可以和本文对照着读。




