使用 JMH 做 Benchmark 基准测试

简介

JMH(Java Microbenchmark Harness)是一个由 OpenJDK 提供的 Java 微基准测试工具,用于对 Java 代码进行微基准测试。微基准测试是针对程序中的小段代码或者单个方法的性能测试,旨在对代码的性能进行准确的度量和比较。

JMH 是为了解决 Java 程序微基准测试的一些挑战而开发的,它提供了以下主要功能:

  1. 自动化的基准测试 : JMH 提供了一组注解和 API,可以方便地定义测试用例,配置测试参数,执行测试,并输出测试结果。这样可以避免手动编写基准测试代码,减少测试代码的冗余和错误。
  2. 预热阶段 : JMH 在运行基准测试之前会进行预热阶段,通过多次运行测试代码来预热 JVM 和代码缓存,以尽量达到代码在稳定状态下的性能。
  3. 统计和报告 : JMH 会对测试结果进行统计和报告,包括平均执行时间、标准差、吞吐量、延迟等性能指标,这些指标可以帮助开发人员更准确地评估代码的性能表现。
  4. 可插拔的扩展性 : JMH 提供了丰富的插件和扩展点,可以扩展和定制基准测试的功能,满足特定的测试需求。

JMH 是一个强大的基准测试工具,特别适用于对 Java 代码进行微基准测试,它可以帮助开发人员更好地了解代码的性能特性,优化程序性能,并进行准确的性能比较。 在进行性能测试时,建议使用 JMH 来进行微基准测试,以确保测试结果的准确性和可靠性。

优点

JMH(Java Microbenchmark Harness)是一个强大的 Java 微基准测试工具,它具有许多优点,使其成为进行性能测试的首选工具。以下是 JMH 的主要优点:

  1. 简单易用 : JMH 提供了注解和 API,可以方便地定义测试用例,配置测试参数,执行测试,并输出测试结果。使用 JMH 可以避免手动编写复杂的基准测试代码,减少测试代码的冗余和错误。
  2. 自动化测试 : JMH 实现了自动化的测试流程,包括预热阶段和真正的测试阶段。它会在运行基准测试之前进行预热阶段,通过多次运行测试代码来预热 JVM 和代码缓存,以尽量达到代码在稳定状态下的性能。这样可以减少测试的干扰和误差,获得更准确的性能指标。
  3. 可靠的测试结果 : JMH 会对测试结果进行统计和报告,包括平均执行时间、标准差、吞吐量、延迟等性能指标。这些指标可以帮助开发人员更准确地评估代码的性能表现,并进行性能优化。
  4. 微基准测试 : JMH 专注于对小段代码或单个方法的性能进行测试,这种微基准测试非常适合对 Java 代码的性能进行准确的度量和比较。它避免了测试过程中其他因素对结果的影响,使得测试结果更可信。
  5. 可扩展性 : JMH 提供了丰富的插件和扩展点,可以扩展和定制基准测试的功能。这样可以根据具体需求,对测试环境进行定制,满足特定的测试需求。
  6. 集成于 OpenJDK : JMH 是由 OpenJDK 提供的官方性能测试工具,因此与 Java 语言和 JVM 紧密集成,能够充分利用 JVM 的优化和特性。

综上所述,JMH 的优点包括简单易用、自动化测试、可靠的测试结果、微基准测试、可扩展性和与 Java/JVM 的紧密集成。这些优点使得 JMH 成为进行 Java 性能测试的首选工具,并能够帮助开发人员更好地了解代码的性能表现,优化程序性能,并进行准确的性能比较。

原理

JMH 是一个 jar 包,它和单元测试框架 JUnit 非常的像,可以通过注解进行一些基础配置。这部分配置有很多是可以通过 main 方法的 OptionsBuilder 进行设置的。

java-test-jmh-1

