29 深入理解JMH:高级特性与最佳实践(下篇)

图片[1]-29 深入理解JMH:高级特性与最佳实践(下篇)-速优课

本文导读

在上一篇文章中,我们介绍了 JMH 的基本概念和快速上手方法。本篇将继续深入 JMH 的高级特性,帮助你更精准地控制性能测试的方方面面。

通过阅读本文,你将了解:

  • @Fork 注解的作用以及为什么需要独立的 JVM 进程
  • 如何配置预热迭代和测试迭代以获得稳定可靠的结果
  • @State@Setup@TearDown 如何管理测试状态
  • Blackhole 机制是如何防止死代码消除的
  • JMH 提供了哪些控制即时编译的功能

一、JMH 运行机制详解

1.1 从输出结果看 JMH 执行流程

在上一篇的结尾,我们运行了 JMH 生成的 jar 包。让我们仔细看看输出结果,理解 JMH 的执行流程:

$ java -jar target/benchmarks.jar
...
# JMH version: 1.21
# VM version: JDK 10.0.2, Java HotSpot(TM) 64-Bit Server VM, 10.0.2+13
# VM invoker: /Library/Java/JavaVirtualMachines/jdk-10.0.2.jdk/Contents/Home/bin/java
# VM options: <none>
# Warmup: 5 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: org.sample.MyBenchmark.testMethod
​
# Run progress: 0,00% complete, ETA 00:08:20
# Fork: 1 of 5
# Warmup Iteration   1: 1023500,647 ops/s
# Warmup Iteration   2: 1030767,909 ops/s
# Warmup Iteration   3: 1018212,559 ops/s
# Warmup Iteration   4: 1002045,519 ops/s
# Warmup Iteration   5: 1004210,056 ops/s
Iteration   1: 1010251,342 ops/s
Iteration   2: 1005717,344 ops/s
Iteration   3: 1004751,523 ops/s
Iteration   4: 1003034,640 ops/s
Iteration   5: 997003,830 ops/s
​
# Run progress: 20,00% complete, ETA 00:06:41
# Fork: 2 of 5
...

可以看到,输出中有五段类似的内容,每段都以 Fork: X of 5 开头。这意味着 JMH 会启动 5 个独立的 JVM 进程来运行测试。

1.2 为什么要 Fork 独立进程

@Fork 注解用来指定 Fork 出多少个 JVM 进程来运行性能测试。默认值是 5。

@Fork(10)
public class MyBenchmark {
  ...
}

为什么需要额外启动独立的 JVM 进程呢?主要有以下几个原因:

1. 获得干净的虚拟机环境

在介绍反射的文章中我们提到过,类型 profile 被污染会导致方法无法内联。使用全新的 JVM 进程可以极大程度地避免这种干扰,保证性能数据的准确性。

2. 类层次分析的准确性

在虚方法内联中,基于类层次分析(CHA)的完全内联依赖于当前加载的类。新启动的 JVM 加载的无关类较少,是否进行完全内联更多地由测试代码本身决定,而不是被环境中的其他类影响。

3. 降低不确定性带来的偏差

JVM 的很多优化都带有不确定性,比如:

  • TLAB(Thread-Local Allocation Buffer)的大小会动态变化
  • 偏向锁、轻量级锁的升级过程
  • 并发数据结构的内部状态

这些因素会导致不同 JVM 实例中的测试结果存在差异。通过运行多个 Fork 并取平均值,可以增强最终数据的可信度,缩小误差范围。

1.3 BenchmarkMode:测试模式

每个 Fork 中包含若干预热迭代(warmup iteration)和测试迭代(measurement iteration)。每个迭代都会输出一个数据,代表本次迭代的吞吐量(operations per second,ops/s)。

默认情况下,一次操作就是调用一次测试方法。除了吞吐量,JMH 还支持其他测试模式,通过 @BenchmarkMode 注解配置:

@BenchmarkMode(Mode.AverageTime)
public class MyBenchmark {
  ...
}

JMH 支持的测试模式包括:

模式说明单位
Throughput吞吐量:单位时间内的操作次数ops/time
AverageTime平均时间:每次操作的平均耗时time/op
SampleTime采样时间:随机采样操作耗时time/op
SingleShotTime单次时间:测量单次操作的耗时time/op
All所有模式都跑一遍

