0%

基准测试框架JMH详解

@Fork和@BenchmarkMode

Foke: 1 of 5指的是JMH会Fork出一个新的JVM,来运行性能基准测试。(另外启动一个JVM进行性能基准测试,是为了获得一个相对干净的虚拟机环境)

JVM是如何实现反射的。因为类型profile被污染,会导致无法内联的情况。使用新的虚拟接,将极大的降低被上述情况干扰的可能性,从而保证更加精确的性能数据。
方法内联-虚方法内联。基于类层次分析的完全内联。新启动的JVM,其加载的与测试无关的抽象类子类或接口实现相对较少。具体是否进行完全内联将交由开发人员来决定。

除了对即时编译器的影响之外,Fork出新的JVM还会提升性能数据的准确度。
不少JVM的优化会带来不确定性,如TLAB(thread-local-allocation-buffer)内存分配,偏向锁,轻量级锁算法,并发数据结构等。这些不确定性都可能导致不同JVM中运行的性能测试的结果不同。

通过运行更多的Fork,并将每个JVM的性能测试结果平均起来,可以增强最终数据的可信度,使其误差更小。在JMH中,可以通过@Fork注解来配置。

1
2
3
4
@Fork(10)
public class MyBenchmark {
...
}

每个Fork包含了5个预热迭代(warmup iteration,如#Warmup Iteration 1: 1023500,647 ops/s)以及5个测试迭代(measurement iteration,如Iteration 1: 1010251,342 ops/s)。

每个迭代后都跟着一个数据,代表本次迭代的吞吐量,也就是每秒运行了多少次操作(operations/s,或ops/s)。默认情况下,一次操作指的是调用一次测试方法testMethod。

除了吞吐量之外,还可以输出其他格式的性能数据,如运行一次操作的平均时间。

1
2
3
4
@BenchmarkMode(Mode.AverageTime)
public class MyBenchmark {
...
}

@Warmup和@Measurement

区分预热迭代和测试迭代,是为了在记录性能数据之前,将JVM带至一个稳定状态。这个稳定状态,不仅包括测试方法被即时编译成机器码,还包括JVM中各种自适配优化算法能够稳定下来,如TLAB大小,或者是使用传统垃圾回收器时的Eden区、Survivor区和老年代的大小。

一般来说,预热迭代的数目以及没每次预热迭代的时间,需要根据所要测试的业务逻辑代码来调配。通常的做法是在首次运行时配置较多次迭代,并监控性能数据达到稳定状态时的迭代数目。

不少性能测试框架都会自动检测稳定状态。它们所采用的算法是计算迭代之间的差值,如果连续几个迭代与前一迭代的差值均小于某个值,便将这几个迭代以及之后的迭代当成稳定状态。

缺陷:在达到最终稳定状态前,程序可能拥有多个中间稳定状态。(通过Java上的JavaScript引擎Nashorn运行JavaScript代码,可能出现多个中间稳定状态)

开发人员需要自行决定预热迭代的次数以及每次迭代的持续时间。
通常情况下,在保持5-10个预热迭代的前提下,将总的预热时间优化至最少,以便节省性能测试的机器时间。

当确定了预热迭代的次数以及每次迭代的持续时间之后,便可以通过@Warmup注解来进行配置。

1
2
3
4
5
//@Warmup注解有四个参数,分别为预热迭代的次数iterations,每次迭代持续的时间time和timeUnit(前者是数值,后者是单位。示例为每次迭代持续100毫秒),以及每次操作包含多少次对测试方法的调用batchSize。  
@Warmup(iterations=10, time=100, timeUnit=TimeUnit.MILLISECONDS, batchSize=10)
public class MyBenchmark {
...
}

测试迭代可通过@Measurement注解来进行配置。它的可配置选项和@Warmup的一致。与预热迭代不同的是,每个Fork中测试迭代的数目越多,得到的性能数据越精确。

@State、@Setup、@TearDown

通常所要测试的业务逻辑只是整个应用程序的一小部分,如某个具体的web app请求。这要求在每次调用测试方法前,程序处于准备接收请求的状态。
上述场景抽象后,变成程序从某种状态到另一种状态的转换,而性能测试,便是在收集该转换的性能数据。

JMH提供了@State注解,被它标注的类便是程序的状态。由于JMH将负责生成这些状态类的实例,因此,它要求状态类必须拥有无参数构造器,以及当状态类为内部类时,该状态类必须是静态的。

JMH还将程序状态细分为整个虚拟机的程序状态,线程私有的程序状态,以及线程租私有的程序状态,分别对应@State注解的参数Scope.Benchmark,Scope.Thread和Scope.Group。(这里的线程租并非JDK中的那个概念,而是JMH自己定义的概念,具体参考@GroupThreads注解)

@State的配置方法以及状态类的用法:

1
2
3
4
5
6
7
8
9
10
11
public class MyBenchmark {
@State(Scope.Benchmark)
public static class MyBenchmarkState {
String message = "exception";
}

@Benchmark
public void testMethod(MyBenchmarkState state) {
new Exception(state.message);
}
}

状态类是通过方法参数的方式传入测试方法之中的。JMH将负责把所构造的状态类实例传入该方法之中。如果MyBenchmark被标注为@State,那么可以不用在测试方法中定义额外的参数,而是直接访问MyBenchmark类中的实例变量。

与JUnit测试一样,可以在测试前初始化程序状态,在测试后校验程序状态。这两种操作分别对应@Setup和@TearDown注解,被它们标注的方法必须是状态类中的方法。
而且,JMH并不限定状态类中@Setup方法以及@TearDown方法的数目。当存在多个@Setup方法或者@TearDown方法时,JMH将按照定义的先后顺序执行。

JMH对@Setup方法以及@TearDown方法的调用时机是可配置的。可供选择的粒度有在整个性能测试前后调用,在每个迭代前后调用,以及在每次调用测试方法前后调用。其中,最后一个粒度将影响测试数据的精度。
这三种粒度分别对应@Setup@TearDown注解的参数Level.Trial,Level.Iteration,以及Level.Invocation。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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() {
//Run with -ea
assert count == 1 : "ERROR";
}
}