上图是一个典型的 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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package test.org.openjdk.jmh;

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.results.format.ResultFormatType;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(1)
@Threads(2)
@State(Scope.Thread)
public class BenchmarkTest {
@Benchmark
public long shift() {
long t = 455565655225562L;
long a = 0;
for (int i = 0; i < 1000; i++) {
a = t >> 30;
}
return a;
}

@Benchmark
// 加上 group, 输出由 "# Benchmark: test.org.openjdk.jmh.BenchmarkTest.div"
// 变成 "# Benchmark: test.org.openjdk.jmh.BenchmarkTest.group1"
@Group("group1")
public long div() {
long t = 455565655225562L;
long a = 0;
for (int i = 0; i < 1000; i++) {
a = t / 1024 / 1024 / 1024;
}
return a;
}

public static void main(String[] args) throws Exception {
Options opts = new OptionsBuilder()
.include(BenchmarkTest.class.getSimpleName())
.resultFormat(ResultFormatType.JSON)
.build();
new Runner(opts).run();
}
}

@BenchmarkMode

样例:

1
2
3
4
// 统计一个维度
@BenchmarkMode(Mode.Throughput)
// 统计多个维度
@BenchmarkMode({Mode.Throughput, Mode.AverageTime})
  • value: Mode[] 类型,表示基准测试类型

此注解用来指定基准测试类型,对应 Mode 选项,用来修饰类和方法都可以。 这里的 value,是一个数组,可以配置多个统计维度

所谓的模式,在 JMH 中,可以分为以下几种:

  • Throughput: 整体吞吐量,比如 QPS,单位时间内的调用量等。
  • AverageTime: 平均耗时,指的是每次执行的平均时间。如果这个值很小不好辨认,可以把统计的单位时间调小一点。
  • SampleTime: 随机取样
  • SingleShotTime: 如果你想要测试仅仅一次的性能,比如第一次初始化花了多长时间,就可以使用这个参数,其实和传统的 main 方法没有什么区别。
  • All: 所有的指标,都算一遍,你可以设置成这个参数看下效果。

我们拿吞吐量,看一下大体的执行结果:

1
2
3
4
Result "test.org.openjdk.jmh.BenchmarkTest.shift":
1060181.283 ±(99.9%) 84533.066 ops/ms [Average]
(min, avg, max) = (1037958.528, 1060181.283, 1087042.452), stdev = 21952.969
CI (99.9%): [975648.217, 1144714.350] (assumes normal distribution)

由于我们声明的时间单位是毫秒,本次 shift 方法的 QPS 就是 1060181.283 次/毫秒。

我们也可以看下最终的 QPS :

1
2
3
Benchmark             Mode  Cnt        Score       Error   Units
BenchmarkTest.div thrpt 5 1035585.045 ± 85157.739 ops/ms
BenchmarkTest.shift thrpt 5 1060181.283 ± 84533.066 ops/ms

由于是平均数,这里的 Error 值的是误差的意思(或者波动)。

@OutputTimeUnit

样例:

1
@OutputTimeUnit(TimeUnit.MILLISECONDS)
  • value: TimeUnit 类型

输出示例:

1
2
3
Benchmark             Mode  Cnt        Score       Error   Units
BenchmarkTest.div thrpt 5 1035585.045 ± 85157.739 ops/ms
BenchmarkTest.shift thrpt 5 1060181.283 ± 84533.066 ops/ms

在衡量输出指标的时候,都有一个时间维度,它就是通过 @OutputTimeUnit 注解进行配置的。

这个就比较简单了,它指明了基准测试结果的时间类型。 可用于类或者方法上。一般选择秒、毫秒、微秒,纳秒那是针对的速度非常快的方法。

OutputTimeUnit 注解同样可以修饰类或者方法,通过更改时间级别,可以获取更加易读的结果。

@Warmup

样例:

1
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)

我们不止一次提到预热, warmup 这个注解,可以用在类或者方法上,进行预热配置。 可以看到,它有几个配置参数。

  • timeUnit:时间的单位,默认的单位是秒。
  • iterations:预热阶段的迭代数。
  • time:每次预热的时间。
  • batchSize:批处理大小,指定了每次操作调用几次方法。

上面的注解,意思是对代码预热总计 3 秒(迭代 3 次,每次一秒) 。 预热过程的测试数据,是不记录测量结果的。

输出示例:

1
2
3
# Warmup Iteration   1: 770393.195 ops/ms
# Warmup Iteration 2: 849304.864 ops/ms
# Warmup Iteration 3: 1055148.024 ops/ms

@Measurement

样例:

1
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
  • timeUnit:时间的单位,默认的单位是秒。
  • iterations:预热阶段的迭代数。
  • time:每次预热的时间。
  • batchSize:批处理大小,指定了每次操作调用几次方法。

