0. 引言
在开发工作中常常会遇到对代码运行速度调优的需求。如何科学评价调优的成果?则需要准确计量一个方法运行速度的快慢 -- 科学的做法是进行微基准测试,从而得出量化的结果。JMH 是由 OpenJDK 提供的对 Java 语言程序进行基准测试的工具。本文将介绍的基本用法和一个实用示例。
相关概念
- BenchMark:又叫做基准测试,主要用来测试一些方法的性能,可以根据不同的参数以不同的单位进行计算(例如可以使用吞吐量为单位,也可以使用平均时间作为单位,在 BenchmarkMode 里面进行调整)。
- Micro Benchmark:简单地说就是在
method
层面上的 benchmark,精度可以精确到微秒级。 - OPS, Opeartion Per Second: 每秒操作量,是衡量性能的重要指标,数值越大,性能越好。类似的有 TPS, QPS
- Throughput 吞吐率:
- Warmup 预热:为什么需要预热?因为 JVM 的 JIT 机制的存在,如果某个函数被调用多次之后,JVM 会尝试将其编译成为机器码从而提高执行速度。程序实际运行中会收到 JVM 的自动优化,为了让 Benchmark 的结果更加接近真实情况就需要进行预热。
1. 什么是 JMH ?
JMH 全称 Java Microbenchmark Harness,是用于构建、运行和分析以 Java 和其他基于 JVM 的其他语言编写的 nano/micro/milli/macro 基准测试的 Java 工具。
JMH 官方介绍:“JMH is a Java harness for building, running, and analysing nano/micro/milli/macro benchmarks written in Java and other languages targetting the JVM.”
2. JMH 能做什么?
功能介绍
"If you cannot measure it, you cannot improve it".
JMH 的作用是度量(measure)某个方法的执行耗时,可以通过执行 JMH 测试得出方法执行耗时的量化结果。
使用场景
JMH 适用范围示例:
- a. 度量某个方法执行耗时
- b. 度量某个方法执行时间和输入 n 的相关性
- c. 评估一个方法的多种不同实现性能表现
- d. 评估应用中调用的第三方库 API 的执行性能
- e. b&c 综合应用
JMH 实际应用示例:
- 评估 ArrayList 遍历性能与输入 n 的相关性
- 比较 ArrayList 和 LinkedList 遍历性能与输入 n 的相关性,并比较差异
- 评估 redis-client Java 库 put 方法的性能
- 比较实现求和的两种方法在 N 次输入下的性能差异,方法 methodSumA 使用了 Stream API,方法 methodSumB 使用了传统遍历累加,需要测试两种方法在不同数据量输入时的表现性能线性变化。通过 JMH 测试,可以对不同量级数据输入时如何选择适宜的求和实现起到指导作用。
3. 如何使用
以比较 for 循环实现求和与 Stream API 实现求和的方法,在输入数据量级分为别为 10000, 100000, 1000000, 10000000 时求和的性能为例,介绍如何使用 JMH 完成这一测试。
测试程序执行过程描述伪代码:
forLoopMethod() {
loop (size in (10000, 100000, 1000000, 10000000)) {//遍历不同输入数量
doSumFromZeroTo(size);//以 for 循环方式累加 0~size 求和
}
}
streamMethod() {
loop (size in (10000, 100000, 1000000, 10000000)) {//遍历不同输入数量
doSumFromZeroTo(size);//以 stream sum API 方式累加 0~size 求和
}
}
3.1. 创建工程
创建工程
以 Maven 构建的工程为例
使用的 JDK 版本为 1.8
添加以下 dependency 节点向工程中引入依赖
<properties>
<!-- 尽量选择最新版本 -->
<jmh.version>1.28</jmh.version>
</properties>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
3.2. 参数说明
运行 JMH 前须进行一定的设置,JMH 的设置项参数可以通过
new Runner(org.openjdk.jmh.runner.options.Options)
方式注入。JMH 实现了JSR269规范,即注解处理器,能在编译Java源码的时候,识别的到需要处理的注解,如@Beanmark,JMH能根据@Beanmark的配置生成一系列测试辅助类。因此也可以通过注解方式注入设置项参数。
以下以注解为例介绍每个设置项参数的作用。
@BenchmarkMode
Mode 表示 JMH 进行 Benchmark 时所使用的模式。通常是测量的维度不同,或是测量的方式不同。目前 JMH 共有四种模式:
- Throughput: 整体吞吐量,例如“1秒内可以执行多少次调用”,单位是操作数/时间。
- AverageTime: 调用的平均时间,例如“每次调用平均耗时xxx毫秒”,单位是时间/操作数。
- SampleTime: 随机取样,最后输出取样结果的分布,例如“99%的调用在xxx毫秒以内,99.99%的调用在xxx毫秒以内”。
- SingleShotTime: 以上模式都是默认一次 iteration 是 1s,唯有 SingleShotTime 是只运行一次。往往同时把 warmup 次数设为0,用于测试冷启动时的性能。
@OutputTimeUnit
输出的时间单位。
@Iteration
Iteration 是 JMH 进行测试的最小单位。在大部分模式下,一次 iteration 代表的是一秒,JMH 会在这一秒内不断调用需要 Benchmark 的方法,然后根据模式对其采样,计算吞吐量,计算平均执行时间等。
@WarmUp
Warmup 是指在实际进行 Benchmark 前先进行预热的行为。
为什么需要预热?因为 JVM 的 JIT 机制的存在,如果某个函数被调用多次之后,JVM 会尝试将其编译成为机器码从而提高执行速度。为了让 Benchmark 的结果更加接近真实情况就需要进行预热。
@State
类注解,JMH 测试类必须使用 @State 注解,它定义了一个类实例的生命周期,可以类比 Spring Bean 的 Scope。由于 JMH 允许多线程同时执行测试,不同的选项含义如下:
- Scope.Thread:默认的 State,每个测试线程分配一个实例;
- Scope.Benchmark:所有测试线程共享一个实例,用于测试有状态实例在多线程共享下的性能;
- Scope.Group:每个线程组共享一个实例;
@Fork
进行 fork 的次数。如果 fork 数是2的话,则 JMH 会 fork 出两个进程来进行测试。
@Meansurement
提供真正的测试阶段参数。指定迭代的次数,每次迭代的运行时间和每次迭代测试调用的数量(通常使用 @BenchmarkMode(Mode.SingleShotTime) 测试一组操作的开销——而不使用循环)
@Setup
方法注解,会在执行 benchmark 之前被执行,正如其名,主要用于初始化。
@TearDown
方法注解,与@Setup 相对的,会在所有 benchmark 执行结束以后执行,主要用于资源的回收等。
@Setup/@TearDown注解使用Level参数来指定何时调用fixture:
名称 描述 Level.Trial 默认level。全部benchmark运行(一组迭代)之前/之后 Level.Iteration 一次迭代之前/之后(一组调用) Level.Invocation 每个方法调用之前/之后(不推荐使用,除非你清楚这样做的目的) @Benchmark
方法注解,表示该方法是需要进行 benchmark 的对象。
@Param
成员注解,可以用来指定某项参数的多种情况。特别适合用来测试一个函数在不同的参数输入的情况下的性能。@Param 注解接收一个String数组,在 @Setup 方法执行前转化为为对应的数据类型。多个 @Param 注解的成员之间是乘积关系,譬如有两个用 @Param 注解的字段,第一个有5个值,第二个字段有2个值,那么每个测试方法会跑5*2=10次。
3.3. 编写测试类
包含 main 方法的类和测试内容封装类
- 包含 main 方法的类
TestsMain
package org.example.jmh;
import org.example.jmh.tests.IntegerSumTests;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.results.format.ResultFormatType;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertTrue;
/**
* OptionsTests
* created at 2021/4/25
*
* @author weny
* @since 1.0.0
*/
public class TestsMain {
//生成的文件路径:{工程根目录}/{reportFileDir}/{XXX.class.getSimpleName()}.json
// e.g. jmh-reports/EmptyMethod.json
private static final String reportFileDir = "jmh-reports/";
// private static final String reportPath = "sample-options-result.json";//生成的文件在工程根目录
/*
* ============================== HOW TO RUN THIS TEST: ====================================
* 1. 修改 Class<IntegerSumTests> targetClazz = IntegerSumTests.class;//需要运行 JMH 测试的类
* 2. 在 IDE 中运行 main 方法
*/
public static void main(String[] args) throws RunnerException {
Class<IntegerSumTests> targetClazz = IntegerSumTests.class;//需要运行 JMH 测试的类
String reportFilePath = setupStandardOptions(targetClazz);
assertTrue(Files.exists(Paths.get(reportFilePath)));
}
/**
* 最基础的配置,目的是以最短的耗时测试 JMH 是否可以正常运行
*
* @param targetClazz 要运行 JMH 测试的类
* @throws RunnerException See:{@link RunnerException}
*/
@SuppressWarnings({"unused"})
private static String setupBasicOptions(Class<?> targetClazz) throws RunnerException {
// number of iterations is kept to a minimum just to verify that the benchmarks work without spending extra
// time during builds.
String reportFilePath = resolvePath(targetClazz);
ChainedOptionsBuilder optionsBuilder =
new OptionsBuilder()
.include(targetClazz.getSimpleName())
.forks(1)
.warmupIterations(0)
.measurementBatchSize(1)
.measurementIterations(1)
.shouldFailOnError(true)
.result(reportFilePath)
.timeUnit(TimeUnit.MICROSECONDS)
.resultFormat(ResultFormatType.JSON);
new Runner(optionsBuilder.build()).run();
return reportFilePath;
}
/**
* 一份标准的配置,根据实际需求配置预热和迭代等参数
*
* @param targetClazz 要运行 JMH 测试的类
* @throws RunnerException See:{@link RunnerException}
*/
private static String setupStandardOptions(Class<?> targetClazz) throws RunnerException {
String reportFilePath = resolvePath(targetClazz);
ChainedOptionsBuilder optionsBuilder =
new OptionsBuilder()
.include(targetClazz.getSimpleName())
.mode(Mode.Throughput)//模式-吞吐量 | 注解方式 @BenchmarkMode(Mode.Throughput)
.forks(1)//Fork进行的数目 | 注解方式 @Fork(2)
.warmupIterations(1)//预热轮数 | 注解方式 @Warmup(iterations = 1)
.measurementIterations(3)//度量轮数 | 注解方式 @Measurement(iterations = 3)
.timeUnit(TimeUnit.MICROSECONDS)//结果所使用的时间单位 | 注解方式 @OutputTimeUnit(TimeUnit.MILLISECONDS)
.shouldFailOnError(true)
.result(reportFilePath)//结果报告文件输出路径
.resultFormat(ResultFormatType.JSON);//结果报告文件输出格式 JSON
new Runner(optionsBuilder.build()).run();
return reportFilePath;
}
private static String resolvePath(Class<?> targetClazz) {
return reportFileDir + targetClazz.getSimpleName() + ".json";
}
}
- 测试内容封装类
EmptyMethod
包含空的方法,用于测试配置是否可以正常运行
package org.example.jmh.tests;
import org.openjdk.jmh.annotations.Benchmark;
/**
* EmptyMethod
* created at 2021/4/25
*
* @author weny
* @since 1.0.0
*/
public class EmptyMethod {
@Benchmark
public void hello() {
// this method was intentionally left blank.
}
}
- 测试内容封装类
IntegerSumTests
包含待度量评估的方法streamSummingInt
,forEachPlus
package org.example.jmh.tests;
import org.openjdk.jmh.annotations.*;
import java.util.Arrays;
import java.util.stream.IntStream;
/**
* IntegerSumTests
* created at 2021/4/25
*
* @author weny
* @since 1.0.0
*/
public class IntegerSumTests {
// Implementation using stream summingInt
@Benchmark
public int streamSummingInt(Params params) {
return Arrays.stream(params.items).sum();
}
// Implementation using forEach
@Benchmark
public int forEachPlus(Params params) {
int res = 0;
for (int item : params.items) {
res += item;
}
return res;
}
// Define benchmarks parameters with @State
@State(Scope.Benchmark)
public static class Params {
// Run with given size parameters of
// @Param({"1000", "10000", "100000", "1000000"})
@Param({"10000", "100000", "1000000", "10000000"})
public int size;
// Items to run benchmark on
public int[] items;
// Setup test data, will be run once and will not affect our results
@Setup
public void setUp() {
items = IntStream.range(0, size).toArray();
}
}
}
3.4. 运行测试
运行测试得出结果数据
运行Main 方法org.example.jmh.TestsMain#main
3.5. 结果分析
对测试结果数据项进行分析
运行开始 - 参数打印
# JMH version: 1.28
# VM version: JDK 1.8.0_162, Java HotSpot(TM) 64-Bit Server VM, 25.162-b12
# VM invoker: /Library/Java/JavaVirtualMachines/jdk1.8.0_162.jdk/Contents/Home/jre/bin/java
# VM options: -javaagent:/Applications/IntelliJ IDEA.app/Contents/lib/idea_rt.jar=58962:/Applications/IntelliJ IDEA.app/Contents/bin -Dfile.encoding=UTF-8
# Blackhole mode: full + dont-inline hint
# Warmup: 1 iterations, 10 s each ------------------------------ 预热5个迭代,每个迭代10s
# Measurement: 3 iterations, 10 s each --------------------------- 正式测试5个迭代,每个迭代10s
# Timeout: 10 min per iteration ---------------------------------- 每个迭代的超时时间10min
# Threads: 1 thread, will synchronize iterations ----------------- 使用1个线程测试
# Benchmark mode: Throughput, ops/time --------------------------- 使用吞吐量作为测试指标
# Benchmark: org.example.jmh.tests.IntegerSumTests.forEachPlus --- 本次迭代测试的目标方法名
# Parameters: (size = 10000) ------------------------------------- 本次迭代注入的参数值
运行中 - 阶段信息打印
# Run progress: 12.50% complete, ETA 00:04:47 ---------------------- 运行进度 12.50%
# Fork: 1 of 1
# Warmup Iteration 1: 32206.476 ops/s
Iteration 1: 32631.226 ops/s
Iteration 2: 32725.618 ops/s
Iteration 3: 32681.244 ops/s
Result "org.example.jmh.tests.IntegerSumTests.forEachPlus": -------- 阶段结果统计
32679.362 ±(99.9%) 861.539 ops/s [Average]
(min, avg, max) = (32631.226, 32679.362, 32725.618), stdev = 47.224
CI (99.9%): [31817.824, 33540.901] (assumes normal distribution)
# 统计结果给出了多次测试后的最小值,最大值和均值,以及标准差 (stdev),置信区间(CI,Confidence interval)
# 标准差(stdev)反映了数值相对于平均值得离散程度,置信区间是指由样本统计量所构造的总体参数的估计区间。在统计学中,一个概率样本的置信区间(Confidence interval)是对这个样本的某个总体参数的区间估计
运行结束 - 结果打印
# Run complete. Total time: 00:05:26
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 (size) Mode Cnt Score Error Units
IntegerSumTests.forEachPlus 10000 thrpt 3 328440.729 ± 92008.121 ops/s
IntegerSumTests.forEachPlus 100000 thrpt 3 32679.362 ± 861.539 ops/s
IntegerSumTests.forEachPlus 1000000 thrpt 3 2796.351 ± 3273.468 ops/s
IntegerSumTests.forEachPlus 10000000 thrpt 3 218.404 ± 36.500 ops/s
IntegerSumTests.streamSummingInt 10000 thrpt 3 50423.644 ± 47232.940 ops/s
IntegerSumTests.streamSummingInt 100000 thrpt 3 7521.114 ± 47042.316 ops/s
IntegerSumTests.streamSummingInt 1000000 thrpt 3 480.979 ± 112.349 ops/s
IntegerSumTests.streamSummingInt 10000000 thrpt 3 132.339 ± 1514.091 ops/s
Benchmark result is saved to jmh-reports/IntegerSumTests.json
Process finished with exit code 0
# Benchmark 列表示这次测试对比的方法。
# Mode 列表上结果的统计纬度。
# Cnt 列表示采样次数,Cnt=Fork*Iteration.
# Score 是对这次评测的打分,对于输入数据量 size=10000 时 forEachPlus 操作数为 328440.729 ops/s,streamSummingInt 时 操作数为 50423.644 ops/s, 意味着 size=10000 时 for 循环求和性能优于 stream.sum() 求和。
# Error 这里表示性能统计上的误差,我们不需要关心这个数据,主要查看 Score
3.6. 结果报告可视化
将结果生成可视化的报表
可选方式:
一、将结果 json 文件上传至 JMH Visualizer 自动生成报表,参考:JMH Visualizer
二、将结果通过第三方报表组件自定义呈现,此处不作展开。
4. 注意事项
4.1. 需要考虑到虚拟机的优化
编写 JHM 代码,需要考虑到虚拟机的优化,而使得测试失真,如下 measureWrong 代码就是所谓的 Dead-Code
代码
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class JMHSample_08_DeadCode {
private double x = Math.PI;
@Benchmark
public void baseline() {
//基准
}
@Benchmark
public void measureWrong() {
//虚拟机会优化掉这部分,性能同baseline
Math.log(x);
}
@Benchmark
public double measureRight() {
// 真正的性能测试
return Math.log(x);
}
}
测试结果如下
Benchmark Mode Score Units
c.i.c.c.c.i.c.c.j.JMHSample_08_DeadCode.baseline avgt 0.358 ns/op
c.i.c.c.c.i.c.c.j.JMHSample_08_DeadCode.measureRight avgt 24.605 ns/op
c.i.c.c.c.i.c.c.j.JMHSample_08_DeadCode.measureWrong avgt 0.366 ns/op
在测试 measureWrong 方法,JIT 能推测出方法体可以被优化调而不影响系统,measureRight 因为定义了返回值,JIT 不会优化。
4.2. 常量折叠
关于常量折叠,JIT 认为方法计算结果为常量,从而优化直接返回常量给调用者
private double x = Math.PI;
private final double wrongX = Math.PI;
@Benchmark
public double baseline() {
// 基准测试
return Math.PI;
}
@Benchmark
public double measureWrong_1() {
// JIT认为是个常量
return Math.log(Math.PI);
}
@Benchmark
public double measureWrong_2() {
// JIT认为方法调用结果是个常量.
return Math.log(wrongX);
}
@Benchmark
public double measureRight() {
// 正确的测试
return Math.log(x);
}
如下是测试结果
Benchmark Mode Score Units
c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.baseline avgt 1.175 ns/op
c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.measureRight avgt 25.805 ns/op
c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.measureWrong_1 avgt 1.116 ns/op
c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.measureWrong_2 avgt 1.031 ns/op
考虑到 inline 对性能影响很大,JMH 支持 @CompilerControl 来控制是否允许内联
public class Inline {
int x=0,y=0;
@Benchmark
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public int add(){
return dataAdd(x,y);
}
@Benchmark
public int addInline(){
return dataAdd(x,y);
}
private int dataAdd(int x,int y){
return x+y;
}
@Setup
public void init() {
x = 1;
y = 2;
}
}
add 和 addInline 方法都会调用 dataAdd 方法,前者使用 CompilerControl 类,可以用在方法或者类上,来提供编译选项
- DONT_INLINE,调用方法不内联
- INLINE,调用方法内联
- BREAK,插入一个调试断点(TODO,如何调试,参考11章)
- PRINT,打印方法被 JIT 编译后的机器码信息
开发人员可能觉得上面的测试,add 方法太简单,会习惯性的在 add 方法里方一个循环,以减少 JMH 调用 add 方法的成本。JMH 不建议这么做,因为 JIT 会实际上对这种循环会做优化,以消除循环调用成本。如下是个例子可以看到循环测试结果不准确
int x = 1;
int y = 2;
/** 正确测试
*/
@Benchmark
public int measureRight() {
return (x + y);
}
private int reps(int reps) {
int s = 0;
for (int i = 0; i < reps; i++) {
s += (x + y);
}
return s;
}
@Benchmark
@OperationsPerInvocation(1)
public int measureWrong_1() {
return reps(1);
}
@Benchmark
@OperationsPerInvocation(10)
public int measureWrong_10() {
return reps(10);
}
@Benchmark
@OperationsPerInvocation(100)
public int measureWrong_100() {
return reps(100);
}
@Benchmark
@OperationsPerInvocation(1000)
public int measureWrong_1000() {
return reps(1000);
}
注解 OperationsPerInvocation 告诉JMH统计性能的时候需要做修正,比如 @OperationsPerInvocation(10) 调用了10次。
性能测试结果如下
编写性能测试的一个好习惯是先编写一个单元测试用例,以确保性能测试准确性,x Benchmark Mode Score Units c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureRight avgt 1.114 ns/op c.i.c.c.c.i.c.c.j.JMHSample_11_oops.measureWrong_1 avgt 1.057 ns/op c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureWrong_10 avgt 0.139 ns/op c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureWrong_100 avgt 0.018 ns/op c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureWrong_1000 avgt 0.035 ns/op java
5. 结语
通过 JMH 测试,可以和科学的评估方法的执行耗时,评估结果可以对性能调优、算法性能预测、服务器基础设施容量规划等行为起到指导作用。量化的结果更有说服力,对结果进一步可视化,将更加直观。
6. 扩展阅读
- 官方的 Code Sample 写得浅显易懂,推荐在需要详细了解 JMH 的用法时可以通读一遍。
- 如果使用 IDEA,IntelliJ IDEA 有 JMH 的插件,提供 benchmark 方法的自动生成等便利功能。
- RPC benchmark实现:java rpc benchmark https://github.com/hank-whu/r...
- Oracle articles 对 JMH 陷阱的描述:https://www.oracle.com/techni...
7. 参考
Reference List:
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。