跳到主要内容
约 9 分钟3 张卡

JMH

JMH 是用于构建 Java 微基准的工具,适合测量一小段代码的 CPU 和内存成本。本文先介绍测量模式,再解释怎样设计输入、保留计算结果,以及安排预热和独立进程测试。最后说明如何分析结果,并用业务负载验证收益。

1. 面试核心​

先记住这几条,再读正文
  • 测量控制。 JMH 用预热稳定类加载与编译,用独立 fork 隔离进程状态,并通过结果消费减少无用计算被消除的干扰。卡 · 测量模式
  • 结果消费。 基准方法返回结果或交给 Blackhole 消费,可以防止 JIT 因结果无人使用而消除被测计算。
  • 可变输入。 可变输入放在 @State 中,可降低常量折叠对测试的干扰。
  • 状态作用域。 Scope.Thread 为每个测试线程提供独立状态,Scope.Benchmark 则让测试线程共享状态。
  • 进程隔离。 每个 fork 启动新的 JVM,隔离前次测试的画像和静态状态。卡 · 预热与 fork

2. 测量模式​

测量前要明确想比较的是吞吐量、平均耗时,还是耗时分布。JMH 提供以下四种模式,每种模式回答的问题不同:

模式测量对象
Throughput单位时间操作数
AverageTime每次操作平均耗时
SampleTime采样得到的耗时分布
SingleShotTime一次方法调用或指定批次的耗时
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class ParserBenchmark {
// 基准方法使用 @Benchmark 标记。
}

这个配置按纳秒报告每次操作的平均耗时。纳秒只是输出单位,实际测量精度仍受计时器和样本量影响。

判断结果是否可靠,需要同时看统计结果与实验环境:

  • 保留每轮迭代和各个 fork 的结果,观察波动是否稳定。
  • 记录误差与置信区间,判断样本是否足以支持差异。
  • 记录 JDK、启动参数和硬件信息,让实验能够复现。

fork 指启动一个独立 JVM 运行基准,后文会解释它的隔离作用。

3. 死代码消除​

如果计算结果无人使用,JIT 可以删除没有可观察副作用的计算。这种优化称为死代码消除,它会让基准测到的工作量小于预期。下面把错误写法与返回结果的写法放在一起比较:

@Benchmark
public void wrong() {
Math.sqrt(123.0);
}

@State(Scope.Thread)
public static class Input {
public double value = 123.0;
}

@Benchmark
public double sqrt(Input input) {
return Math.sqrt(input.value);
}

wrong() 丢弃了计算结果,JIT 可以省去这次计算。sqrt() 则从状态对象读取输入并返回结果,JMH 会消费返回值,让计算结果保持可观察。

如果有多个结果,可以分别交给 Blackhole.consume()。Blackhole 是 JMH 提供的结果消费工具,用于让 JIT 保留产生这些结果的计算。有可观察副作用的方法即使无返回值,也会保留相应工作。

测纯计算时,应把日志、断言和 I/O 放在测量路径之外。它们的耗时会被一起计入,甚至超过被测代码本身。

4. 常量折叠与 State​

返回结果解决了结果无人使用的问题,还要防止答案被提前算出。如果把固定常量直接写进基准,JIT 可以在编译时完成计算,这称为常量折叠。

@State 标记基准使用的状态对象,用字段提供运行时输入。下面通过参数化,让同一基准比较不同输入规模:

@State(Scope.Thread)
public static class Input {
@Param({"16", "256", "4096"})
public int size;
public byte[] data;

@Setup(Level.Trial)
public void setup() {
data = new byte[size];
}
}

@Param 会分别使用 16、256、4096 作为 size,并让结果标明输入规模。@Setup 在指定时机准备数组,基准方法再读取状态对象中的数据。如果这些数据仍能被静态推导,JIT 仍然能够简化计算,所以状态设计也要对应真实输入。

准备工作的执行频率由 Setup 层级决定:

Setup 层级执行时机
Trial每次 trial 开始前,对各状态实例执行
Iteration每轮预热和测量迭代前
Invocation每次基准方法调用前

