Java 基准测试工具:JMH

背景

在日常开发中,我们经常需要对一段代码进行性能测试,以验证我们的优化手段是否有效。通常我们的做法可能是

1
2
3
4
5
6
public void someFunction() {
long startTime = System.nanoTime();
// do some execution
long timeCost = System.nanoTime() - startTime;
System.out.println("Time cost is %d ns".format(timeCost));
}

如果我们需要对这段代码测试之后取平均值,那么我们还得在外面加上一个循环。如果需要计算方差等数据,还需要写更多额外的代码。如果考虑到 JIT 编译,那么我们还需要在测试之前加上 Warm up 的过程。有没有什么工具能够帮助我们完成这个过程呢?就像我们会用 JUnit 来帮助我们完成单元测试一样,Java 中的 Java Microbenchmark Harness(JMH)就是一个帮助我们完成基准测试的工具。

什么是 JMH

JMH 是一个用于编写、执行和分析Java代码微基准测试的框架。微基准测试是一种性能测试方法,用于测量代码片段的执行时间。它们通常用于评估不同实现之间的性能差异,以确定哪个实现更适合特定场景。JMH由OpenJDK项目开发和维护,因此具有很高的可靠性和稳定性。

JMH的主要功能如下:

  1. 自动执行:JMH会自动运行基准测试,控制迭代次数、预热时间等参数,以确保测试结果的准确性。
  2. 精确度:JMH通过减少常见的微基准测试陷阱(例如死代码消除、循环展开等)来提高测试的精确度。
  3. 灵活性:JMH支持多种输出格式,如CSV、JSON等,便于结果分析和可视化。同时,它还允许用户自定义基准测试的各种参数,如预热时间、迭代次数等。
  4. 注解驱动:JMH使用注解来标记基准测试方法,方便快速编写测试代码。

一个简单的例子

如果我们想要在一个 Maven 项目中使用 JMH,首先需要在 pom.xml 中加入

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.32</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.32</version>
</dependency>

在代码中,我们可以通过注解来指定我们要测试的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 3, time = 5)
public class JMHExample {
@Benchmark
public void addArrayList() {
List<Integer> list = new ArrayList<>();
for(int i = 0; i < 10_0000; i++) {
list.add(0);
}
}

@Benchmark
public void addLinkedList() {
List<Integer> list = new ArrayList<>();
for(int i = 0; i < 10_0000; i++) {
list.add(0);
}
}
}

在运行前,我们需要在 IDEA 中下载 JMH 插件,这个插件能够帮助我们在 IDEA 直接运行性能测试。下载成功以后,IDEA 的界面如图所示,我们直接点击左边的三个运行按钮即可运行 Benchmark。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# JMH version: 1.33
# VM version: JDK 11.0.2, OpenJDK 64-Bit Server VM, 11.0.2+9
# VM invoker: C:\Program Files\Java\jdk-11.0.2\bin\java.exe
# VM options: -javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2022.3.1\lib\idea_rt.jar=62315:C:\Program Files\JetBrains\IntelliJ IDEA 2022.3.1\bin -Dfile.encoding=UTF-8
# Blackhole mode: full + dont-inline hint (default, use -Djmh.blackhole.autoDetect=true to auto-detect)
# Warmup: 3 iterations, 1 s each
# Measurement: 3 iterations, 5 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: org.example.JMHExample.addArrayList

# Run progress: 0.00% complete, ETA 00:03:00
# Fork: 1 of 5
# Warmup Iteration 1: 339789.195 ns/op
# Warmup Iteration 2: 262514.212 ns/op
# Warmup Iteration 3: 259732.333 ns/op
Iteration 1: 269787.357 ns/op
Iteration 2: 307217.039 ns/op
Iteration 3: 368421.391 ns/op

...


Result "org.example.JMHExample.addArrayList":
281986.682 ±(99.9%) 28381.842 ns/op [Average]
(min, avg, max) = (262447.982, 281986.682, 368421.391), stdev = 26548.393
CI (99.9%): [253604.840, 310368.524] (assumes normal distribution)


# JMH version: 1.33
# VM version: JDK 11.0.2, OpenJDK 64-Bit Server VM, 11.0.2+9
# VM invoker: C:\Program Files\Java\jdk-11.0.2\bin\java.exe
# VM options: -javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2022.3.1\lib\idea_rt.jar=62315:C:\Program Files\JetBrains\IntelliJ IDEA 2022.3.1\bin -Dfile.encoding=UTF-8
# Blackhole mode: full + dont-inline hint (default, use -Djmh.blackhole.autoDetect=true to auto-detect)
# Warmup: 3 iterations, 1 s each
# Measurement: 3 iterations, 5 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: org.example.JMHExample.addLinkedList

# Run progress: 50.00% complete, ETA 00:01:34
# Fork: 1 of 5
# Warmup Iteration 1: 349367.752 ns/op
# Warmup Iteration 2: 289455.708 ns/op
# Warmup Iteration 3: 287668.947 ns/op
Iteration 1: 274087.549 ns/op
Iteration 2: 266708.740 ns/op
Iteration 3: 268492.382 ns/op

...


Result "org.example.JMHExample.addLinkedList":
266844.826 ±(99.9%) 4881.063 ns/op [Average]
(min, avg, max) = (258488.261, 266844.826, 274087.549), stdev = 4565.749
CI (99.9%): [261963.764, 271725.889] (assumes normal distribution)


