← 返回题目列表

什么是 JMH?为什么 Java 基准测试不能简单地用 System.currentTimeMillis 计时?

中等 第 18 / 34 题 更新于 2026/07/28
JMH基准测试微基准JIT

简化版

**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 预热若干轮再正式测
死代码消除结果没用被优化掉,测出 0Blackhole.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/系统」。