第一部分:为什么需要 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();

}

}
  1. @BenchmarkMode(Mode.AverageTime)

告诉 JMH 我们关心的是平均每次操作耗时(单位:纳秒/操作)。适合对比不同实现的快慢。

  1. @OutputTimeUnit(TimeUnit.NANOSECONDS)

所有结果以纳秒显示。字符串拼接通常在几十到几百纳秒(长度10),纳秒单位合适。

  1. @Warmup(iterations = 3, time = 1)

预热3轮,每轮1秒。这3轮的结果不记录,只为了让 JVM 完成 JIT 编译和内联优化。

  1. @Measurement(iterations = 5, time = 5)

正式测量5轮,每轮5秒。这里每轮时长5秒,比预热长,是为了收集足够多的样本。

  1. @Threads(4)

启动4个线程同时执行testStringAdd方法。这会模拟并发场景,测试在多线程下字符串拼接的性能(注意:这里的拼接是每个线程独立操作自己的局部变量a,没有共享状态,所以线程安全)。

  1. @Fork(2)

启动2个独立的 JVM 进程。每个进程都会完整执行预热+测量。这样可以消除前一个测试的 JIT 状态对后一个测试的影响。

  1. @State(value = Scope.Benchmark)

状态作用域为Benchmark,意味着所有线程(包括不同 Fork 的线程?不,每个 Fork 独立)共享同一个StringConnectTest实例。

    • 风险提醒:本例中length字段是只读的(在测试过程中不会被修改),所以即使多线程共享也是安全的。但如果有人尝试在@Benchmark方法中修改length,就会产生数据竞争。
    • 更好的实践:对于只读参数,Scope.Benchmark没问题;但如果状态需要被修改,应使用Scope.Thread。
  1. @Param({“10”, “50”, “100”})

JMH 会分别运行三组测试:length=10、length=50、length=100。每组都会独立进行完整的预热、测量、Fork 流程。

  1. @Benchmark方法
    • 方法名testStringAdd,参数Blackhole blackhole。
    • 循环内使用a += i,这是经典的字符串拼接反例(每次循环创建新的StringBuilder并调用toString()),性能较差。
    • 最后blackhole.consume(a)确保计算结果被消费,防止 JVM 认为a从未使用而删除整个循环。
  2. 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 方法的执行会经历以下阶段:

  1. 解释执行(Interpreted)– 速度极慢(比编译后慢 10~100 倍)。
  2. 客户端编译(C1,轻量级 JIT)– 速度提升明显,但优化较少。
  3. 服务端编译(C2,重量级 JIT)– 进行激进优化:方法内联、循环展开、逃逸分析、栈上替换(OSR)等,速度达到峰值。
  4. 稳态(Steady State)– 性能稳定。

预热解决了什么问题?

  • 类加载和静态初始化开销
  • JIT 编译开销
  • 方法内联决策
  • 栈上替换(OSR)
  • 代码缓存(Code Cache)填充

如果不预热,测到的是“冷启动”性能,而不是真实稳态性能。差异可达一个数量级。

第八部分:JMH 底层运行原理

  1. 注解处理器(APT)

编译时扫描@Benchmark方法,生成包装类,包含预热/测量循环、Blackhole 调用、多线程调度代码。

  1. Fork 进程隔离

每个 Fork 是新 JVM 进程,完全独立,避免 JIT 状态污染。

  1. 预热阶段

执行指定次数但不记录结果,让 JVM 完成类加载、JIT 编译、内联、栈上替换(OSR),达到稳态。

  1. 测量阶段

记录每次调用的耗时,通过 Blackhole 防止死码消除。

  1. 统计

汇总所有 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/并发)

第十一部分:最佳实践总结

  1. 总是使用@Fork(≥2),避免 JIT 污染。
  2. 预热不少于 3 轮,保证稳态。
  3. 测量不少于 5 轮,保证统计显著性。
  4. 使用Blackhole消费所有返回值(除非方法是void且已有副作用)。
  5. 不要测 I/O– 如果测了,结果不可信。
  6. 使用@Param代替手动循环,让 JMH 管理参数。
  7. 重量级资源在@Setup(Level.Trial)中初始化,避免开销污染测试。
  8. 线程数不要超过 CPU 核心数太多,一般 ≤ CPU核心数×2。
  9. 注意@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 等专业压测工具。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