# Run complete. Total time: 00:03:08

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
JMHExample.addArrayList avgt 15 281986.682 ± 28381.842 ns/op
JMHExample.addLinkedList avgt 15 266844.826 ± 4881.063 ns/op

在这个例子中,我们使用注解在代码中指定了我们测试的方式。

@State 注解指定了对象在工作线程之间的共享程度。按照示例代码中 @State(Scope.Thread) 的指示,JMH 会为每一个线程都创建一个 JMHExample 实例。如果我们将注解改为 @State(Scope.Benchmark),那么 JMH 就会在多个线程之间共享一个 JMHExample 实例,这样可以帮助我们测试某个方法在多线程下的性能。此外,还可以选择 @State(Scope.Group) ,JMH 将在一个线程组内共享一个 JMHExample 实例。

@BenchmarkMode(Mode.AverageTime) 注解指定了这次测试主要统计平均的响应时间,此外还有吞吐、响应时间范围、跳过 JIT 的响应时间可选。

@OutputTimeUnit(TimeUnit.NANOSECONDS) 指定了输出结果的单位是纳秒。

@Warmup(iterations = 3, time = 1) 指定了预热阶段的配置,每次基准测试前进行三次预热,每次预热的迭代时间为 1 秒。预热可以在测试之前提前运行代码,有利于 JVM 对待测代码进行 JIT 编译,提高代码的运行效率。

@Measurement(iterations = 3, time = 5) 制定了基准测试的配置,基准测试总共运行 3 次,每次测试都运行 5 秒钟。

@Benchmark 需要写在需要测试的方法前,就像 JUnit 里的 @Test 注解一样,框架会根据这些注解找到需要测试的函数,并根据配置运行这些函数。在同一个类里,@Benchmark 可以写在多个函数中,测试时 JMH 框架会依次对这些被标记的函数进行测试。

对于简单的函数,我们可以在 IDEA 中运行测试。对于一些平台依赖的代码(例如必须在 Linux 上运行的代码)或者耗时较久的代码,我们可以把代码编译成 jar 包放到服务器上运行。因此,需要在 pom.xml 中加入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<build>
<finalName>JMH-Test</finalName>
<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>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resourceManifestResourceTransformer">
<mainClass>org.example.JMHExample</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

在类 JMHExmaple 中加入主函数

1
2
3
4
5
6
7
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(JMHExample.class.getSimpleName())
.result("result.json")
.resultFormat(ResultFormatType.JSON).build();
new Runner(opt).run();
}

然后执行 mvn clean package,然后将 JMH-Test.jar 发送到服务器上,并使用

1
java -jar JMH-Test.jar

命令运行即可。运行结束后,结果会保存在 result.json 文件中。

JMH 的其他注解

我们在实例中介绍了 @State@BenchmarkMode@OutputTimeUnit@Warmup@Measurement 以及 @Benchmark 注解,JMH 中还有其他很实用的注解。

与 JUnit 类似,JMH 中的 @Setup@TearDown 注解允许我们标记基准测试的初始化函数和终止函数。在这两个函数中可以处理一些资源的初始化和回收工作。

@Fork 注解用于指定 Measurement 的次数,例如 @Fork(1),对某一段代码就只会运行一次 Measurement。@Fork 注解必须作用于一个类上或者一个方法上。默认情况下,Fork 的值为 5,也就是对一个方法会进行 5 次的 Measurement。

@Threads 注解可以指定某个测试的线程数,可以作用于类上或者方法上。

JMH 在运行前会对代码进行一定的优化,例如删去一些 Dead Code。例如下面这段代码,在 JMH 中的运行时间是差不多的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 3, time = 5)
@Fork(1)
public class JMHExample {

@Benchmark
public void test1()
{
}

@Benchmark
public void test2()
{
Math.log(Math.PI);
}

public static void main(String[] args) throws RunnerException {
final Options opts = new OptionsBuilder()
.include(JmhTestApp13_CompilerControl1.class.getSimpleName())
.build();
new Runner(opts).run();
}
}

运行结果

1
2
3
Benchmark         Mode  Cnt  Score   Error  Units
JMHExample.test1 avgt 3 0.215 ± 0.007 ns/op
JMHExample.test2 avgt 3 0.214 ± 0.022 ns/op

原因是 JMH 识别出了 test2 中的 Math.log(Math.PI) 是一段死代码,并没有任何意义,因此没有执行。@CompilerControl 可以控制 JMH 的这类行为,例如在 test2 方法前 @CompilerControl.Mode.EXCLUDE 就可以令编译器不做去掉死代码的行为,得到的结果为

1
2
3
Benchmark         Mode  Cnt   Score   Error  Units
JMHExample.test1 avgt 3 0.213 ± 0.008 ns/op
JMHExample.test2 avgt 3 44.397 ± 5.358 ns/op

可以看出,此时两个函数之间性能有了明显差距。CompilerControl.Mode 有 6 种选择,分别是

1
2
3
4
5
6
BREAK: 在生成的代码中插入断点;
PRINT:打印代码编译后的汇编代码;
EXCLUDE:将代码从编译去除(不参与编译期的优化);
INLINE:强制代码内联;
DONT_INLINE:强制代码不内联;
COMPILE_ONLY:只对这份代码做编译,不做其他事情。

参考文章

JMH - Java 代码性能基准测试

性能调优之JMH必知必会1:什么是JMH

JMH 使用指南

JMH 官方提供的用例


Java 基准测试工具:JMH
https://thumarklau.github.io/2023/04/24/jhm-introduce/
作者
MarkLau
发布于
2023年4月24日
许可协议