🗒️我和JMH有个约————Java微基准测试工具探究
00 分钟
2022-3-30
2023-8-7
type
status
date
slug
summary
tags
category
icon
password
😃
从 JDK 12开始,JDK 就带有 JMH (Java Microbenchmark Harness) ,它是一个工具包,可以帮助您正确地实现 Java 微基准测试。JMH 是由实现 Java 虚拟机(JVM)的同一批人开发的,因此他们了解 Java 的内部原理以及 Java 如何在运行时进行优化。

一、What is JMH?

JMH: Java Micro Benchmark Harness【代码微基准测试工具集】 的简写。【github for jmh
  • JMH 是 OpenJDK 团队开发的一款基准测试工具,一般用于代码的性能调优,精度甚至可以达到纳秒级别,适用于 java 以及其他基于 JVM 的语言。和 Apache JMeter 不同,JMH 测试的对象可以是任一方法,颗粒度更小,而不仅限于rest api。

二、Why JMH?

2.1 JVM causes!

现在的 JVM 已经越来越为智能,它可以在编译阶段、加载阶段、运行阶段对代码进行优化。在需要进行性能测试时,如果不知道 JVM 优化细节,可能会导致你的测试结果差之毫厘,失之千里。同样的,Java 诞生之初就有一次编译、随处运行的口号,JVM 提供了底层支持,也提供了内存管理机制,这些机制都会对我们的性能测试结果造成不可预测的影响。
也许我们测试一个简单方法,是使用如下方式,亦或者加个循环,然后用总时间除以循环次数。
但是,最终测试出来的数据真的准确吗?答案是否定的。
首先,时间戳的获取就有可能存在误差;其次,JVM可能会对一些代码进行优化,导致运行时不是真实场景下的耗时;再则,在循环中,JVM同样会有优化,会把循环展开(这里不展开说明);最后,JVM会在各个阶段都有可能对代码进行优化,存在不确定性。

2.2 Without JMH

  • 错误估计代码的性能
    • 当基准测试单独执行该组件时,JVM 或底层硬件可能会对您的组件应用许多优化。 当组件作为更大应用程序的一部分运行时,这些优化可能无法应用。 因此,实施不当的微基准测试可能会让你相信你的组件的性能比实际情况要好。
  • 无法清晰判断相似方法之间的真实性能差距,从而错误选择方案
    • 每当我们遇到问题时,我们倾向于递归地、迭代地或使用我们语言中提供的内置方法来解决它。 编码后,我们可能会倾向于混淆对于给定的数据集的规模,大的,小的,还是固定;有时,是否有更好的优势来使用内置方法或其他的方法A,B等等呢?

2.3 With JMH

  • 性能测试更精确,能够阻止 JVM 和硬件在微基准执行期间应用的优化,从而模拟真实场景的代码运行性能
    • 该工具是由Oracle JVM开发团队相关成员开发的,借助它,开发者将能足够了解自己所编写的程序代码,以及程序在运行期的精确性能表现。
  • 上手简单,只需要一些简单注解修饰,即可对相似的方法集合进行性能测试
    • 使用时,我们只需要通过配置告诉 JMH 测试哪些方法以及如何测试,JMH 就可以为我们自动生成基准测试的代码。如同编写单元测试一样简单。
      考虑这样一种场景,在实现某个功能时需要某线程安全的类,但是该类却有不同的实现方式,难以取舍之际,即可使用 JMH 进行精准的性能测试,提供一个比较好的参考。

三、JMH 快速上手

3.1 依赖引入:

3.2 一个简单Demo:

我们对一个简单方法进行性能测试
从代码中可以看出,我们对 wellHelloThere 函数进行性能测试,这里是故意留空的。
measurementIterations(5) warmupIterations(5) 分别表示正式运行批次与预热运行批次为5
运行结果如下:
得出的结果是,每秒可以运行 4370327450.774 次 【ops/s = operations per second】,误差在 93359784.885

四、JMH基本用法

4.1 @Benchmark标记基准测试方法

