无比强大的JMH基准测试工具
1. JMH是什么?
JMH:即(Java Microbenchmark Harness),它是由 Java 官方团队开发的一款用于 Java 微基准测试工具。
基准测试:是指通过设计科学的测试方法、测试工具和测试系统,实现对一类测试对象的某项性能指标进行定量的和可对比的测试。
比如鲁大师、安兔兔,都是按一定的基准或者在特定条件下去测试某一对象的的性能,比如显卡、IO、CPU之类的。
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);
}