一般来说,默认的吞吐量模式已经能满足大多数测试需求。


二、迭代配置:预热与测量

2.1 为什么需要预热

区分预热迭代和测试迭代的根本目的是:在正式记录性能数据之前,让 JVM 进入稳定状态。

这里的”稳定状态”不仅仅指测试方法被即时编译成机器码,还包括 JVM 中各种自适应优化算法都稳定下来,比如:

  • TLAB 的大小调整
  • 垃圾回收器的 Eden 区、Survivor 区、老年代大小
  • 各种 JIT 编译优化的触发

2.2 预热迭代的配置

预热迭代的次数和每次迭代的持续时间需要根据具体的业务逻辑来调整。通常的做法是:首次运行时配置较多的迭代次数,观察性能数据在第几次迭代时达到稳定状态。

有些性能测试框架会自动检测稳定状态——计算连续几个迭代之间的差值,如果差值小于某个阈值就认为达到了稳定状态。但这种方法有个缺陷:在达到最终稳定状态之前,程序可能经历多个中间稳定状态。比如用 Nashorn 引擎运行 JavaScript 代码就可能出现这种情况。

所以,预热迭代的次数和持续时间最终还是需要开发者自己判断。

我的个人经验是:在保持 5-10 个预热迭代的前提下(这样可以直观地看出是否达到稳定),尽量减少总的预热时间以节省机器资源。这在 CI/CD 环境中尤为重要——毕竟硬件资源的增长往往赶不上代码提交的速度。

通过 @Warmup 注解可以配置预热参数:

@Warmup(iterations=10, time=100, timeUnit=TimeUnit.MILLISECONDS, batchSize=10)
public class MyBenchmark {
  ...
}

@Warmup 有四个参数:

  • iterations:预热迭代的次数
  • time:每次迭代持续的时间(数值部分)
  • timeUnit:时间单位(如 TimeUnit.MILLISECONDS 表示毫秒)
  • batchSize:每次操作包含多少次对测试方法的调用

2.3 测试迭代的配置

测试迭代通过 @Measurement 注解配置,可配置的选项和 @Warmup 完全一样:

与预热迭代不同的是:每个 Fork 中测试迭代的次数越多,最终的性能数据就越精确。当然,测试时间也会相应变长。

@Measurement(iterations=10, time=1, timeUnit=TimeUnit.SECONDS)
public class MyBenchmark {
  ...
}

三、状态管理:@State、@Setup、@TearDown

3.1 为什么需要状态管理

在实际场景中,我们要测试的业务逻辑往往只是整个应用的一小部分,比如一个具体的 Web 请求处理。这就要求每次调用测试方法之前,程序都处于正确的”准备状态”。

我们可以把性能测试抽象为:程序从某个状态转换到另一个状态的过程,而我们要测量的就是这个转换过程的性能数据。

JMH 提供了 @State 注解来管理测试状态。被 @State 标注的类就是状态类,JMH 会负责创建和管理这些类的实例。

注意:状态类必须有一个无参构造器;如果状态类是内部类,则必须是静态内部类。

3.2 状态的作用域

JMH 将状态分为三个级别,通过 @State 的参数指定:

作用域说明
Scope.Benchmark整个基准测试共享同一个状态实例(所有线程共享)
Scope.Thread每个线程有自己独立的状态实例
Scope.Group每个线程组共享一个状态实例(JMH 自定义的线程组概念)

使用方式如下:

public class MyBenchmark {
    @State(Scope.Benchmark)
    public static class MyBenchmarkState {
      String message = "exception";
    }
​
    @Benchmark
    public void testMethod(MyBenchmarkState state) {
        new Exception(state.message);
    }
}

可以看到,状态类通过方法参数的方式传入测试方法,JMH 会自动把状态实例注入进来。

还有一种更简洁的写法:直接把 @State 标注在测试类本身上,这样就可以直接访问类的实例变量,不用再通过方法参数传递:

@State(Scope.Benchmark)
public class MyBenchmark {
    String message = "exception";
​
    @Benchmark
    public void testMethod() {
        new Exception(message);
    }
}

3.3 初始化与清理:@Setup 和 @TearDown

和 JUnit 类似,JMH 也支持在测试前进行初始化、在测试后进行清理或校验,分别对应 @Setup@TearDown 注解。这两个注解必须标注在状态类的方法上。