对需要测试的方法使用注解 @Benchmark
如果没有检测到被注解,则会抛出异常

4.2 WarmupMeasurement

什么是 Warmup 与 Measurement?

Warmup 与 Measurement 可以设置运行批次,前者表示预热的批次数,后者表示正式运行的批次数。
  • Warmup【预热】在JMH中,Warmup所做的就是【在基准测试代码正式度量之前,先对其进行预热,使得代码的执行是经历过了类的早期优化、JVM运行期编译、JIT优化之后的最终状态】,从而能够获得代码真实的性能数据。
  • Measurement 则是真正的度量操作,在每一轮的度量中,所有的度量数据会被纳入统计之中(预热数据不会纳入统计之中)

怎么使用 Warmup 与 Measurement?

  1. 设置全局的Warmup和Measurement
      • 既可以通过构造Options时设置
      • 也可以在对应的class上用相应的注解进行设置。
  1. 在基准测试方法上设置Warmup和Measurement
注意:runtime 的 options 配置可以覆盖 注解中设置的数值

Warmup 以及 Measurement 详细说明

事实上,对于 Warmup 以及 Measurement,可以设置四个变量:
  • iterations 迭代的批次
  • time 对于每个批次的时间
  • timeUnit 与time对应,是其时间单位
  • batchSize 每个批次时benchmark方法运行的次数

4.3 BenchmarkMode

JMH使用@BenchmarkMode这个注解来声明使用哪一种模式来运行,JMH为我们提供了四种运行模式,当然它还允许若干个模式同时存在
  1. AverageTimeAverageTime 它主要用于输出基准测试方法每调用一次所耗费的时间,也就是elapsed time/operation。
  1. ThroughputThroughput(方法吞吐量)则刚好与AverageTime相反,它的输出信息表明了在单位时间内可以对该方法调用多少次。
  1. SampleTimeSampleTime(时间采样)的方式是指采用一种抽样的方式来统计基准测试方法的性能结果,与我们常见的Histogram图(直方图)几乎是一样的,它会收集所有的性能数据,并且将其分布在不同的区间中。
  1. SingleShotTime 主要可用来进行冷测试,不论是Warmup还是Measurement,在每一个批次中基准测试方法只会被执行一次,一般情况下,我们会将Warmup的批次设置为0。
  1. 多Mode以及All 我们除了对某个基准测试方法设置上述四个模式中的一个之外,还可以为其设置多个模式的方式运行基准测试方法,如果你愿意,甚至可以设置全部的Mode。【可以看到 BenchmarkMode 注解是支持一个Mode 数组的】
BenchmarkMode 可以作为注解对 Benchmark方法或者 class上,也可以通过 Options 进行设置,同样的,它会覆盖注解中的设置。

4.4 OutputTimeUnit

OutputTimeUnit提供了统计结果输出时的单位,比如,调用一次该方法将会耗费多少个单位时间,或者在单位时间内对该方法进行了多少次的调用,同样,OutputTimeUnit既可以设置在class上,也可以设置在method上,还可以在Options中进行设置,它们的覆盖次序与BenchmarkMode一致,这里就不再赘述了。

4.5 三大State的使用

在JMH中,有三大State分别对应于Scope的三个枚举值。
  • Benchmark
  • Thread
  • Group

Thread独享的State

所谓线程独享的State是指,每一个运行基准测试方法的线程都会持有一个独立的对象实例,该实例既可能是作为基准测试方法参数传入的,也可能是运行基准方法所在的宿主class,将State设置为Scope.Thread一般主要是针对非线程安全的类。

Thread共享的State

有时候,我们需要测试在多线程的情况下某个类被不同线程操作时的性能,比如,多线程访问某个共享数据时,我们需要让多个线程使用同一个实例才可以。因此JMH提供了多线程共享的一种状态Scope.Benchmark。

线程组共享的State

第一,是在多线程情况下的单个实例;第二,允许一个以上的基准测试方法并发并行地运行。比如,在多线程高并发的环境中,多个线程同时对一个ConcurrentHashMap进行读写。使用 group 即可实现这种情况,多个基准测试方法可以并发运行。

