无比强大的JMH基准测试工具

本文最后更新于:2020年12月12日 下午

1. JMH是什么?

JMH:即(Java Microbenchmark Harness),它是由 Java 官方团队开发的一款用于 Java 微基准测试工具。

基准测试:是指通过设计科学的测试方法、测试工具和测试系统,实现对一类测试对象的某项性能指标进行定量的和可对比的测试。

比如鲁大师、安兔兔,都是按一定的基准或者在特定条件下去测试某一对象的的性能,比如显卡、IO、CPU之类的。

JMH官网:http://openjdk.java.net/projects/code-tools/jmh

2. JMH和JMeter的不同?

JMeter更多的是对rest api进行压测,而JMH关注的粒度更细,它更多的是发现某块性能槽点代码,然后对优化方案进行基准测试对比。比如json序列化方案对比,bean copy方案对比等。

3. 使用步骤

3.1 pom.xml引入依赖

因为 JMH 是 JDK9 自带的,如果是 JDK9 之前的版本需要加入如下依赖(目前 JMH 的最新版本为 1.26):

<!-- Java 官方基准测试工具 JMH jar -->
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.26</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.26</version>
    <scope>provided</scope>
</dependency>

3.2 编写基准测试类

假设有两个函数getSum1()、getSum2()都是将map集合的value求和,现在需要测试两者的性能哪个更好。

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;


@BenchmarkMode(Mode.AverageTime) // 测试平均完成时间
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS) // 预热 2 轮,每次 1s
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) // 测试 5 轮,每次 1s
@Fork(1) // fork 1 个线程
@State(Scope.Thread) // 每个测试线程一个实例
public class JMHTests {

    public static void main(String[] args) throws RunnerException {
        // 使用 Builder 模式配置测试信息
        Options opt = new OptionsBuilder()
                // 要导入的测试类
                .include(JMHTests.class.getSimpleName())
                // 输出测试结果的文件
//                .output("D:/dev/jmh-map.log")
                .build();
        // 执行测试
        new Runner(opt).run();
    }

    static Map<String, Integer> map = new HashMap() {{
        // 添加数据
        for (int i = 1; i <= 100; i++) {
            put("k_" + i, i);
        }
    }};

    @Benchmark
    public int getSum1() {
        int sum = 0;
        for (Map.Entry<String, Integer> entry : map.entrySet()) {
            sum += entry.getValue();
        }
        return sum;
    }

    @Benchmark
    public int getSum2() {
        int sum = 0;
        for (String k : map.keySet()) {
            sum += map.get(k);
        }
        return sum;
    }

}

3.3 测试结果

# JMH version: 1.26
# VM version: JDK 1.8.0_241, Java HotSpot(TM) 64-Bit Server VM, 25.241-b07
# VM invoker: C:\Program Files\Java\jdk1.8.0_241\jre\bin\java.exe
# VM options: -javaagent:D:\java\IntelliJ IDEA 2019.3.4\lib\idea_rt.jar=7158:D:\java\IntelliJ IDEA 2019.3.4\bin -Dfile.encoding=UTF-8
# Warmup: 2 iterations, 1 s each
# Measurement: 5 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: com.liang.onepiece.demo.JMHTests.getSum1

## 	^  
## 	^  
## 这是测试的基本信息,比如,使用的Java路径,预热代码的迭代次数,测量代码的迭代次数,使用的线程数量,测试的统计单位等。

# Run progress: 0.00% complete, ETA 00:00:14
# Fork: 1 of 1
# Warmup Iteration   1: 290.674 ns/op
# Warmup Iteration   2: 272.164 ns/op

## 	^  
## 	^  
## 这是每次预热迭代的结果,预热迭代不会作为最终的统计结果。预热的目的是让Java虚拟机对被测代码进行足够多的优化,比如,在预热后被测代码应该得到了充分的JIT编译和优化。

Iteration   1: 252.834 ns/op
Iteration   2: 249.030 ns/op
Iteration   3: 248.453 ns/op
Iteration   4: 247.457 ns/op
Iteration   5: 247.619 ns/op

## 	^  
## 	^  
## 这是每次基准测试迭代的结果,每一次迭代都显示了当前的执行速率,即一个操作所花费的时间。

Result "com.liang.onepiece.demo.JMHTests.getSum1":
  249.079 ±(99.9%) 8.448 ns/op [Average]
  (min, avg, max) = (247.457, 249.079, 252.834), stdev = 2.194
  CI (99.9%): [240.630, 257.527] (assumes normal distribution)