Trial 适合在一轮完整实验前准备可复用数据,Iteration 用于每轮迭代前重置状态。Invocation 则需要为每次调用单独安排准备工作和计时,会增加开销,并干扰流水线。它适合单次执行时间足够长的基准,使用后仍需验证测量干扰。

4.1 可变输入的重置​

会修改输入的基准,需要保证每次调用都遇到目标状态。比如,排序会直接改变数组顺序。如果只在 Trial 开始时创建随机数组,第一次调用后数组已经有序,后续测到的就是有序输入的排序成本。

一种做法是在每次调用中复制原数组,再排序副本。这时复制成本也被计入,报告需要写清测量的是“复制加排序”。如果用 Invocation 在计时前重置,还要检查逐次计时和缓存干扰。准备工作虽然移出了计时区,却仍会改变 CPU 缓存中的数据。

5. 预热与 fork​

刚启动的 JVM 还在加载类和编译热点代码,直接统计会混入这些启动成本。预热让这些活动逐步稳定,预热轮次的结果会被排除在最终统计之外。

下面是一组预热、测量和独立进程配置:

@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 8, time = 1)
@Fork(3)

该配置启动 3 个 JVM,每个 JVM 执行 5 轮预热和 8 轮测量,每轮设置为 1 秒。实际需要多少轮预热,要根据结果何时稳定来调整。

每个 fork 都从新的 JVM 开始,因此能隔离之前基准留下的 JIT 画像和静态状态。保存机器码的 Code Cache 也会重新建立,避免结果依赖测试顺序。多个进程样本还能显示进程之间的波动。

如果目标是首次调用耗时,应选择零预热或明确指定预热范围。SingleShotTime 测的是一次基准调用或批次,未包含 JVM 自身启动。需要测完整进程冷启动时,应在外部计时,让进程启动和业务就绪都处于测量范围内。

6. 基准方法中的循环​

为了减少计时开销而手写大循环,会改变被测程序。例如,在一次基准调用中执行 operation(i) 一百万次,再把总耗时除以一百万,得到的是批处理中的平均成本。

循环让 JIT 有机会展开循环、合并计算,或将多个元素的运算改成向量指令。方法内联、常量传播和 CPU 分支预测的效果也会变化。因此,这个均值不能直接代表独立调用一次操作的成本。

JMH 本身就会重复调用基准方法,纯粹为了凑次数的循环可以省去。业务确实按批处理时,则保留业务中的批量大小。@OperationsPerInvocation 用于按逻辑操作数换算结果,它只改变统计单位,循环优化仍然存在。

7. State 的 Scope​

并发测试首先要确定线程是否操作同一个对象。@State 的 Scope 决定状态由哪些工作线程共享:

  • Scope.Thread 为每个工作线程创建独立状态。
  • Scope.Benchmark 让参与测试的线程共享状态。
  • Scope.Group 让同一个线程组内的线程共享状态。

测共享容器的竞争时,应采用 Benchmark 或 Group。如果误用 Thread,每个线程操作的都是自己的容器,测试中就没有目标竞争。反过来,测纯算法时若误用共享可变状态,也会把锁竞争或伪共享混入结果。伪共享指不同线程修改同一缓存行内的不同数据,却引发缓存同步开销。

线程数也应显式配置,例如 @Threads(4)。共享状态只确定对象归属,并发程度仍由实际测试线程数决定。

8. Profiler 与测量证据​

均值变小只能说明观察到了差异,还需要解释差异来自哪里。Profiler 是采集 CPU、内存分配等运行信息的分析工具,JMH 可以在测量时启用它:

java -jar target/benchmarks.jar ParserBenchmark -prof gc

-prof gc 输出分配与 GC 指标,其中 gc.alloc.rate.norm 表示每次操作分配的字节数。环境安装了 async-profiler 等工具时,也可以选择 -prof async 等分析方式。可用的 profiler 取决于安装环境。