4.6 @Param的妙用

可以解决代码的冗余,提供类似 Data-Driven-Test 的能力。
使用 param 可以实现 N* N * N 的测试效果。
另外,可以
参考 JMHSample_27_Params 例子

4.7 JMH的测试套件(Fixture)

Setup以及TearDown

JMH提供了两个注解@Setup和@TearDown用于套件测试,其中@Setup会在每一个基准测试方法执行前被调用,通常用于资源的初始化,@TearDown则会在基准测试方法被执行之后被调用,通常可用于资源的回收清理工作

Level

使用Setup和TearDown时,在默认情况下,Setup和TearDown会在一个基准方法的所有批次执行前后分别执行,如果需要在每一个批次或者每一次基准方法调用执行的前后执行对应的套件方法,则需要对@Setup和@TearDown进行简单的配置。
  • Trial:Setup和TearDown默认的配置,该套件方法会在每一个基准测试方法的所有批次执行的前后被执行。【对应下图的位置1与位置2】
  • Iteration:由于我们可以设置Warmup和Measurement,因此每一个基准测试方法都会被执行若干个批次,如果想要在每一个基准测试批次执行的前后调用套件方法,则可以将Level设置为Iteration。【对应下图的位置3和位置4】
  • Invocation:将Level设置为Invocation意味着在每一个批次的度量过程中,每一次对基准方法的调用前后都会执行套件方法。【对应下图的位置5与位置6】
notion image

4.8 CompilerControl

JMH提供了可以控制是否使用内联的注解 @CompilerControl ,它的参数有如下可选:
  • CompilerControl.Mode.DONT_INLINE:不使用内联
  • CompilerControl.Mode.INLINE:强制使用内联
  • CompilerControl.Mode.EXCLUDE:不编译
此外还有其他的参数选项,可以参考:
这里给到jmh的一个示例:
结果如下:
从执行结果可以看到内联方法和空方法执行速度一样,不编译执行最慢。

五、如何正确使用JMH

了解之后,那么应该需要知道——如何编写正确的微基准测试用例:
  1. 避免 DCE(Dead Code Elimination)
  1. 使用 Blackhole
  1. 避免常量折叠(Constant Folding)
  1. 避免循环展开(Loop Unwinding)
  1. Fork 用于避免 Profile-guided optimizations
编写正确的微机准

5.1 避免 DCE(Dead Code Elimination)

编译器会对一些冗余的、不被其他地方用到的代码进行删除,如果这些代码在我们的性能测试中,那么会造成结果的不准确。
我们看一个官方例子:
baseline 方法啥都不做,作为基准方法
measureWrong 方法进行了 log 计算,但是结果未被使用,这里会被编译器优化的,实际运行效果与 baseline 一致。
measureRight 方法正好相反,返回了计算的结果,这里会有正常的耗时。
我们直接看结果:
measureWrong 与 baseline 耗时基本持平
measureRight 耗时明显增多

5.2 使用 Blackhole

那有什么方法可以避免这种情况呢?上面可以知道,将局部变量返回,就能避免DCE了,但是,如果有很多变量,我们不可能去构造一个List来保存吧。那构造的时间还得考虑进去,这就太复杂了。
所以,这里就要用到 Blackhole 【黑洞】了。
还是看一个例子:
里面有四个基准方法:
  • baseline 方法是 通过return 返回 Log 计算,会执行一次log,作为我们的baseline。
  • measureWrong 虽然看起来计算了两次,但是只有 return 那个语句会执行,所以结果应该是与 baseline 持平的。
  • measureRight_1 通过 加法操作将两次计算求和,时间上大致上是baseline 的两倍
  • measureRight_2 通过将结果保存在Blackhole 中,让两次计算不会被编译器优化,时间上与 measureRight_1 基本持平,大致上是baseline 的两倍。