## 	^  
## 	^  
## 这是一个方法测试完成后的统计,结果在Result后。Result第一段结果告诉了我们最小值、平均值、最大值的信息。第二段是最主要的信息。
## 下面是第二个方法的测试信息。
 

# JMH version: 1.26
# VM version: JDK 1.8.0_241, Java HotSpot(TM) 64-Bit Server VM, 25.241-b07
# VM invoker: C:\Program Files\Java\jdk1.8.0_241\jre\bin\java.exe
# VM options: -javaagent:D:\java\IntelliJ IDEA 2019.3.4\lib\idea_rt.jar=7158:D:\java\IntelliJ IDEA 2019.3.4\bin -Dfile.encoding=UTF-8
# Warmup: 2 iterations, 1 s each
# Measurement: 5 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: com.liang.onepiece.demo.JMHTests.getSum2

# Run progress: 50.00% complete, ETA 00:00:07
# Fork: 1 of 1
# Warmup Iteration   1: 522.107 ns/op
# Warmup Iteration   2: 514.760 ns/op
Iteration   1: 597.481 ns/op
Iteration   2: 598.313 ns/op
Iteration   3: 596.199 ns/op
Iteration   4: 597.036 ns/op
Iteration   5: 595.382 ns/op


Result "com.liang.onepiece.demo.JMHTests.getSum2":
  596.882 ±(99.9%) 4.368 ns/op [Average]
  (min, avg, max) = (595.382, 596.882, 598.313), stdev = 1.134
  CI (99.9%): [592.514, 601.250] (assumes normal distribution)


# Run complete. Total time: 00:00:15

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.

Benchmark         Mode  Cnt    Score   Error  Units
JMHTests.getSum1  avgt    5  249.079 ± 8.448  ns/op
JMHTests.getSum2  avgt    5  596.882 ± 4.368  ns/op

## 	^  
## 	^  
## 这是最终的统计,也是我们常看的数据。
Benchmark        	 Mode 		 Cnt   	 Score   Error   Units
基准测试执行的方法   测试模式	 运行多少次   测试值   错误    单位

3.4 生成 jar 包执行

对于一些小测试,直接用上面的方式写一个 main 函数手动执行就好了。

对于大型的测试,需要测试的时间比较久、线程数比较多,加上测试的服务器需要,一般要放在 Linux 服务器里去执行。

JMH 官方提供了生成 jar 包的方式来执行,我们需要在 maven 里增加一个 plugin,具体配置如下:

普通maven项目:

<plugins>
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <version>2.4.1</version>
        <executions>
            <execution>
                <phase>package</phase>
                <goals>
                    <goal>shade</goal>
                </goals>
                <configuration>
                    <finalName>al-jmh</finalName>
                    <transformers>
                        <transformer
                                implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                            <mainClass>org.openjdk.jmh.Main</mainClass>
                        </transformer>
                    </transformers>
                </configuration>
            </execution>
        </executions>
    </plugin>
</plugins>

SpringBoot项目:

<plugins>
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <executions>
            <execution>
                <phase>package</phase>
                <goals>
                    <goal>shade</goal>
                </goals>
                <configuration>
                    <finalName>al-jmh-test</finalName>
                    <transformers>
                        <transformer
                                implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                            <resource>META-INF/spring.handlers</resource>
                        </transformer>
                        <transformer
                                implementation="org.springframework.boot.maven.PropertiesMergingResourceTransformer">
                            <resource>META-INF/spring.factories</resource>
                        </transformer>
                        <transformer
                                implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                            <resource>META-INF/spring.schemas</resource>
                        </transformer>
                        <transformer
                                implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
                        <transformer
                                implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                            <mainClass>org.openjdk.jmh.Main</mainClass>
                        </transformer>
                    </transformers>
                </configuration>
            </execution>
        </executions>
    </plugin>
</plugins>

接着执行 maven 的命令生成可执行 jar 包并执行:

mvn clean install
java -jar al-jmh-test.jar

4. JMH的基本概念和配置

4.1 测试模式

测试模式是指JMH的测量方式和角度,共有4种,吞吐量和方法执行的平均时间是最为常用的统计方式。

可通过@BenchmarkMode注解配置。 可以用在类和方法上。

