【JAVA】JMH 解析:Java 微基准测试
第一部分:为什么需要 JMH?
在日常开发中,我们经常凭直觉判断代码性能:
- for循环 vs. Stream 流,哪个更快?
- 反射调用 vs. 直接调用,差多少?
- ArrayList还是LinkedList更适合我的场景?
- 对象复制是BeanUtil.copy快还是MapStruct对象转换快?
- List<Object>去重哪种方式性能最佳?
- 代码后的代码是否性能提升?
我们可能会写一个简单的System.currentTimeMillis()测试,但结果往往是错的,我们需要数据说话。
因为JVM 太聪明了,它会做很多优化:
- JIT 即时编译:热点代码被编译成机器码,并可能内联(inlining)。
- 死码消除:如果计算结果没有被使用,JVM 直接丢弃计算。
- GC 干扰:垃圾回收随时可能发生。
为了科学、精准、可重复地测量 Java 代码性能,我们需要一把“度量衡”——JMH。
第二部分:JMH 是什么?
- 全称:Java Microbenchmark Harness(Java 微基准测试套件)
- 作者:OpenJDK 团队(就是开发 JVM 的那群大神)
- 定位:专注于方法级别的性能测试,精度可达纳秒(ns)级
一句话:JMH 是用来测一小段 Java 代码跑得有多快的工具。
第三部分:JMH 核心注解详解(含冲突与注意事项)
下面以表格形式列出最常用的注解,并特别说明哪些组合会产生冲突或需要谨慎搭配。
| 注解 | 作用 | 关键参数 / 可选值 | 默认值 | 冲突与注意事项 |
|---|---|---|---|---|
| @Benchmark | 标记一个方法为基准测试方法 | 无 | 无 | 方法必须是 public,不能是 static(除非特殊处理)。 |
| @BenchmarkMode | 指定测量模式 | Mode.Throughput(吞吐量) Mode.AverageTime(平均耗时) Mode.SampleTime(采样时间分布) Mode.SingleShotTime(冷启动) |
Mode.Throughput | 无冲突,但常与 @OutputTimeUnit 搭配。 |
| @Warmup | 预热配置(不记录结果) | iterations(次数) time(单次时长) timeUnit(单位) |
iterations=5, time=1 | 与 @Measurement 无冲突,但两者应配合使用。缺少 @Warmup 时会使用默认预热。 |
| @Measurement | 正式测量配置(记录结果) | 同上 | iterations=5, time=1 | 无冲突。 |
| @Fork | 启动多少个独立的 JVM 进程 | value(进程数) warmups(预热的 Fork 数) jvmArgs(JVM 参数) |
value=1 | ⚠️ 与 @Threads 一起使用时:每个 Fork 进程都会独立创建指定数量的线程,总线程数 = @Fork.value × @Threads。这是正常行为,但需注意总线程数可能过高。 |
| @State | 定义状态对象的作用域 | Scope.Thread(每线程独立) Scope.Benchmark(所有线程共享) Scope.Group(组内共享) |
无(必须显式标注) | ⚠️ 与 @Threads 的冲突: — Scope.Thread + @Threads>1:线程安全,每个线程有独立状态。 — Scope.Benchmark + @Threads>1:所有线程共享同一状态,必须自行保证线程安全(如使用 AtomicInteger),否则数据竞争导致结果错误。 |
| @Threads | 指定同时执行 @Benchmark 方法的并发线程数 | 整数值 | CPU 核心数 | ⚠️ 与 @State(Scope.Benchmark) 一起使用时,必须确保状态线程安全。 ⚠️ 与 @Fork 一起使用时,总线程数倍增。 ⚠️ 线程数不宜超过 CPU 核心数太多,否则上下文切换开销会污染结果。 |
| @Param | 为 @State 字段注入多个参数值 | 字符串数组,如 {"10","50","100"} |
无 | 每个参数组合会重新执行完整的 Fork、预热、测量,可能大幅增加测试总时间。与 @Setup(Level.Invocation) 同时使用会导致巨大开销,应避免。 |
| @OutputTimeUnit | 指定结果输出的时间单位 | NANOSECONDS / MICROSECONDS / MILLISECONDS / SECONDS(TimeUnit) | 无(JMH 会根据模式自动选择) | 无冲突,但应与 @BenchmarkMode 匹配。例如 Mode.AverageTime 时应使用时间单位,Mode.Throughput 时输出为「操作/时间单位」。 |
| @Setup / @TearDown | 测试前后的初始化 / 清理 | Level.Trial(整个基准测试前后) Level.Iteration(每次迭代前后) Level.Invocation(每次 @Benchmark 调用前后) |
Level.Trial | ⚠️ Level.Invocation 开销极大,会严重拖慢测试,应尽量避免。与 @Param 结合使用时会成倍放大开销。 |
| Blackhole(非注解) | 消费返回值,防止死码消除 | 作为 @Benchmark 方法参数传入,调用 consume(obj) |
无 | 无冲突。如果 @Benchmark 方法有非 void 返回值,JMH 会自动消费,但显式使用 Blackhole 更清晰且可控。 |
第四部分:Maven 集成与项目搭建
4.1 方式一:新建一个独立的 JMH 项目(推荐)
使用 Maven 原型一键生成:
mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DgroupId=com.example \
-DartifactId=jmh-demo \
-Dversion=1.0
然后进入目录,执行mvn clean install,最后运行java -jar target/benchmarks.jar。
4.2 方式二:集成到现有的 Maven 项目中
导入jar包:
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.35</version>
<scope>provided</scope>
</dependency>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
<fork>true</fork>
<annotationProcessorPaths>
<path>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.35</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
第五部分:完整代码示例(使用StringConnectTest)
下面代码演示了如何使用@Threads(4)以及@Param测试不同字符串长度下的拼接性能。
package com.controller;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
// 1. 测量模式:平均耗时
@BenchmarkMode(Mode.AverageTime)
// 2. 输出单位:纳秒
@OutputTimeUnit(TimeUnit.NANOSECONDS)
// 3. 预热:3轮,每轮1秒
@Warmup(iterations = 3, time = 1)
// 4. 正式测量:5轮,每轮5秒(注意:这里 time=5,比预热长)
@Measurement(iterations = 5, time = 5)
// 5. 并发线程数:4个线程同时压测
@Threads(4)
// 6. Fork:2个独立JVM进程
@Fork(2)
// 7. 状态作用域:所有线程共享同一个状态对象(因为用了Scope.Benchmark)
// ⚠️ 注意:这里使用了 Scope.Benchmark,意味着 length 字段会被所有4个线程共享读取,
// 但 length 是只读的(final效果),所以是线程安全的。如果写入则需要同步。
@State(value = Scope.Benchmark)
public class StringConnectTest {
// 8. 参数化:分别测试字符串长度为 10、50、100 的情况
@Param(value = {"10", "50", "100"})
private int length;
@Benchmark
public void testStringAdd(Blackhole blackhole) {
String a = "";
for (int i = 0; i < length; i++) {
a += i; // 使用 + 拼接,每次循环创建新的StringBuilder
}
blackhole.consume(a); // 防止死码消除
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(StringConnectTest.class.getSimpleName())
.build();
new Runner(opt).run();
}
}
- @BenchmarkMode(Mode.AverageTime)
告诉 JMH 我们关心的是平均每次操作耗时(单位:纳秒/操作)。适合对比不同实现的快慢。
- @OutputTimeUnit(TimeUnit.NANOSECONDS)
所有结果以纳秒显示。字符串拼接通常在几十到几百纳秒(长度10),纳秒单位合适。
- @Warmup(iterations = 3, time = 1)
预热3轮,每轮1秒。这3轮的结果不记录,只为了让 JVM 完成 JIT 编译和内联优化。
- @Measurement(iterations = 5, time = 5)
正式测量5轮,每轮5秒。这里每轮时长5秒,比预热长,是为了收集足够多的样本。
- @Threads(4)
启动4个线程同时执行testStringAdd方法。这会模拟并发场景,测试在多线程下字符串拼接的性能(注意:这里的拼接是每个线程独立操作自己的局部变量a,没有共享状态,所以线程安全)。
- @Fork(2)
启动2个独立的 JVM 进程。每个进程都会完整执行预热+测量。这样可以消除前一个测试的 JIT 状态对后一个测试的影响。
- @State(value = Scope.Benchmark)
状态作用域为Benchmark,意味着所有线程(包括不同 Fork 的线程?不,每个 Fork 独立)共享同一个StringConnectTest实例。
-
- 风险提醒:本例中length字段是只读的(在测试过程中不会被修改),所以即使多线程共享也是安全的。但如果有人尝试在@Benchmark方法中修改length,就会产生数据竞争。
- 更好的实践:对于只读参数,Scope.Benchmark没问题;但如果状态需要被修改,应使用Scope.Thread。
- @Param({“10”, “50”, “100”})
JMH 会分别运行三组测试:length=10、length=50、length=100。每组都会独立进行完整的预热、测量、Fork 流程。
- @Benchmark方法
- 方法名testStringAdd,参数Blackhole blackhole。
- 循环内使用a += i,这是经典的字符串拼接反例(每次循环创建新的StringBuilder并调用toString()),性能较差。
- 最后blackhole.consume(a)确保计算结果被消费,防止 JVM 认为a从未使用而删除整个循环。
- main方法
使用 JMH 的Runner运行测试。OptionsBuilder指定包含StringConnectTest类。
第六部分:运行结果简化版及解读
6.1 简化输出(仅保留关键结果)
Benchmark (length) Mode Cnt Score Error Units
StringConnectTest.testStringAdd 10 avgt 10 233.308 ± 8.764 ns/op
StringConnectTest.testStringAdd 50 avgt 10 2776.528 ± 158.405 ns/op
StringConnectTest.testStringAdd 100 avgt 10 10069.792 ± 531.247 ns/op
6.2 结果解读
| 列名 | 含义 | 示例值(length=10) | 解读 |
|---|---|---|---|
| Benchmark | 测试方法名 | StringConnectTest.testStringAdd | — |
| (length) | 参数值 | 10 | 本次测试使用的字符串拼接次数 |
| Mode | 模式 | avgt | AverageTime 的缩写 |
| Cnt | 有效测量次数 | 10 | = @Fork(2) × @Measurement.iterations(5) = 10 |
| Score | 平均耗时 | 233.308 ns/op | 每次 testStringAdd 调用平均耗时 233.308 纳秒 |
| Error | 99.9% 置信区间半宽 | ±8.764 ns/op | 真实均值有 99.9% 概率落在 [224.544, 242.072] 区间内 |
| Units | 单位 | ns/op | 纳秒 / 每次操作 |
趋势分析:
- length=10:约 233 ns
- length=50:约 2777 ns(增长约 12 倍)
- length=100:约 10070 ns(比 50 时增长约 3.6 倍)
为什么不是线性增长?
因为字符串拼接a += i每次都会复制整个已有字符串,复杂度是O(n²)。当length从 10 增加到 50(5倍),耗时增加了约 12 倍(符合 O(n²) 预期)。从 50 到 100(2倍),耗时增加约 3.6 倍(也接近 2²=4 倍)。
误差(Error):随着length增加,误差绝对值变大(8.7 → 158 → 531),但相对误差(Error/Score)保持在 3%~5%,说明结果可信。
6.3 完整输出中值得关注的细节
在完整输出中,你会看到每个 Fork 的预热迭代耗时逐渐下降(例如 length=10 时:306 → 255 → 232 ns/op),这证明了预热确实让 JIT 优化生效,代码越跑越快。正式测量阶段的数值稳定在 230 左右。
第七部分:为什么要预热?(Warmup 的必要性)
在 JMH 中,预热是最容易被忽略却最关键的环节。
简单回答:因为 JVM 不是一开始就跑得最快,它会随着代码被反复执行而越跑越快。
一个 Java 方法的执行会经历以下阶段:
- 解释执行(Interpreted)– 速度极慢(比编译后慢 10~100 倍)。
- 客户端编译(C1,轻量级 JIT)– 速度提升明显,但优化较少。
- 服务端编译(C2,重量级 JIT)– 进行激进优化:方法内联、循环展开、逃逸分析、栈上替换(OSR)等,速度达到峰值。
- 稳态(Steady State)– 性能稳定。
预热解决了什么问题?
- 类加载和静态初始化开销
- JIT 编译开销
- 方法内联决策
- 栈上替换(OSR)
- 代码缓存(Code Cache)填充
如果不预热,测到的是“冷启动”性能,而不是真实稳态性能。差异可达一个数量级。
第八部分:JMH 底层运行原理
- 注解处理器(APT)
编译时扫描@Benchmark方法,生成包装类,包含预热/测量循环、Blackhole 调用、多线程调度代码。
- Fork 进程隔离
每个 Fork 是新 JVM 进程,完全独立,避免 JIT 状态污染。
- 预热阶段
执行指定次数但不记录结果,让 JVM 完成类加载、JIT 编译、内联、栈上替换(OSR),达到稳态。
- 测量阶段
记录每次调用的耗时,通过 Blackhole 防止死码消除。
- 统计
汇总所有 Fork 和迭代的数据,计算平均值、标准差、百分位数、置信区间。
第九部分:JMH 的能力边界 – 能测什么?不能测什么?
✅ 适合 JMH 的场景(纯 CPU / 内存操作)
- 集合框架性能:ArrayListvsLinkedList
- 字符串操作:+拼接 vsStringBuilder
- 反射 vs 直接调用 vs 方法句柄
- Lambda vs 匿名内部类
- 序列化库(Jackson / Gson / Kryo)解析内存中的同一份 JSON
- 自定义算法(排序、加密、哈希、ID 生成器)
- 并发工具:synchronizedvsLock的 overhead
❌ 不适合 JMH 的场景(任何涉及 I/O 或外部依赖)
| 反例 | 为什么不行 |
|---|---|
| 调用 Redis(真正网络请求) | 网络延迟(0.1ms1ms)远大于微基准测量范围(nsμs),结果被网络支配,且 Redis 状态不可控。 |
| 通过 Feign / Dubbo 调用微服务 | 包含网络 + 序列化 + 下游业务逻辑,测的是整个链路,而不是你的代码片段。 |
| 执行 JDBC 查询 | 数据库 IO、锁等待、连接池开销占主导,且结果高度依赖数据分布和缓存。 |
| 文件读写 | 磁盘 IO 延迟不稳定(ms 级),且受 OS 缓存影响巨大。 |
| 完整的 HTTP 请求(如 Spring Controller) | 这是端到端测试,应使用 JMeter。 |
💡 如果真的想测“包含外部依赖”的逻辑怎么办?
只测纯计算部分,对外部依赖进行Mock。示例:
@State(Scope.Thread)
public class RedisMockBenchmark {
private MyService service;
private RedisClient mockRedis;
@Setup
public void setup() {
mockRedis = mock(RedisClient.class);
when(mockRedis.get(anyString())).thenReturn("mocked_value");
service = new MyService(mockRedis);
}
@Benchmark
public void testBusinessLogic() {
service.doSomething(); // 只测 doSomething 中的计算逻辑
}
}
第十部分:与其他性能测试框架的详细对比
| 维度 | JMH | JMeter | Gatling | wrk / ab | Redis-benchmark |
|---|---|---|---|---|---|
| 定位 | Java 微基准测试 | 端到端压力测试 | 异步端到端压力测试 | HTTP 服务器基准测试 | Redis 专用压测 |
| 测试粒度 | 单个方法 / 一段代码 | 完整业务流(多个请求) | 完整业务流(多个请求) | 单个 HTTP 端点 | Redis 命令 |
| 测量精度 | 纳秒(ns) | 毫秒(ms) | 毫秒(ms) | 毫秒(ms) | 微秒(μs) |
| 网络协议支持 | ❌ 不支持(应 Mock) | ✅ HTTP, JDBC, FTP, JMS 等 | ✅ HTTP, WebSocket | ✅ HTTP | ✅ Redis 协议 |
| 并发模拟 | ✅ 内置多线程 | ✅ 强大线程组 | ✅ Actor 高并发 | ✅ 多线程 | ✅ 多客户端 |
| 场景编排 | ❌ 线性执行 | ✅ 条件控制器、循环 | ✅ Scala DSL | ❌ 简单 | ❌ 简单 |
| 结果统计 | 均值、误差、百分位数 | 聚合报告、图表 | 动态图表、百分位数 | QPS、延迟分布 | QPS、延迟 |
| 能否测外部依赖 | ❌ 不能(必须 Mock) | ✅ 能(真实调用) | ✅ 能(真实调用) | ✅ 能(真实调用) | ✅ 能(真实调用) |
| 学习曲线 | 中高 | 中 | 中 | 低 | 低 |
| 典型场景 | 算法、集合、字符串性能对比 | Web 应用压测、数据库压测 | 高并发 WebSocket | 快速测试 Nginx | Redis 服务器性能 |
关键原则:
- JMH 测代码效率(CPU/内存)
- JMeter/Gatling 测系统容量(网络/IO/并发)
第十一部分:最佳实践总结
- 总是使用@Fork(≥2),避免 JIT 污染。
- 预热不少于 3 轮,保证稳态。
- 测量不少于 5 轮,保证统计显著性。
- 使用Blackhole消费所有返回值(除非方法是void且已有副作用)。
- 不要测 I/O– 如果测了,结果不可信。
- 使用@Param代替手动循环,让 JMH 管理参数。
- 重量级资源在@Setup(Level.Trial)中初始化,避免开销污染测试。
- 线程数不要超过 CPU 核心数太多,一般 ≤ CPU核心数×2。
- 注意@State作用域与@Threads的搭配:Scope.Benchmark+ 多线程必须自行保证线程安全。
推荐的生产级配置模板
@Fork(3)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Threads(4) // 根据 CPU 核心数调整
@State(Scope.Thread) // 除非有明确的共享需求
JMH 是 Java 微基准测试的黄金标准。它通过 Fork、预热、Blackhole 等机制,穿透 JVM 优化的迷雾,让你看到代码的真实性能。
但是,它只适用于纯内存计算的微基准。对于涉及网络、磁盘、外部服务的测试,请使用 JMeter、Gatling 等专业压测工具。
更多推荐




所有评论(0)