直接看结果:
与我们分析的分毫误差。
measureRight_2 会比 measureRight_1 多一些时间,这是因为 black hole 的 consume 方法也会占用一定的CPU。这种情况下,对无返回的方法进行性能分析时,针对局部变量的使用都统一使用 black hole,就能够保证同样的基准执行条件了。

5.3 避免常量折叠(Constant Folding)

常量折叠是Java编译器早起的一种优化——编译优化。
在编译器里进行语法分析的时候,将常量表达式计算求值,并用求得的值来替换表达式,放入常量表,可以算作一种编译优化。这种情况下,也会对我们的真实性能测试起到一些影响。
我们看一个例子:
其中有四个基准方法:
  • baseline 直接返回 PI 这个常量,用来作为baseline 对比
  • measureWrong_1 中返回的结果 Math.log(Math.PI) 也是可以通过计算得到的一个常量,基本时间与 baseline 一致
  • measureWrong_2 中对类中的final变量进行计算,final修饰的数为常量,这里也是可以计算出的一个常量,基本时间与 baseline 一致
  • measureRight 中 对类中的成员变量进行 log 计算,这里在类初始化才能拿到值的,所以对于编译器是不可预知的值,所以结果会比前三个方法运行耗时较久。
我们看看运行结果:
结果正如我们分析所料,前三个方法中,在编译器优化阶段的时候就发生了常量折叠,这些方法在运行阶段根本不需要再进行计算,直接将结果返回即可,而 measureRight 则没有进行编译器优化,所以统计数据会较高。

5.4 避免循环展开(Loop Unwinding)

循环展开,是一种牺牲程序的大小来加快程序执行速度的优化方法。可以由程序员完成,也可由编译器自动优化完成。
对于如下的代码:
JVM可能会优化成这样
我们看一个例子:
代码中使用了 @OperationsPerInvocation 注解,这里是为了在最终结果中计算单次循环的耗时
@OperationsPerInvocation(1_000) 表示我们将 op 作为 1000,也就是计算耗时的时候,是用总时间除以 1000次,其他的类似。
我们可以看到,在循环次数越多的情况下,折叠的情况也越多,因此性能会更好,说明JVM在运行期对我们的代码进行了优化。

5.5 Fork 用于避免 Profile-guided optimizations

Profile-guided optimization (PGO, sometimes pronounced as pogo)是计算机编程中的一种编译器最佳化技术,它使用进程 Profile 来提高程序运行时的性能。
Fork的引入也是考虑到了这个问题,虽然Java支持多线程,但是不支持多进程,这就导致了所有的代码都在一个进程中运行,相同的代码在不同时刻的执行可能会引入前一阶段对进程profiler的优化,甚至会混入其他代码profiler优化时的参数,这很有可能会导致我们所编写的微基准测试出现不准确的问题。
使用Fork能重新开辟一个 JVM,保证环境基础一致,而不会被其他环境影响。
我们还是看一个例子:
运行结果:
若将Fork设置为0,则会与运行基准测试的类共享同样的进程Profiler
若设置为1则会为每一个基准测试方法开辟新的进程去运行
当然,你可以将Fork设置为大于1的数值,那么它将多次运行在不同的进程中,不过一般情况下,我们只需要将Fork设置为1即可。

六、如何接入项目?

根据GitHub - openjdk/jmh的介绍,可以知道
The recommended way to run a JMH benchmark is to use Maven to setup a standalone project that depends on the jar files of your application. This approach is preferred to ensure that the benchmarks are correctly initialized and produce reliable results. It is possible to run benchmarks from within an existing project, and even from within an IDE, however setup is more complex and the results are less reliable.
推荐的运行 JMH 基准测试的方法是使用 Maven 建立一个独立的项目,该项目依赖于您的应用程序的 jar 文件。这种方法更适合于确保基准测试程序正确地初始化并产生可靠的结果。在现有的项目中甚至在 IDE 中运行基准测试是可能的,但是设置更复杂,结果更不可靠。

参考链接:

 
上一篇
非暴力沟通合集
下一篇
【笔记】MySQL必知必会

评论
Loading...