MeasurementWarmup 的参数是一样的。 但不同于预热,它指的是真正的迭代次数。

输出示例:

1
2
3
4
5
Iteration   1: 1037958.528 ops/ms
Iteration 2: 1054419.302 ops/ms
Iteration 3: 1087042.452 ops/ms
Iteration 4: 1042323.968 ops/ms
Iteration 5: 1079162.167 ops/ms

虽然经过预热之后,代码都能表现出它的最优状态,但一般和实际应用场景还是有些出入的。 如果你的测试机器性能很高,或者你的测试机资源利用已经达到了极限,都会影响测试结果的数值。 通常情况下,需要在测试的时候,给机器充足的资源,保持一个稳定的环境。 在分析结果的时候,也更加关注不同实现方式的性能差异,而不是测试数据本身。

@Fork

样例:

1
@Fork(1)
  • value: int 类型,表示使用几个进程测试

fork 的值一般设置成 1,表示只使用一个进程进行测试;如果这个数字大于 1,表示会启用新的进程进行测试;但如果设置成 0,程序依然会运行,不过这样是在用户的 JVM 进程上运行的,可以看下下面的提示,但不推荐这么做。

1
2
3
# Fork: N/A, test runs in the host VM
# *** WARNING: Non-forked runs may silently omit JVM options, mess up profilers, disable compiler hints, etc. ***
# *** WARNING: Use non-forked runs only for debugging purposes, not for actual performance runs. ***

那么 fork 到底是在进程还是线程环境里运行呢?我们追踪一下 JMH 的源码,发现每个 fork 进程是单独运行在 Proccess 进程里的,这样就可以做完全的环境隔离,避免交叉影响。它的输入输出流,通过 Socket 连接的模式,发送到我们的执行终端。

java-test-jmh-2

在这里分享一个小技巧。其实 fork 注解有一个参数叫做 jvmArgsAppend,我们可以通过它传递一些 JVM 的参数,示例:

1
@Fork(value = 3, jvmArgsAppend = {"-Xmx2048m", "-server", "-XX:+AggressiveOpts"})

在平常的测试中,也可以适当增加 fork 数,来减少测试的误差。

@Threads

样例:

1
@Fork(2)
  • value: int 类型,表示使用几个线程测试

fork是面向进程的,而Threads是面向线程的。指定了这个注解以后,将会开启并行测试。

如果配置了 Threads.MAX ,则使用和处理机器核数相同的线程数。

@Group

样例:

1
@Group("group1")
  • value: String 类型,默认值: “group”

@Group 注解只能加在方法上,用来把测试方法进行归类。 如果你单个测试文件中方法比较多,或者需要将其归类,则可以使用这个注解。

与之关联的 @GroupThreads 注解,会在这个归类的基础上,再进行一些线程方面的设置。

输出示例:

1
# Benchmark: test.org.openjdk.jmh.BenchmarkTest.group1

@State

样例:

1
@State(Scope.Thread)
  • value: Scope 类型,指定了在类中变量的作用范围,有如下 3 种值:
    • Benchmark: 表示变量的作用范围是某个基准测试类。
    • Thread: 每个线程一份副本,如果配置了 Threads 注解,则每个 Thread 都拥有一份变量,它们互不影响。
    • Group: 联系上面的 @Group 注解,在同一个 Group 里,将会共享同一个变量实例。

@Setup 和 @TearDown

样例:

1
2
@Setup(Level.Trial)
@TearDown(Level.Trial)

这两个注解,同样有一个 Level 类型的 value 值,标明了方法运行的时机,它有三个取值。

  • value: Level 类型
    • Trial: 默认的级别。 也就是 Benchmark 级别。
    • Iteration: 每次迭代都会运行。
    • Invocation: 每次方法调用都会运行,这个是粒度最细的。

和单元测试框架 JUnit 类似, @Setup 用于基准测试前的初始化动作, @TearDown 用于基准测试后的动作,来做一些全局的配置。

@Param

样例:

1
@Param({"1", "31"})
  • value: String[] 类型

@Param 注解只能修饰字段,用来测试不同的参数,对程序性能的影响。配合 @State 注解,可以同时制定这些参数的执行范围。

