使用JMH做Java微基准测试
背景
在使用Java编程过程中,我们对于一些代码调用的细节有多种编写方式,但是不确定它们性能时,往往采用重复多次计数的方式来解决。但是随着JVM不断的进化,随着代码执行次数的增加,JVM会不断的进行编译优化,使得重复多少次才能够得到一个稳定的测试结果变得让人疑惑,这时候有经验的同学就会在测试执行前先循环上万次并注释为预热。
没错!这样做确实可以获得一个偏向正确的测试结果,但是我们试想如果每到需要斟酌性能的时候,都要根据场景写一段预热的逻辑吗?当预热完成后,需要多少次迭代来进行正式内容的测量呢?每次测试结果的输出报告是不是都需要用System.out来输出呢?
其实这些工作都可以交给 JMH (the Java Microbenchmark Harness) ,它被作为Java9的一部分来发布,但是我们完全不需要等待Java9,而可以方便的使用它来简化我们测试,它能够照看好JVM的预热、代码优化,让你的测试过程变得更加简单。
使用
- 首先在项目中新增依赖,jmh-core以及jmh-generator-annprocess的依赖可以在maven仓库中找寻最新版本。
1
2
3
4
5
6
7
8
9
10<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.19</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.19</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
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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94package com.dxy.keygen.core;
import com.dxy.keygen.utils.KeyGeneratorUtils;
import com.dxy.keygen.utils.OrderNoGeneratorUtils;
import org.junit.Test;
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;
/**
* 基准测试
* @author: peijiepang
* @date 2018-12-07
* @Description:
*/
public class BenchmarkTest {
/**
* 基准测试类
* @throws RunnerException
*/
public void benchmarkTest() throws RunnerException {
Options opt = new OptionsBuilder()
.include("defaultKeyGeneratorBenchmarkTest")
.include("DefaultGeneratorOrderNoBenchmarkTest")
.include("TimestampGeneratorOrderNoBenchmarkTest")
.warmupIterations(5)//预热做5轮
.measurementIterations(10)//正式计量测试做10轮
.forks(3)//做3轮测试
.build();
new Runner(opt).run();
}
/**
* 分布式主键生成基准测试
*/
public void defaultKeyGeneratorBenchmarkTest() {
KeyGeneratorUtils.generateKey();
}
/**
* 默认订单号生成基准测试
*/
public static class DefaultGeneratorOrderNoBenchmarkTest{
/**
* 订单号生成init
*/
public void init() {
OrderNoGeneratorUtils.init(OrderNoGeneratorUtils.OrderNoGeneratorEnum.DEFAULT);
System.out.println("init");
}
/**
* 分布式主键生成基准测试
*/
public void orderNoGeneratorBenchmarkTest() {
OrderNoGeneratorUtils.generateOrderNo();
}
}
/**
* 时间戳订单号生成基准测试
*/
public static class TimestampGeneratorOrderNoBenchmarkTest{
/**
* 订单号生成init
*/
public void init() {
OrderNoGeneratorUtils.init(OrderNoGeneratorUtils.OrderNoGeneratorEnum.TIMESTAMP);
System.out.println("init");
}
/**
* 分布式主键生成基准测试
*/
public void orderNoGeneratorBenchmarkTest() {
OrderNoGeneratorUtils.generateOrderNo();
}
}
} - 基准测试结果如下
1
2
3
4
5
6
7
8
9# Run complete. Total time: 00:06:32
Benchmark Mode Cnt Score Error Units
BenchmarkTest.DefaultGeneratorOrderNoBenchmarkTest.orderNoGeneratorBenchmarkTest thrpt 30 8612.597 ± 1864.319 ops/s
BenchmarkTest.TimestampGeneratorOrderNoBenchmarkTest.orderNoGeneratorBenchmarkTest thrpt 30 8899.778 ± 2066.678 ops/s
BenchmarkTest.defaultKeyGeneratorBenchmarkTest thrpt 30 1024568.969 ± 146.663 ops/s
BenchmarkTest.DefaultGeneratorOrderNoBenchmarkTest.orderNoGeneratorBenchmarkTest avgt 30 ≈ 10⁻⁴ s/op
BenchmarkTest.TimestampGeneratorOrderNoBenchmarkTest.orderNoGeneratorBenchmarkTest avgt 30 ≈ 10⁻⁴ s/op
BenchmarkTest.defaultKeyGeneratorBenchmarkTest avgt 30 ≈ 10⁻⁶ s/op
注解介绍
好了,当你对JMH有了一个基本认识后,现在来详细解释一下前面代码中的各个注解含义。
@BenchmarkMode
基准测试类型。这里选择的是Throughput也就是吞吐量。根据源码点进去,每种类型后面都有对应的解释,比较好理解,吞吐量会得到单位时间内可以进行的操作数。
- Throughput: 整体吞吐量,例如“1秒内可以执行多少次调用”。
- AverageTime: 调用的平均时间,例如“每次调用平均耗时xxx毫秒”。
- SampleTime: 随机取样,最后输出取样结果的分布,例如“99%的调用在xxx毫秒以内,99.99%的调用在xxx毫秒以内”
- SingleShotTime: 以上模式都是默认一次 iteration 是 1s,唯有 SingleShotTime 是只运行一次。往往同时把 warmup 次数设为0,用于测试冷启动时的性能。
- All(“all”, “All benchmark modes”);
@Warmup
上面我们提到了,进行基准测试前需要进行预热。一般我们前几次进行程序测试的时候都会比较慢, 所以要让程序进行几轮预热,保证测试的准确性。其中的参数iterations也就非常好理解了,就是预热轮数。
为什么需要预热?因为 JVM 的 JIT 机制的存在,如果某个函数被调用多次之后,JVM 会尝试将其编译成为机器码从而提高执行速度。所以为了让 benchmark 的结果更加接近真实情况就需要进行预热。
@Measurement
度量,其实就是一些基本的测试参数。
- iterations 进行测试的轮次
- time 每轮进行的时长
- timeUnit 时长单位
都是一些基本的参数,可以根据具体情况调整。一般比较重的东西可以进行大量的测试,放到服务器上运行。
@Threads
每个进程中的测试线程,这个非常好理解,根据具体情况选择,一般为cpu乘以2。
@Fork
进行 fork 的次数。如果 fork 数是2的话,则 JMH 会 fork 出两个进程来进行测试。
@OutputTimeUnit
这个比较简单了,基准测试结果的时间类型。一般选择秒、毫秒、微秒。
@Benchmark
方法级注解,表示该方法是需要进行 benchmark 的对象,用法和 JUnit 的 @Test 类似。
@Param
属性级注解,@Param 可以用来指定某项参数的多种情况。特别适合用来测试一个函数在不同的参数输入的情况下的性能。
@Setup
方法级注解,这个注解的作用就是我们需要在测试之前进行一些准备工作,比如对一些数据的初始化之类的。
@TearDown
方法级注解,这个注解的作用就是我们需要在测试之后进行一些结束工作,比如关闭线程池,数据库连接等的,主要用于资源的回收等。
@State
当使用@Setup参数的时候,必须在类上加这个参数,不然会提示无法运行。
State 用于声明某个类是一个“状态”,然后接受一个 Scope 参数用来表示该状态的共享范围。 因为很多 benchmark 会需要一些表示状态的类,JMH 允许你把这些类以依赖注入的方式注入到 benchmark 函数里。Scope 主要分为三种。
- Thread: 该状态为每个线程独享。
- Group: 该状态为同一个组里面所有线程共享。
- Benchmark: 该状态在所有线程间共享。