JMH 不限制 @Setup@TearDown 方法的数量。如果有多个,会按照定义的先后顺序执行。

这两个注解的调用时机是可配置的,有三种粒度可选:

级别说明
Level.Trial在整个性能测试(所有迭代)的前后执行
Level.Iteration在每个迭代的前后执行
Level.Invocation在每次调用测试方法的前后执行(会影响测试精度)

下面是一个使用示例:

public class MyBenchmark {
  @State(Scope.Benchmark)
  public static class MyBenchmarkState {
    int count;
​
    @Setup(Level.Invocation)
    public void before() {
      count = 0;
    }
​
    @TearDown(Level.Invocation)
    public void after() {
      // 运行时加上 -ea 参数开启断言
      assert count == 1 : "ERROR";
    }
  }
​
  @Benchmark
  public void testMethod(MyBenchmarkState state) {
    state.count++;
  }
}

注意:Level.Invocation 级别的 @Setup@TearDown 会在每次方法调用前后执行,它们的开销会影响性能测试的精度,所以使用时要谨慎。


四、即时编译控制

4.1 @CompilerControl 注解

JMH 提供了 @CompilerControl 注解来精细控制每个方法的即时编译行为,比如是否内联、是否编译等。

常见的控制选项包括:

  • CompilerControl.Mode.INLINE:强制内联
  • CompilerControl.Mode.DONT_INLINE:禁止内联
  • CompilerControl.Mode.EXCLUDE:排除编译(始终解释执行)

4.2 Blackhole:防止死代码消除

Blackhole 是 JMH 中另一个非常实用的工具类。它的 consume 方法可以防止即时编译器把传入的值当作死代码优化掉。

使用方法很简单:给测试方法增加一个 Blackhole 类型的参数,然后在代码中调用 bh.consume() 方法:

@Benchmark
public void testMethod(Blackhole bh) {
  bh.consume(new Object()); // 阻止逃逸分析优化掉对象创建
}

这里需要特别注意:Blackhole.consume 只会阻止”结果被优化掉”,但不会阻止”计算过程被优化”。

举个例子,下面这段代码中,3+4 仍然会被编译器常量折叠成 7,但 Blackhole 会阻止编译器把这个 7 当成没用的值直接消除掉:

@Benchmark
public void testMethod(Blackhole bh) {
  bh.consume(3+4); // 3+4 会被常量折叠成 7,但 7 不会被消除
}

除了 consume 实例方法,Blackhole 还提供了一个静态方法 consumeCPU,用来消耗指定数量的 CPU 时间。它接收一个 long 类型的参数,参数值与消耗的 CPU 时间呈线性关系。这在某些需要模拟计算耗时的场景中非常有用。


总结与思考

本文要点回顾

  1. Fork 机制保证测试准确性:JMH 通过启动独立的 JVM 进程来避免类型 profile 污染、类层次干扰等问题,多个 Fork 的平均值能有效降低不确定性带来的偏差。
  2. 预热是性能测试的关键:预热迭代让 JVM 进入稳定状态,包括 JIT 编译完成、各种自适应优化稳定。预热的次数和时间需要根据具体场景调整,不能盲目依赖自动检测。
  3. 状态管理让测试更真实@State 注解支持三种作用域(Benchmark、Thread、Group),配合 @Setup@TearDown 可以精确控制测试前后的状态初始化与清理。
  4. Blackhole 防止优化干扰:通过 Blackhole.consume() 可以防止死代码消除,但要注意它不会阻止计算过程本身的优化(如常量折叠)。
  5. 精细控制编译行为@CompilerControl 注解可以控制单个方法的内联、编译等行为,满足各种高级测试需求。

思考与实践

  1. 想一想,在你的工作场景中,哪些性能测试适合用 JMH 来做?哪些不适合?
  2. 动手试试 JMH 官方提供的各种示例,每个示例的代码注释里都说明了它演示的是什么功能。
  3. 如果让你来设计一个性能测试框架,你会怎么处理预热检测的问题?

JMH 的功能非常丰富,本文只介绍了最核心的部分。要想真正用好 JMH,还需要在实践中不断积累经验。记住那句话:数字只是数据,重要的是理解数字背后的原因。

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

请登录后发表评论

    请登录后查看评论内容

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