0%

基准测试框架JMH简介

基准测试benchmarking

性能测试的坑

通过System.nanoTime或者System.currentTimeMillis来测量每若干个操作所花费的时间,这种测量方式过于理性化,忽略了JVM、操作系统、硬件系统所带来的影响。

JVM的影响

JVM堆空间的自适配,即时编译等。

真正进行测试的代码由于循环次数不多,属于冷循环,不一定能触发OSR(On-Stack Replacement)编译。
在main方法中解释执行,然后调用foo方法即时编译生成的机器码。这种混杂了解释执行以及即时编译生成代码的测量方式,其得到的数据含义不明。(即时编译器循环优化)

操作系统和硬件系统的影响

例电源管理策略。操作系统会动态配置CPU的频率,CPU的频率直接影响到性能测试的数据,因此短时间的性能测试得出的数据未必可靠。

例CPU缓存,如果程序的数据本地性较好,那么它的性能指标便会非常好;如果程序存在false sharing的问题,即几个线程写入内存中属于同一缓存行的不同部分,那么它的性能指标便会非常糟糕。

例超线程技术,将为每个物理核心虚拟出两个虚拟核心,从而尽可能的提高物理核心的利用率。如果性能测试的两个线程被安排在同一物理核心上,那么得到的测试数据显然要比被安排在不同物理核心上的数据糟糕。

性能基准测试存在着许多深坑(pitfall)。性能测试数据很有可能是有偏差的(biased)。

JMH

OpenJDK的开源项目JMH(Java Microbenchmark Harness)是一个面向Java语言或者其它JVM语言的性能基准测试框架。它针对的是纳秒级别、微妙级别、毫秒级别、以及秒级别的性能测试。
JMH内置了许多功能来控制即时编译器的优化,对于其它影响性能评测的因素,JMH提供了不少策略来降低影响。使用JMH可以将精力完全几种在所要测试的业务逻辑,并以最小的代价控制除业务逻辑之外的可能影响性能的因素。JMH也不能完美解决性能测试数据的偏差问题。

通常来说,性能基准测试的结果反映的是所测试的业务逻辑在所运行的JVM、操作系统、硬件系统这一组合上的性能指标,而根据这些性能指标得出的通用结论则需要经过严格论证。

生成JMH项目

借助JMH部署在maven上的archetype,生成预设好依赖关系的maven项目模板。

1
2
3
4
5
6
7
8
$ 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),其中包含了定义该maven项目依赖的pom.xml文件,以及自动生成的测试文件src/main/org/sample/MyBenchmark.java(对应参数-DgroupId=org.sample)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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.
//测试新建异常对象的性能
new Exception();
//native方法调用的调用者或者参数会被识别为逃逸。
//Exception的构造器将间接调用至native方法fillInStackTrace中,该方法调用的调用者便是新建的Exception队形。
//逃逸分析将判定该新建对象逃逸,而即时编译器无法优化掉原本的新建对象操作。
//当Exception的构造器返回时,JVM将不再拥有指向这一新建对象的引用,该新建对象可以被垃圾回收。
}

}

@Benchmark注解,标注的方法就是JMH基准测试的测试方法。该方法默认是空的,可以填入需要进行性能测试的业务逻辑。

编译和运行JMH项目

JMH是利用注解处理器来自动生成性能测试的代码。除了@Benchmark之外,JMH的注解处理器还将处理所有位于org.openjdk.jmh.annotations包下的注解。

mvn compile

可以运行mvn compile命令来编译这个maven项目。该命令将生成target文件夹,其中的generated-sources目录存放着由JMH的注解处理器所生成的Java源代码:

1
2
3
$ 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

在这些源代码里,所有以MyBenchmark_jmhType为前缀的Java类都继承自MyBenchmark。这是注解处理器的常见用法,通过生成子类来将注解所带来的额外语义扩张成方法。

它们之间的继承关系是MyBenchmark_jmhType->B3->B2->B1->MyBenchmark(A->B代表A继承B)。其中B2存放着JMH用来控制基准测试的各项字段。

为了避免这些控制字段对MyBenchmark类中的字段造成false sharing的影响,JMH生成了B1和B3,分别存放了256个boolean字段,从而避免B2中的字段与MyBenchmark类、MyBenchmark_jmhType类中的字段(或内存里下一个对象中的字段)会出现在同一缓存行中。

因为JVM的字段重排列,所以不能在同一类中安排这些字段。类之间的继承关系,可以避免不同类所包含的字段之间的重排列。

除了jmhType源代码外,generated-source目录还存放着真正的性能测试代码MyBenchmark_testMethod_jmhTest.java。当进行性能测试时,JVM所运行的代码很有可能便是这一个源文件中的热循环经过OSR编译过后的代码。

在通过CompileCommand分析即时编译后的机器码时,需要关注的其实是MyBenchmark_testMethod_jmhTest中的方法。

mvn package

可以运行mvn package命令,将编译号的class文件打包成jar包。生成的jar包同样位于target目录下,名字为benchmarks.jar。jar包中附带了一系列配置文件。

其中三个配置文件:

  • MANIFEST.MF中指定了该jar包的默认入口,即org.openjdk.jmh.Main。
  • BenchmarkList中存放了测试配置。该配置是根据MyBenchmark.java里的注解自动生成的。
  • CompilerHints中存放了传递给JVM的-XX:CompileCommandFile参数的内容。它规定了无法内联以及必须内联的几个方法,其中便有存放业务逻辑的测试方法testMethod。

在编译MyBenchmark_testMethod_jmhTest类中的测试方法时,JMH会让即时编译器强制内联对MyBenchmark.testMethod的方法调用,以避免调用开销。

打包生成的jar包可以直接运行。

1
2
3
4
5
6

$ 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

输出的最后便是本次基准测试的结果。其中比较重要的两项指标是Score和Error,分别代表本次基准测试的平局吞吐量(每秒运行testMethod方法的次数)以及误差范围。(本次基准测试平均每秒生成10^6个异常实例,误差范围大致在4000个异常实例)

总结

Java程序的性能测试存在着许多深坑,有来自JVM的,有来自操作系统的,甚至有来自硬件系统的。性能测试的结果很有可能是有偏差的。

性能基准测试框架JMH是OpenJDK中的其中一个开源项目。它内置了许多功能,来规避由JVM中的即时编译器或者其它优化对性能测试造成的影响。它还提供了不少策略来降低来自操作系统以及硬件系统的影响。

开发人员仅需要将所要测试的业务逻辑通过@Benchmark注解,便可以让JMH的注解处理器自动生成真正的性能测试代码,以及相应的性能测试配置文件。