什么是 JMH?为什么 Java 基准测试不能简单地用 System.currentTimeMillis 计时?
简化版
**JMH(Java Microbenchmark Harness)**是 OpenJDK 官方出的「Java 微基准测试框架」——专门用来准确测量一小段代码的性能(吞吐量/耗时)。为什么不能简单用 System.currentTimeMillis 计时:因为 JVM 有一堆「捣乱」的机制会让手写计时严重失真——① JIT 编译(代码跑几千次后才被编译成机器码,前面「预热」阶段慢、后面快,不预热测的是解释执行的慢速度);② 死代码消除(如果计算结果没被用,JIT 会直接把整段代码优化掉,测出来快得离谱);③ 常量折叠 / 循环展开(JIT 把能提前算的算掉);④ GC、类加载等干扰。JMH 帮你处理了这些坑:自动预热(warmup 让 JIT 充分编译再开始测)、用 Blackhole 消费结果(防死代码消除)、多次迭代取统计值(平均值、误差范围)、多分叉(fork)隔离进程干扰。所以测 Java 小代码性能,必须用 JMH,别手写计时。
详细版
手写计时为什么不准——一个典型的错误例子:
// ❌ 错误的基准测试
long start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
Math.log(i); // 结果没用 → JIT 直接把这行优化掉!测出来是 0ms
}
long end = System.currentTimeMillis();
System.out.println(end - start); // 测到的是"假的"性能
问题:① 没预热,前几千次是解释执行(慢),后面才 JIT 编译(快),混在一起测不准;② Math.log(i) 结果没用,JIT「死代码消除」把它删了,测出 0ms。
JMH 的正确写法:
@Benchmark
@BenchmarkMode(Mode.AverageTime) // 测平均耗时
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5) // 预热 5 轮(让 JIT 充分编译)
@Measurement(iterations = 10) // 正式测 10 轮
@Fork(2) // 开 2 个独立进程各测一遍
public void testLog(Blackhole bh) {
bh.consume(Math.log(42)); // 用 Blackhole 消费结果,防死代码消除
}
JMH 帮你处理的坑:
| 坑 | 手写会怎样 | JMH 怎么解决 |
|---|---|---|
| JIT 预热 | 混入解释执行的慢速度 | @Warmup 预热若干轮再正式测 |
| 死代码消除 | 结果没用被优化掉,测出 0 | Blackhole.consume 消费结果 |
| 常量折叠 | 常量入参被提前算掉 | @State 提供非常量输入 |
| 进程/环境干扰 | 单次运行偶然性大 | @Fork 多进程 + 多次迭代取统计 |
⚠️ 微基准测试是「陷阱密布」的领域——「感觉自己会写」的手写计时几乎一定是错的。核心原因是 JVM 是动态优化的:同一段代码在解释执行、C1 编译、C2 编译不同阶段速度差几十倍;JIT 还会做死代码消除、常量折叠、内联、循环展开等优化,你以为在测 A,实际 JVM 把 A 优化成了别的东西。JMH 是 JIT 编译器的作者们写的,专门对抗这些优化干扰,是 Java 微基准测试的事实标准——测小代码性能,用 JMH,不要自己写循环计时。
完整版教学
一、JMH 是什么:官方微基准框架
JMH 是 OpenJDK 团队出品的「Java 微基准测试框架」:
JMH = Java Microbenchmark Harness
- OpenJDK 官方项目(JIT 编译器的开发者们写的)
- 专门测"一小段代码"的性能(微基准 microbenchmark)
- 处理了 JVM 各种动态优化对测量的干扰
微基准 vs 宏基准:
微基准:测一个方法、一段代码(如"这个 HashMap 操作多快")
宏基准:测整个系统/接口(如"这个 API QPS 多少",用 JMeter/wrk)
JMH 专注微基准
JMH 的定位是「准确测量一小段代码的性能」——这看似简单,实则是最容易出错的领域(因为 JVM 的动态优化)。它由 JIT 编译器的作者们编写,最懂 JVM 会怎么优化代码、怎么对抗这些优化。所以「测 Java 小代码性能,JMH 是事实标准」。理解「JMH 是 OpenJDK 官方微基准框架、专测小代码性能、由 JIT 作者写最懂对抗优化干扰」,就理解了它的定位和权威性。
二、为什么手写计时不准:JIT 预热
手写计时最大的坑是「没有考虑 JIT 预热」:
JVM 执行代码的三个阶段(速度差几十倍):
① 解释执行:一开始逐条解释字节码,最慢
② C1 编译(Client):跑了一定次数,编译成机器码,较快
③ C2 编译(Server):跑了很多次(热点),深度优化,最快
一段代码跑 10000 次:
前几百次:解释执行(慢)
中间:C1 编译(中)
后面:C2 编译(快)
→ 如果不预热就计时,混入了慢速的解释执行阶段,测不准!
JMH 的解决:@Warmup 先"空跑"若干轮
→ 让代码被 JIT 充分编译(进入 C2 稳定状态)
→ 再开始 @Measurement 正式测量
JIT 预热是微基准的第一大坑——同一段代码在解释执行、C1、C2 阶段速度差几十倍,不预热就把慢速的解释执行混进去了。JMH 用 @Warmup 先「空跑」若干轮,让代码被 JIT 编译到稳定的 C2 状态,再正式测量。这样测的才是「代码热了之后的真实性能」(生产环境代码大多是热的)。理解「JVM 有解释/C1/C2 三阶段速度差几十倍、不预热混入解释执行不准、JMH 用 @Warmup 预热到 C2 再测」,就理解了预热的必要性。
三、为什么手写计时不准:死代码消除
第二大坑是「死代码消除」——JIT 会删掉「结果没被用」的代码:
❌ for (int i=0;i<N;i++) Math.log(i); // 结果没用
→ JIT 发现 Math.log(i) 的结果没人用、无副作用
→ 直接把整个循环删掉(死代码消除)
→ 测出来 0 纳秒!(你以为 Math.log 快,其实根本没执行)
JMH 的解决:Blackhole
bh.consume(Math.log(i));
→ Blackhole 是个"黑洞",假装用掉了结果
→ JIT 认为结果被使用了,不敢删代码
→ 真实地执行了 Math.log
死代码消除是最坑的——如果计算结果没被使用,JIT 认为这段代码「无意义」直接删掉,你测出 0ns 还以为代码快到飞起。JMH 用 Blackhole(黑洞)「消费」结果,让 JIT 以为结果有用,不敢删代码。所以基准测试里一定要用 Blackhole 消费结果,或从方法 return 结果(JMH 会自动 consume 返回值)。理解「结果没用会被 JIT 死代码消除测出 0、JMH 用 Blackhole.consume 消费结果防止被删」,就理解了第二大坑。
四、为什么手写计时不准:常量折叠
第三大坑是「常量折叠」——JIT 把能提前算的常量算掉:
❌ @Benchmark
public double test() {
return Math.log(42); // 42 是常量
}
→ JIT 发现入参是常量 42,Math.log(42) 结果也是常量
→ 直接在编译期算好,运行时返回常量(常量折叠)
→ 测出来是"返回一个常量"的速度,不是"计算 log"的速度
JMH 的解决:@State 提供"非常量"输入
@State(Scope.Thread)
public class MyState { int x = 42; } // 从 state 读,JIT 不能当常量
→ 入参来自 state 对象的字段,JIT 无法常量折叠
常量折叠是「输入是常量」时的坑——JIT 把常量表达式在编译期算好,你测的是「返回常量」而非「真实计算」。JMH 用 @State 把输入放到状态对象的字段里,JIT 无法把字段当常量折叠,从而保证真实计算。所以基准测试的输入要来自 @State,不要用字面常量。理解「常量入参会被 JIT 常量折叠测不准、JMH 用 @State 提供非常量输入」,就理解了第三大坑。
五、JMH 的核心注解和用法
把 JMH 的核心注解串起来,理解一个完整的基准测试:
@State(Scope.Benchmark) // 状态对象(提供非常量输入、共享数据)
public class MyBenchmark {
private int[] data = ...; // 测试数据放这(防常量折叠)
@Benchmark // 标记这是一个基准方法
@BenchmarkMode(Mode.Throughput) // 测吞吐量(每秒多少次)
@Warmup(iterations = 5, time = 1) // 预热 5 轮,每轮 1 秒
@Measurement(iterations = 10, time = 1) // 正式测 10 轮
@Fork(2) // 开 2 个独立 JVM 进程各测(隔离偶然性)
public void test(Blackhole bh) {
bh.consume(compute(data)); // Blackhole 消费结果
}
}
核心注解:
@Benchmark 标记基准方法
@BenchmarkMode 测量模式:Throughput(吞吐)/AverageTime(平均耗时)/...
@Warmup 预热配置(对抗 JIT 预热问题)
@Measurement 正式测量配置
@Fork 开几个独立进程(隔离进程间偶然性、JIT 决策差异)
@State 状态对象(提供非常量输入、管理测试数据)
Blackhole 消费结果(对抗死代码消除)
这些注解各自对抗一个坑:@Warmup 对抗 JIT 预热、Blackhole/return 对抗死代码消除、@State 对抗常量折叠、@Fork+多迭代对抗环境偶然性。JMH 输出还带统计信息(平均值、标准差、置信区间),而非单个数字——因为性能测量有波动,要看统计分布。理解「JMH 的核心注解(@Benchmark/@Warmup/@Measurement/@Fork/@State)+ Blackhole,各自对抗一个 JVM 优化坑,输出带统计信息」,就掌握了 JMH 的用法。
六、什么时候用 JMH,什么时候不用
理解 JMH 的适用边界,避免误用:
适合用 JMH(微基准):
- 对比两种算法/数据结构的性能(ArrayList vs LinkedList 遍历)
- 优化一个热点方法,验证优化效果
- 测一个库/API 调用的开销
→ "测一小段代码的性能"
不适合用 JMH(该用别的):
- 测整个接口的 QPS/RT → 用 JMeter、wrk、Gatling(压测工具)
- 测系统在高并发下的表现 → 压测 + 监控
- 找性能瓶颈在哪 → 用 profiler(async-profiler、JFR)先定位
误区:不要用 JMH 测"带 IO、网络、数据库"的代码
→ 那些开销远大于代码本身,JMH 的精度没意义,用宏观压测
JMH 是「显微镜」——适合精确测量一小段纯 CPU 计算代码。不适合测带 IO/网络/数据库的代码(外部开销远大于代码本身,微基准的纳秒级精度没意义)或整个系统的性能(那要用宏观压测工具 JMeter/wrk)。找瓶颈应先用 profiler(async-profiler/JFR)定位,再用 JMH 精测热点。理解「JMH 适合测小段纯计算代码、不适合测 IO/网络/系统级性能(那用压测工具/profiler)」,就掌握了 JMH 的适用边界。
记忆钩子:「JMH = OpenJDK 官方微基准框架(JIT 作者写的),测小代码性能;手写 System.currentTimeMillis 计时的坑:① JIT 预热(解释/C1/C2 差几十倍,不预热不准)② 死代码消除(结果没用被 JIT 删,测出 0)③ 常量折叠(常量入参被提前算)④ 环境偶然性;JMH 对抗:@Warmup 预热 + Blackhole 消费结果 + @State 非常量输入 + @Fork 多进程 + 输出统计值;只测小段纯计算,别测 IO/网络/系统(那用压测/profiler)」。
七、常见误区与追问
- 误区:用 System.currentTimeMillis 前后打点就能测代码性能。 几乎一定不准——没预热会混入解释执行的慢速度、结果没用会被 JIT 死代码消除测出 0、常量入参会被常量折叠;必须用 JMH 处理这些干扰。
- 误区:基准测试的计算结果不用管,只看时间。 大错——结果没被使用会触发 JIT 死代码消除(整段代码被删,测出 0ns);必须用 Blackhole.consume 或从方法 return 结果让 JIT 认为结果有用。
- 误区:测一次就能得到准确结果。 性能测量有波动(GC、CPU 调度、JIT 决策差异);要预热 + 多轮迭代 + 多次 fork,看统计值(平均值、标准差、置信区间),而非单次数字。
- 误区:JMH 能测任何代码的性能。 JMH 是微基准显微镜,适合测小段纯计算;测带 IO/网络/数据库的代码(外部开销远大于代码)或系统 QPS 应该用压测工具(JMeter/wrk),找瓶颈用 profiler。
- 追问:JMH 的 @Fork 是干嘛的? 开 N 个独立的 JVM 进程各跑一遍基准——因为同一个进程里 JIT 的编译决策(profile-guided)有随机性、可能被前面的测试「污染」,多进程能隔离这种偶然性,让结果更可靠。
- 追问:为什么要预热?生产代码也没「预热」啊? 生产环境的热点代码运行久了早就被 JIT 编译到 C2 稳定态了(相当于「一直在预热后的状态」);基准测试预热就是为了模拟这个「代码已经热」的稳定状态,测真实的稳态性能,而不是刚启动的冷启动性能。
- 追问:BenchmarkMode 有哪些模式? Throughput(吞吐量,每秒操作次数)、AverageTime(平均耗时)、SampleTime(采样耗时分布,看百分位)、SingleShotTime(单次执行时间,测冷启动/首次调用)、All(全部);根据关注吞吐还是延迟选。
八、加强记忆
JMH(Java Microbenchmark Harness)是 OpenJDK 官方的「Java 微基准测试框架」(JIT 编译器作者写的),专门准确测量一小段代码的性能。为什么不能手写 System.currentTimeMillis 计时——JVM 的动态优化会让手写计时严重失真:① JIT 预热(解释/C1/C2 三阶段速度差几十倍,不预热混入解释执行的慢速度);② 死代码消除(结果没被用,JIT 直接删掉整段代码,测出 0ns);③ 常量折叠(常量入参被 JIT 在编译期算掉);④ 环境/进程偶然性。JMH 逐一对抗:@Warmup 预热到 C2 稳态、Blackhole.consume 消费结果防死代码消除、@State 提供非常量输入防常量折叠、@Fork 多进程 + 多轮迭代隔离偶然性、输出统计值(平均值/标准差/置信区间)。核心注解:@Benchmark/@BenchmarkMode(Throughput/AverageTime)/@Warmup/@Measurement/@Fork/@State。适用边界:只测小段纯计算代码(算法/数据结构对比、热点方法优化验证);不测 IO/网络/数据库代码(外部开销盖过代码)或系统 QPS(用 JMeter/wrk 压测,用 profiler 找瓶颈)。一句话「测 Java 小代码性能必须用 JMH——手写计时会被 JIT 预热/死代码消除/常量折叠坑到失真,JMH 用 @Warmup + Blackhole + @State + @Fork 对抗这些优化,只测小段纯计算别测 IO/系统」。