模式描述
Throughput表示1秒内可以执行多少次调用(吞吐量)
AverageTime调用的平均时间, 指每一次调用所需要的时间
SampleTime随机取样,最后输出取样结果的分布
SingleShotTime以上模式都是默认一次Iteration是1秒,唯有SingleShotTime 只运行一次。往往同时把warmup 次数设为0, 用于测试冷启动时的性能。
All所有模式

4.2 迭代

迭代是JMH的一次测量的单位。在大部分测量模式下,一次迭代表示1秒。在这一秒内会不间断调用被测方法,并采样计算吞吐量、平均时间等。

4.3 预热

预热是指在实际进行 benchmark 前先进行预热的行为。

为什么需要预热?因为 JVM 的 JIT 机制的存在,如果某个函数被调用多次之后,JVM 会尝试将其编译成为机器码从而提高执行速度。为了让 benchmark 的结果更加接近真实情况就需要进行预热。

由于Java 虚拟机的JIT 的存在,同一个方法在JIT编译前后的时间将会不同。通常只考虑方法在JIT编译后的性能。使用 -Xint 参数可以关闭JIT优化。

4.4 状态

@State注解,只能作用在类上。通过State 可以指定一个对象的作用范围,范围主要有三种:

模式描述
Scope.Thread默认的State,每个测试线程分配一个实例,也就是一个对象只会被一个线程访问。在多线程池测试时,会为每一个线程生成一个对象。
Scope.Benchmark所有测试线程共享一个实例,用于测试有状态实例在多线程共享下的性能
Scope.Group每个线程组共享一个实例,在测试方法上使用 @Group 设置线程组。

4.5 配置类(Options/OptionsBuilder)

在测试开始前, 首先要对测试进行配置。通常需要指定一些参数, 比如指定测试类(include) 、使用的进程个数(fork) 、预热迭代次数(warmuplterations) 。在配置启动测试时, 需要使用配置类。
OptionsBuilder的常用方法及对应的注解形式如下:

方法名作用对应注解
include指定要运行的基准测试类和方法
exclude指定不要运行的基准测试类方法
warmupIterations指定预热的迭代次数@Warmup
warmupBatchSize指定预热批量的大小@Warmup
warmupForks指定预热模式:INDI,BULK,BULK_INDI@Warmup
warmupMode指定预热的模式@Warmup
warmupTime指定预热的时间@Warmup
measurementIterations指定测试的迭代次数@Measurement(iterations = 5)
measurementBatchSize指定测试批量的大小@Measurement(batchSize = 1)
measurementTime指定测试的时间@Measurement(time = 1)
mode指定测试模式@BenchmarkMode
fork进行fork的次数。如果fork数是2的话,则JMH会fork出两个进程来进行测试。@Fork(2)
threads指定每个方法开启线程数量@Threads(1)

4.6 其他注解

方法名作用
@Benchmark方法级注解,表示该方法是需要进行 基准测试 的对象。
@Group结合@Benchmark一起使用,把多个基准方法归为一类,只能作用在方法上。
@GroupThreads定义了多少个线程参与在组中运行基准方法。只能作用在方法上。
@OutputTimeUnit结果所使用的时间单位,可用于类或者方法注解,使用java.util.concurrent.TimeUnit中的标准时间单位。
@Setup方法注解,会在执行 benchmark 之前被执行,正如其名,主要用于初始化。
@TearDown方法注解,与@Setup相对的,会在所有benchmark执行结束以后执行,主要用于资源的回收等。
@Param成员注解,可以用来指定某项参数的多种情况。特别适合用来测试一个函数在不同的参数输入的情况下的性能。
@Param注解接收一个String数组,在@setup方法执行前转化为为对应的数据类型。
多个@Param注解的成员之间是乘积关系,譬如有两个用@Param注解的字段,第一个有5个值,第二个字段有2个值,那么每个测试方法会跑5*2=10次。

5. JMH 陷阱

5.1 死码消除

@Benchmark
public void test() {
    String a = "";
    for (int i = 0; i < 10; i++) {
        a += i;
    }
}

以上代码 JVM 可能会认为变量 a 从来没有使用过,从而进行优化把整个方法内部代码移除掉,这就会影响测试结果。

JMH 提供了两种方式避免这种问题,一种是将这个变量作为方法返回值 return a,一种是通过 Blackhole 的 consume 来避免 JIT 的优化消除。

如下所示:

@Benchmark
public void test(Blackhole bh) {
    String a = "";
    for (int i = 0; i < 10; i++) {
        a += i;
    }
    bh.consume(a);
}

参考链接: 1.L-Java 2.官方demo