解释差异时,至少要确认以下四项证据:

  • CPU 时间主要花在哪些方法上。
  • 每次操作分配了多少字节。
  • 是否发生预期之外的锁竞争或 GC。
  • 编译结果是否按预期完成内联和向量化。

随后还要比较误差与置信区间。区间重叠时,需要更多证据判断差异是否稳定。CPU 降频、后台任务和容器 CPU 限流也会改变结果,因此要同时记录这些环境因素。

9. 微基准与系统压测​

微基准适合比较解析器、集合操作和序列化等小块 CPU 与内存代码。真实服务还需要考虑以下系统成本:

  • 网络与数据库访问会带来等待。
  • 线程池和请求排队会改变延迟分布。
  • 缓存命中率会改变实际执行的路径。

因此,局部优化要先确认输入分布有代表性,并估算热点在总耗时中的占比。JMH 验证每次操作成本确实降低后,再用服务负载检查吞吐量、长尾延迟和资源占用。长尾延迟指少数较慢请求的耗时。

只有业务指标也改善,才能说明局部收益传递到了服务层面。还要检查成本是否转移到了其他资源,例如 CPU 减少后内存占用却明显增加。局部代码原本占用的时间,也限制了整体收益的上限。

10. 常见问题​

10.1 手写计时循环的干扰​

IDE 中手写循环计时会受到执行环境与编译优化的干扰:

  • IDE 注入的 Agent 和共享 JVM 状态会影响运行。
  • 预热不足会混入类加载和 JIT 编译成本。
  • 死代码消除、常量折叠和循环优化会改变被测工作。
  • 计时器开销会影响耗时很短的操作。

JMH 会生成调用基准的测试框架,并提供 fork、迭代和统计支持。输入状态与实际工作量仍需要按实验目标设计。

10.2 结果消费方式​

10.3 微基准结果的应用范围​

11 · 面试题卡片​

11.1 怎样设计一个可信的 Java 微基准​

出现公司:腾讯云、京东数科

考察重点:说明如何确定测量边界、设计输入状态,并控制预热、结果消除和编译画像的干扰。

相关内容:测量模式、常量折叠与 State。

参考回答

可信的微基准要让被测工作对应真实问题,并控制输入状态、预热和编译优化的干扰。

测量目标应明确为吞吐量、稳态耗时或首次调用耗时,再据此配置预热、测量轮次与多个 fork。

输入用合适 Scope 的 State 提供,并通过参数覆盖真实规模。结果返回或交给 Blackhole,避免无用计算被消除。会被修改的输入要在每次操作前恢复目标状态,基准中也应省去没有业务含义的大循环。

结果需要结合误差和 profiler,确认 CPU、分配与编译行为符合预期。最后通过系统压测验证业务长尾延迟和资源成本,判断局部收益是否有效。

11.2 为什么微基准需要 fork 新的 JVM​

出现公司:京东数科

考察重点:解释独立进程如何隔离编译画像、代码缓存和静态状态,以及哪些环境噪声仍会保留。

相关内容:预热与 fork。

参考回答

fork 用新的 JVM 隔离此前基准留下的编译画像、Code Cache 和静态状态。

同一进程中的测试还会共享类初始化结果,所以后续基准的表现会依赖执行顺序。多个 fork 在独立进程中重复实验,既隔离状态,也提供进程级样本。

进程隔离后,CPU 频率、后台任务和 NUMA 内存访问差异仍会影响结果。NUMA 指多处理器访问不同内存区域时延迟不同的硬件结构。因此,还需要控制环境,并记录 JDK 参数和机器信息。

11.3 排序基准只在 Trial 生成随机数组,会测到什么?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释排序如何改变后续输入,以及复制或重置数组会怎样影响测量成本。

相关内容:可变输入的重置。

参考回答

首次调用排序随机数组,后续调用处理的则是已经排好序的数组。

因为 Trial 只在实验开始前准备数据,排序对数组的修改会保留到下一次调用。

如果目标是随机输入排序,就需要每次恢复随机数据。在基准内复制后排序,会把复制成本一并计入;用 Invocation 重置,则要验证逐次计时和缓存变化带来的干扰。

12. 参考资料​