![图片[1]-28 深入理解JMH:Java微基准测试框架实战指南(上篇)-速优课](http://www.suyouke.com/wp-content/uploads/2026/07/doubao_img_2304x1728_20260722_174942-1024x768.png)
本文导读
在日常开发中,我们经常需要对代码进行性能测试,以验证某个方案的优劣。然而,很多开发者写出的性能测试代码往往存在各种问题,导致测试结果严重偏离实际情况。本文将带你深入了解 OpenJDK 官方提供的性能基准测试框架 JMH(Java Microbenchmark Harness),帮助你写出准确可靠的性能测试代码。
通过阅读本文,你将了解:
- 为什么手动写的性能测试结果往往不可靠
- JVM 即时编译器是如何”欺骗”你的性能测试的
- 操作系统和硬件层面有哪些因素会影响性能测试结果
- 如何快速搭建一个 JMH 基准测试项目
- JMH 项目的编译运行原理和内部机制
一、性能测试的那些坑
1.1 一个看似简单的性能测试
我们先来看一段常见的性能测试代码。假设我们要测试一个简单的循环累加方法的性能:
static int foo() {
int i = 0;
while (i < 1_000_000_000) {
i++;
}
return i;
}
很多开发者会用下面这种方式来进行性能测试——通过 System.nanoTime() 或 System.currentTimeMillis() 来计算若干次操作的耗时:
public class LoopPerformanceTest {
static int foo() { ... }
public static void main(String[] args) {
// 预热
for (int i = 0; i < 20_000; i++) {
foo();
}
// 正式测量
long current = System.nanoTime();
for (int i = 1; i <= 10_000; i++) {
foo();
if (i % 1000 == 0) {
long temp = System.nanoTime();
System.out.println(temp - current);
current = System.nanoTime();
}
}
}
}
这种写法看似合理,实则过于理想化,完全忽略了 JVM、操作系统乃至硬件系统对性能测试的影响。
1.2 JIT 编译带来的干扰
我们在前面的文章中已经介绍过不少 JVM 对性能的影响因素,比如堆空间的自适应调整、即时编译(JIT)等。
在上面的测试代码中,真正用于测量的代码(// 正式测量 之后的部分)由于循环次数不多,属于冷循环,可能无法触发 OSR(On-Stack Replacement,栈上替换)编译。
这就导致了一种尴尬的局面:main 方法在解释执行,而调用的 foo 方法却已经被即时编译成了机器码。这种解释执行和编译执行混杂的测量方式,得到的数据含义非常模糊。
有人可能会说,foo 方法执行 10 亿次加法,耗时应该很长,main 方法的解释执行开销可以忽略不计。但实际测试结果却让人震惊——在某台机器上,每 1000 次 foo 方法调用只需要约 20 微秒。
按这个速度推算,CPU 频率岂不是达到了 100,000,000 GHz?(假设循环体只有两条指令,每个时钟周期执行一条指令)这显然是不可能的,目前单核 CPU 频率也就 2-5 GHz 左右,再怎么超频也不可能差七八个数量级。
1.3 循环优化:你的代码可能根本没执行
答案其实很简单——即时编译器把整个循环优化掉了。我们来看 foo 方法编译后的实际机器码:
0x8aa0: sub rsp,0x18 // 创建方法栈帧
0x8aa7: mov QWORD PTR [rsp+0x10],rbp // 保存寄存器
0x8aac: mov eax,0x3b9aca00 // 直接返回 10^9
0x8ab1: add rsp,0x10 // 弹出方法栈帧
0x8ab5: pop rbp // 恢复寄存器
0x8ab6: mov r10,QWORD PTR [r15+0x70] // 安全点测试
0x8aba: test DWORD PTR [r10],eax // 安全点测试
0x8abd: ret
看到了吗?编译器直接把:
static int foo() {
int i = 0;
while (i < 1_000_000_000) {
i++;
}
return i;
}
优化成了:
static int foo() {
return 1_000_000_000;
}
这可不是循环展开(默认情况下,JVM 的循环展开次数只有 60 次,对应参数 -XX:LoopUnrollLimit),而是更彻底的常量传播 + 死代码消除。只要是计数循环且结果可预测,编译器就可能直接计算出最终结果。
顺带一提,如果你想”避免”这个优化,可以把循环变量
i从int改成long类型,因为 JVM 的这个优化仅针对 int 类型的计数循环。
1.4 操作系统与硬件的影响
除了 JVM 层面,操作系统和硬件层面也有很多因素会影响性能测试结果。
电源管理策略
一个常见的例子是 CPU 的电源管理策略。在很多机器上(尤其是笔记本),操作系统会动态调整 CPU 频率。频率直接影响性能,所以短时间的性能测试结果往往不可靠。
![图片[2]-28 深入理解JMH:Java微基准测试框架实战指南(上篇)-速优课](https://www.suyouke.com/wp-content/uploads/2026/07/image-28.png)
比如某台笔记本,刚开始性能测试时单核频率能冲到 4.0 GHz,但随着 CPU 温度升高,频率会被降到 3.0 GHz 甚至更低。这种情况下,测试数据的波动会非常大。
CPU 缓存与伪共享
CPU 缓存也是一个重要因素。如果程序的数据局部性好,缓存命中率高,性能就会很好;反之如果存在伪共享(false sharing)问题——即多个线程写入同一缓存行的不同部分——性能就会急剧下降。
分支预测与超线程
分支预测器的准确率、超线程技术的调度情况,都会对测试结果产生影响。
以超线程为例,每个物理核心会虚拟出两个逻辑核心来提高利用率。如果两个测试线程被调度到同一个物理核心的两个逻辑核心上,性能数据肯定比调度到不同物理核心上差很多。
二、JMH:专业级基准测试框架
2.1 JMH 是什么
说了这么多坑,那有没有靠谱的性能测试方案呢?答案是肯定的——OpenJDK 官方的 JMH(Java Microbenchmark Harness)。
JMH 是一个面向 Java 及其他 JVM 语言的性能基准测试框架,支持纳秒级、微秒级、毫秒级乃至秒级别的性能测试。
由于 JMH 的核心开发者中有不少就是 JIT 编译器的开发者,所以 JMH 内置了大量功能来控制即时编译器的优化。对于操作系统和硬件层面的影响因素,JMH 也提供了不少策略来降低甚至消除它们的干扰。
使用 JMH 的最大好处是:开发者可以把精力完全集中在要测试的业务逻辑上,以最小的代价控制各种干扰因素。
2.2 JMH 的官方忠告
不过,JMH 也不是万能的。它甚至会在每次运行的输出结果中打印这样一段提醒:
REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial experiments, perform baseline and negative tests that provide experimental control, make sure the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts. Do not assume the numbers tell you what you want them to tell.
这段话的大意是:数字只是数据,不要轻信数字,要去探究数字背后的原因。 性能测试的结果反映的是”业务逻辑 + 特定 JVM + 特定操作系统 + 特定硬件”这一组合下的性能指标,从中得出通用结论需要非常谨慎。
三、快速搭建 JMH 项目
3.1 使用 Maven Archetype 生成项目
JMH 的使用非常简单。最便捷的方式是使用 Maven Archetype 来生成一个预设好依赖的项目模板:
$ mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DgroupId=org.sample \
-DartifactId=test \
-Dversion=1.21
$ cd test
执行完这条命令后,当前目录下会生成一个 test 文件夹(对应 -DartifactId=test,可自行修改),里面包含:
pom.xml:项目依赖配置src/main/org/sample/MyBenchmark.java:自动生成的测试类(org/sample对应-DgroupId=org.sample)
自动生成的测试类内容如下:
package org.sample;
import org.openjdk.jmh.annotations.Benchmark;
public class MyBenchmark {
@Benchmark
public void testMethod() {
// This is a demo/sample template for building your JMH benchmarks. Edit as needed.
// Put your benchmark code here.
}
}
这里面类名和方法名都可以随便改,真正关键的是 @Benchmark 注解。被它标注的方法就是 JMH 的基准测试方法。默认是空实现,我们填入自己要测试的业务逻辑即可。
举个例子,测试创建异常对象的性能:
@Benchmark
public void testMethod() {
new Exception();
}
你可能会担心这段代码会不会被 JIT 优化掉。其实不用担心,我们学过逃逸分析就知道:
Exception的构造器会间接调用 native 方法fillInStackTrace,而这个 native 调用的调用者就是新建的Exception对象,因此逃逸分析会判定该对象逃逸,编译器无法优化掉对象创建操作。当然,方法返回后这个对象就可以被 GC 回收了。
3.2 编译 JMH 项目
之前介绍注解处理器的时候我们提到过,JMH 正是利用注解处理器来自动生成性能测试代码的。除了 @Benchmark 之外,org.openjdk.jmh.annotations 包下的所有注解都会被 JMH 的注解处理器处理。
执行 mvn compile 命令编译项目,在 target/generated-sources 目录下可以看到 JMH 注解处理器生成的源代码:
$ mvn compile
$ ls target/generated-sources/annotations/org/sample/generated/
MyBenchmark_jmhType.java
MyBenchmark_jmhType_B1.java
MyBenchmark_jmhType_B2.java
MyBenchmark_jmhType_B3.java
MyBenchmark_testMethod_jmhTest.java
这些 _jmhType 前缀的类都继承自 MyBenchmark。这是注解处理器的常见套路——通过生成子类来扩展注解的语义。
它们的继承关系是:MyBenchmark_jmhType -> B3 -> B2 -> B1 -> MyBenchmark(A -> B 表示 A 继承 B)。其中:
- B2:存放 JMH 控制基准测试的各种字段
- B1 和 B3:各存放 256 个 boolean 字段,用来防止伪共享(false sharing)——确保 B2 中的控制字段不会和
MyBenchmark类、MyBenchmark_jmhType类的字段(或者内存中相邻对象的字段)出现在同一个缓存行中
为什么不直接在一个类里安排这些字段?因为 JVM 会对类中的字段进行重排列。而通过继承关系,不同类的字段之间不会被重排列,这样就能精确控制内存布局了。
除了 jmhType 相关的类,generated-sources 目录下还有真正的性能测试代码 MyBenchmark_testMethod_jmhTest.java。性能测试时,JVM 执行的热循环很可能就是这个文件中的代码经过 OSR 编译后的版本。
如果你想用
CompileCommand来分析即时编译后的机器码,应该关注MyBenchmark_testMethod_jmhTest中的方法。
3.3 打包与运行
接下来执行 mvn package 命令,把编译好的 class 文件打包成 jar 包。生成的 jar 包位于 target 目录下,名为 benchmarks.jar:
$ mvn package
jar 包里附带了一系列配置文件,我们挑几个重要的来看:
$ jar tf target/benchmarks.jar META-INF
META-INF/MANIFEST.MF
META-INF/BenchmarkList
META-INF/CompilerHints
...
1. MANIFEST.MF
指定了 jar 包的主类入口:
Main-Class: org.openjdk.jmh.Main
2. BenchmarkList
存放了测试配置,是根据 MyBenchmark.java 中的注解自动生成的:
JMH S 22 org.sample.MyBenchmark S 51 org.sample.generated.MyBenchmark_testMethod_jmhTest S 10 testMethod S 10 Throughput E A 1 1 1 E E E E E E E E E E E E E E E E E
3. CompilerHints
这个文件的内容会作为 -XX:CompileCommandFile 参数传递给 JVM,规定了哪些方法不能内联、哪些方法必须内联:
dontinline,*.*_all_jmhStub
dontinline,*.*_avgt_jmhStub
dontinline,*.*_sample_jmhStub
dontinline,*.*_ss_jmhStub
dontinline,*.*_thrpt_jmhStub
inline,org/sample/MyBenchmark.testMethod
可以看到,JMH 会强制让 JIT 编译器内联我们的测试方法 testMethod,以避免方法调用的开销影响测试结果。
3.4 运行基准测试
打包好的 jar 包可以直接运行:
$ java -jar target/benchmarks.jar
WARNING: An illegal reflective access operation has occurred
...
Benchmark Mode Cnt Score Error Units
MyBenchmark.testMethod thrpt 25 1004801,393 ± 4055,462 ops/s
JMH 的输出内容非常多,我们先看最后的结果。关键的两个指标:
- Score:平均吞吐量,也就是每秒能运行多少次
testMethod方法 - Error:误差范围
比如上面的结果表示,平均每秒能创建约 100 万个异常实例,误差范围大约是 ±4000 个。
总结与思考
本文要点回顾
- 性能测试陷阱多:手动编写的性能测试往往不可靠,JVM 的 JIT 编译、操作系统的电源管理、CPU 缓存与伪共享等因素都会严重影响测试结果。
- JIT 优化的威力:即时编译器能够进行常量传播、死代码消除等激进优化,甚至可能把你的测试代码完全优化掉,导致测试结果毫无意义。
- JMH 是专业选择:作为 OpenJDK 官方的基准测试框架,JMH 内置了大量机制来规避各种性能测试陷阱,让开发者可以专注于业务逻辑本身。
- 注解驱动的设计:JMH 利用注解处理器自动生成测试代码和配置文件,开发者只需用
@Benchmark标记测试方法即可。 - 精心设计的内存布局:JMH 通过继承层次和填充字段来避免伪共享问题,确保测试结果的准确性。
思考与实践
- 回忆一下你之前写过的性能测试代码,有没有可能踩了本文提到的坑?
- 动手生成一个 JMH 项目,试着用它来测试你感兴趣的某个操作的性能。
- 想一想,除了本文提到的这些,还有哪些因素可能影响性能测试的准确性?
在下一篇文章中,我们将继续深入 JMH 的高级特性,包括各种功能注解、测试模式、Blackhole 机制等,敬请期待。








请登录后查看评论内容