@Benchmark
public void testMethod(MyBenchmarkState state) {
state.count++;
}
}

即时编译相关功能

JMH还提供了不少控制即时编译的功能,如可以控制每个方法内联与否的@CompilerControl注解。

另一个更小粒度的功能则是Blackhole类。它里边的consume方法可以防止即时编译器将所传入的值给优化掉。
具体的使用方法便是为被@Benchmark注解标注了的测试方法增添一个类型为Blackhole的参数,并且在测试方法的代码中调用其实例方法Blackhole.consume。

1
2
3
4
@Benchmark
public void testMethod(Blackhole bh) {
bh.consume(newObject());// prevents escape analysis
}

它并不会阻止对传入值的计算的优化。

1
2
3
4
5
@Benchmark
public void teatMethod(Blockhole bh) {
bh.consume(3+4);
}
//将3+4的值传入Blackhole.consume方法中。即时编译器仍旧会进行常量折叠,而Blackhole将阻止即时编译器把所得到的常量值7给优化消除掉。

除了防止死代码消除的consume之外,Blackhole类还提供了一个静态方法consumeCPU,来消耗CPU时间。该方法将接收一个long类型的参数,这个参数与所消耗的CPU时间成呈线性相关。

总结

  • @Fork允许开发人员制定所要Fork出的JVM的数目。
  • @BenchmarkMode允许指定性能数据的格式。
  • @Warmup和@Measurement允许配置预热迭代或者测试迭代的数目,每个迭代的时间以及每个操作所包含多少次测试方法的调用。
  • @State允许配置测试程序的状态。测试前对程序状态的初始化以及测试后对程序状态的恢复或者校验可分别通过@Setup和@TearDown来实现。