28 深入理解JMH:Java微基准测试框架实战指南(上篇)

图片[1]-28 深入理解JMH:Java微基准测试框架实战指南(上篇)-速优课

本文导读

在日常开发中,我们经常需要对代码进行性能测试,以验证某个方案的优劣。然而,很多开发者写出的性能测试代码往往存在各种问题,导致测试结果严重偏离实际情况。本文将带你深入了解 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),而是更彻底的常量传播 + 死代码消除。只要是计数循环且结果可预测,编译器就可能直接计算出最终结果。

顺带一提,如果你想”避免”这个优化,可以把循环变量 iint 改成 long 类型,因为 JVM 的这个优化仅针对 int 类型的计数循环。

1.4 操作系统与硬件的影响

除了 JVM 层面,操作系统和硬件层面也有很多因素会影响性能测试结果。

电源管理策略

一个常见的例子是 CPU 的电源管理策略。在很多机器上(尤其是笔记本),操作系统会动态调整 CPU 频率。频率直接影响性能,所以短时间的性能测试结果往往不可靠。

图片[2]-28 深入理解JMH:Java微基准测试框架实战指南(上篇)-速优课

比如某台笔记本,刚开始性能测试时单核频率能冲到 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 个。


总结与思考

本文要点回顾

  1. 性能测试陷阱多:手动编写的性能测试往往不可靠,JVM 的 JIT 编译、操作系统的电源管理、CPU 缓存与伪共享等因素都会严重影响测试结果。
  2. JIT 优化的威力:即时编译器能够进行常量传播、死代码消除等激进优化,甚至可能把你的测试代码完全优化掉,导致测试结果毫无意义。
  3. JMH 是专业选择:作为 OpenJDK 官方的基准测试框架,JMH 内置了大量机制来规避各种性能测试陷阱,让开发者可以专注于业务逻辑本身。
  4. 注解驱动的设计:JMH 利用注解处理器自动生成测试代码和配置文件,开发者只需用 @Benchmark 标记测试方法即可。
  5. 精心设计的内存布局:JMH 通过继承层次和填充字段来避免伪共享问题,确保测试结果的准确性。

思考与实践

  1. 回忆一下你之前写过的性能测试代码,有没有可能踩了本文提到的坑?
  2. 动手生成一个 JMH 项目,试着用它来测试你感兴趣的某个操作的性能。
  3. 想一想,除了本文提到的这些,还有哪些因素可能影响性能测试的准确性?

在下一篇文章中,我们将继续深入 JMH 的高级特性,包括各种功能注解、测试模式、Blackhole 机制等,敬请期待。

© 版权声明
THE END
喜欢就支持一下吧
点赞10
相关推荐
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!