代码样例如下:

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
package test.org.openjdk.jmh;

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.math.BigInteger;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(1)
@State(Scope.Benchmark)
public class BenchmarkParamTest {
@Param({"1", "31"})
public int arg;
@Param({"0", "1", "4"})
public int certainty;

@Benchmark
public boolean bench() {
return BigInteger.valueOf(arg).isProbablePrime(certainty);
}

public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(BenchmarkParamTest.class.getSimpleName())
// .param("arg", "41", "42") // Use this to selectively constrain/override parameters
.build();
new Runner(opt).run();
}
}

值得注意的是,如果设置了非常多的参数,这些参数将执行多次,通常会运行很长时间。比如参数 1 有 M 个,参数 2 有 N 个,那么总共要执行 M * N 次。

执行结果示例:

1
2
3
4
5
6
7
Benchmark                 (arg)  (certainty)  Mode  Cnt    Score    Error  Units
BenchmarkParamTest.bench 1 0 avgt 5 3.320 ± 0.068 ns/op
BenchmarkParamTest.bench 1 1 avgt 5 5.166 ± 0.326 ns/op
BenchmarkParamTest.bench 1 4 avgt 5 5.092 ± 0.064 ns/op
BenchmarkParamTest.bench 31 0 avgt 5 5.769 ± 0.501 ns/op
BenchmarkParamTest.bench 31 1 avgt 5 517.943 ± 54.459 ns/op
BenchmarkParamTest.bench 31 4 avgt 5 964.887 ± 43.358 ns/op

@CompilerControl

样例:

1
@CompilerControl(CompilerControl.Mode.INLINE)
  • value: CompilerControl.Mode 类型

这可以说是一个非常有用的功能了。

Java 中方法调用的开销是比较大的,尤其是在调用量非常大的情况下。拿简单的 getter/setter 方法来说,这种方法在 Java 代码中大量存在。我们在访问的时候,就需要创建相应的栈帧,访问到需要的字段后,再弹出栈帧,恢复原程序的执行。

如果能够把这些对象的访问和操作,纳入到目标方法的调用范围之内,就少了一次方法调用,速度就能得到提升,这就是方法内联的概念。 代码经过 JIT 编译之后,效率会有大的提升,如图所示:

java-test-jmh-3

这个注解可以用在类或者方法上,能够控制方法的编译行为,常用的有 3 种模式。

  • INLINE : 强制使用内联
  • DONT_INLINE : 禁止使用内联
  • EXCLUDE : 禁止方法编译

更多模式见源码:

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
/**
* Compilation mode.
*/
enum Mode {
/**
* Insert the breakpoint into the generated compiled code.
*/
BREAK("break"),
/**
* Print the method and it's profile.
*/
PRINT("print"),
/**
* Exclude the method from the compilation.
*/
EXCLUDE("exclude"),
/**
* Force inline.
*/
INLINE("inline"),
/**
* Force skip inline.
*/
DONT_INLINE("dontinline"),
/**
* Compile only this method, and nothing else.
*/
COMPILE_ONLY("compileonly")
}

将结果图形化

使用 JMH 测试的结果,可以二次加工,进行图形化展示。结合图表数据,更加直观。通过运行时,指定输出的格式文件,即可获得相应格式的性能测试结果。

比如下面这行代码,就是指定输出 JSON 格式的数据。

1
2
3
Options opts = new OptionsBuilder()
.resultFormat(ResultFormatType.JSON)
.build();

JMH 支持以下 5 种格式的结果:

  • TEXT 导出文本文件。
  • CSV 导出 csv 格式文件。
  • SCSV 导出 scsv 等格式的文件。
  • JSON 导出成 json 文件。
  • LATEX 导出到 latex,一种基于 ΤΕΧ 的排版系统。

一般来说,我们导出成 CSV 文件,直接在 Excel 中操作,生成相应的图形就可以了。

另外介绍几个可以做图的工具:

  1. JMH Visualizer
    通过导出 json 文件,上传之后,可得到简单的统计结果。 个人认为它的展示方式并不是很好。
  2. JMH Visual Chart
    相比较而言,这个工具就相对直观一些。
  3. meta-chart
    一个通用的在线图表生成器。

像 Jenkins 等一些持续集成工具,也提供了相应的插件,用来直接显示这些测试结果。

参考资料

  1. 顶级 Java 才懂的,基准测试 JMH!