在学习完 Java 的单元测试后,趁热打铁,作为有追求的程序开发人员,不顺便再学个基准测试、性能测试吗?
必须安排!
在 Java 的依赖库中,有个大名鼎鼎的 JMH(Java Microbenchmark Harness),是由 Java虚拟机团队开发的 Java 基准测试工具。
在 JMH 中,正如 单元测试框架 JUnit 一样,我们也可以通过大量的注解来进行一定的配置,一个典型的 JMH 程序执行如下图所示[2]:
也即,通过开启多个进程,多个线程,先执行预热,然后执行迭代,最后汇总所有的测试数据进行分析,这就是 JMH 的执行流程,听起来是不是不难理解。
1.示例
学习新技能通常先通过一个 case 来帮准我们怎么用,有什么结果,这里我们通过改写官方的一个 sample 来看看。
java
package org.example;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Warmup;
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.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
@Warmup(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS)
public class HelloWorldBenchmark {
private static int num = 0;
@Benchmark
public void helloWorld() {
++num;
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(HelloWorldBenchmark.class.getSimpleName())
.forks(1)
.result("helloWorld.json")
.resultFormat(ResultFormatType.JSON)
.build();
new Runner(opt).run();
}
}
示例很简单,就是简单对 static 变量做自增,最后将结果输出到 json 文件中,下面是运行结果:
bash
# JMH version: 1.23
# VM version: JDK 21, Java HotSpot(TM) 64-Bit Server VM, 21+35-LTS-2513
# VM invoker: C:\Program Files\Java\jdk-21\bin\java.exe
# VM options: -javaagent:D:\chromedownload\ideaIC-2023.2.3.win\lib\idea_rt.jar=55507:D:\chromedownload\ideaIC-2023.2.3.win\bin -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8
# Warmup: 1 iterations, 1 s each
# Measurement: 2 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: org.example.HelloWorldBenchmark.helloWorld
# Run progress: 0.00% complete, ETA 00:00:03
# Fork: 1 of 1
# Warmup Iteration 1: 1659899825.872 ops/s
Iteration 1: 1646745186.884 ops/s
Iteration 2: 1681125023.980 ops/s
Result "org.example.HelloWorldBenchmark.helloWorld":
1663935105.432 ops/s
# Run complete. Total time: 00:00:03
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
HelloWorldBenchmark.helloWorld thrpt 2 1663935105.432 ops/s
Benchmark result is saved to helloWorld.json
通过简单的设置,我们在基准测试中可以看到多次测试的每秒吞吐量,最后结果输出到 helloWorldjson 文件:
css
[ { "jmhVersion" : "1.23", "benchmark" : "org.example.HelloWorldBenchmark.helloWorld", "mode" : "thrpt", "threads" : 1, "forks" : 1, "jvm" : "C:\Program Files\Java\jdk-21\bin\java.exe", "jvmArgs" : [ "-javaagent:D:\chromedownload\ideaIC-2023.2.3.win\lib\idea_rt.jar=55507:D:\chromedownload\ideaIC-2023.2.3.win\bin", "-Dfile.encoding=UTF-8", "-Dsun.stdout.encoding=UTF-8", "-Dsun.stderr.encoding=UTF-8" ],
"jdkVersion" : "21",
"vmName" : "Java HotSpot(TM) 64-Bit Server VM",
"vmVersion" : "21+35-LTS-2513",
"warmupIterations" : 1,
"warmupTime" : "1 s",
"warmupBatchSize" : 1,
"measurementIterations" : 2,
"measurementTime" : "1 s",
"measurementBatchSize" : 1,
"primaryMetric" : {
"score" : 1.6639351054317546E9,
"scoreError" : "NaN",
"scoreConfidence" : [
"NaN",
"NaN"
],
"scorePercentiles" : {
"0.0" : 1.6467451868835843E9,
"50.0" : 1.6639351054317546E9,
"90.0" : 1.6811250239799252E9,
"95.0" : 1.6811250239799252E9,
"99.0" : 1.6811250239799252E9,
"99.9" : 1.6811250239799252E9,
"99.99" : 1.6811250239799252E9,
"99.999" : 1.6811250239799252E9,
"99.9999" : 1.6811250239799252E9,
"100.0" : 1.6811250239799252E9
},
"scoreUnit" : "ops/s",
"rawData" : [
[
1.6467451868835843E9,
1.6811250239799252E9
]
]
},
"secondaryMetrics" : {
}
}
]
看完怎么用,接下来看看在项目中注意的点和值得注意的参数注解。
2.JMH的使用
引入依赖
由于这不是标准库有的依赖,所以这里我们依然用 Maven 管理依赖,在我们构建的 Maven 项目中的 pom.xml 添加下列依赖:
xml
<dependencies>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.23</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.23</version>
</dependency>
接下来看看代码应用。
代码示例基于参考编写[2]:
java
package org.example;
import org.openjdk.jmh.annotations.*;
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.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(1)
@Threads(2)
public class MyBenchmarkTest {
@Benchmark
public long shift() {
long t = 455565655225562L;
long a = 0;
for (int i = 0; i < 1000; i++) {
a = t >> 30;
}
return a;
}
@Benchmark
public long div() {
long t = Long.MAX_VALUE;
long a = 0;
for (int i = 0; i < 1000; i++) {
a = t / 1024 / 1024 / 1024;
}
return a;
}
public static void main(String[] args) throws RunnerException {
Options opts = new OptionsBuilder()
.include(MyBenchmarkTest.class.getSimpleName())
.result("MyBenchmarkTest.json")
.resultFormat(ResultFormatType.JSON)
.build();
new Runner(opts).run();
}
}
在示例中,其实我们的目的就是测试 移位和整除 两个方法的性能,看看每秒的吞吐量如何,最后将结果汇总在 MyBenchmarkTest.json 文件中,当然运行测试,我们也可以在控制台得到相应输出。
注解
在上面的 demo 中,我们在类上加了很多注解,注解的作用又是啥呢?
@BenchmarkMode
该注解用来指定基准测试类型,对应 Mode 选项,修饰类和方法,这里我们修饰类,注解的 value 是 Mode[] 类型,我们这里填入的是 Throughput ,表示整体吞吐量,即单位时间内的调用量,查看 Mode 源码就可以发现,其实总的类型有以下:
-
Throughput: 略
-
AverageTime: 平均耗时,指的是每次执行的平均时间。如果这个值很小不好辨认,可以把统计的单位时间调小一点。
-
SampleTime: 随机
取样
。 -
SingleShotTime: 如果你想要测试仅仅一次的性能,比如第一次初始化花了多长时间,就可以使用这个参数,其实和传统的main方法没有什么区别。
-
All: 所有的指标,都算一遍。
从 控制台的结果可以看看相关输出:
bash
Benchmark Mode Cnt Score Error Units
MyBenchmarkTest.div thrpt 5 500758.115 ± 3350.796 ops/ms
MyBenchmarkTest.shift thrpt 5 500045.811 ± 1609.779 ops/ms
如果填入 Mode.All 看看结果输出:
bash
Benchmark Mode Cnt Score Error Units
MyBenchmarkTest.div thrpt 5 500554.176 ± 8015.731 ops/ms
MyBenchmarkTest.shift thrpt 5 499731.423 ± 4635.160 ops/ms
MyBenchmarkTest.div avgt 5 ≈ 10⁻⁵ ms/op
MyBenchmarkTest.shift avgt 5 ≈ 10⁻⁵ ms/op
MyBenchmarkTest.div sample 316909 ≈ 10⁻⁴ ms/op
MyBenchmarkTest.div:div·p0.00 sample ≈ 0 ms/op
MyBenchmarkTest.div:div·p0.50 sample ≈ 0 ms/op
MyBenchmarkTest.div:div·p0.90 sample ≈ 10⁻⁴ ms/op
MyBenchmarkTest.div:div·p0.95 sample ≈ 10⁻⁴ ms/op
MyBenchmarkTest.div:div·p0.99 sample ≈ 10⁻⁴ ms/op
MyBenchmarkTest.div:div·p0.999 sample ≈ 10⁻⁴ ms/op
MyBenchmarkTest.div:div·p0.9999 sample 0.002 ms/op
MyBenchmarkTest.div:div·p1.00 sample 0.025 ms/op
MyBenchmarkTest.shift sample 315964 ≈ 10⁻⁴ ms/op
MyBenchmarkTest.shift:shift·p0.00 sample ≈ 0 ms/op
MyBenchmarkTest.shift:shift·p0.50 sample ≈ 0 ms/op
MyBenchmarkTest.shift:shift·p0.90 sample ≈ 10⁻⁴ ms/op
MyBenchmarkTest.shift:shift·p0.95 sample ≈ 10⁻⁴ ms/op
MyBenchmarkTest.shift:shift·p0.99 sample ≈ 10⁻⁴ ms/op
MyBenchmarkTest.shift:shift·p0.999 sample ≈ 10⁻⁴ ms/op
MyBenchmarkTest.shift:shift·p0.9999 sample 0.001 ms/op
MyBenchmarkTest.shift:shift·p1.00 sample 0.024 ms/op
MyBenchmarkTest.div ss 5 0.052 ± 0.091 ms/op
MyBenchmarkTest.shift ss 5 0.015 ± 0.023 ms/op
此时可以看到十分详尽的输出,每秒的吞吐量,每个操作的耗费时间等,因为本例简单,时间耗费建议填入 ns 等单位。
@BenchmarkMode 表示单位时间的操作数或者吞吐量,或者每个操作耗费的时间等,注意我们都没有限定时间单位,所以通常这个注解也会和 @OutputTimeUnit 结合使用。
@OutputTimeUnit
基准测试结果的时间类型。一般选择秒、毫秒、微秒,这里填入的是 TimeUnit 这个枚举类型,涉及单位很多从纳秒到天都有,按需选择,最终输出易读的结果。
@State
@State 指定了在类中变量的作用范围。它有三个取值。
@State 用于声明某个类是一个"状态",可以用Scope 参数用来表示该状态的共享范围。这个注解必须加在类上,否则提示无法运行。
Scope有如下3种值:
- Benchmark:表示变量的作用范围是某个基准测试类。
- Thread:每个线程一份副本,如果配置了Threads注解,则每个Thread都拥有一份变量,它们互不影响。
- Group:联系上面的@Group注解,在同一个Group里,将会共享同一个变量实例。
本例中,相关变量的作用范围是 Thread。
@Warmup
预热,可以加在类上或者方法上,预热只是测试数据,是不作为测量结果的。
该注解一共有4个参数:
- iterations 预热阶段的迭代数
- time 每次预热时间
- timeUnit 时间单位,通常秒
- batchSize 批处理大小,指定每次操作调用几次方法
本例中,我们加在类上,让它迭代3次,每次1秒,时间单位秒。
@Measurement
和预热类似,这里的注解是会影响测试结果的,它的参数和 Warmup 一样,这里不多介绍。
本例中我们在迭代中设置的是5次,每次1秒。
通常 @Warmup 和 @Measurement 两个参数会一起使用。
@Fork
表示开启几个进程测试,通常我们设为1,如果数值大于1,则启用新的进程测试,如果设置为0,程序依然进行,但是在用户的 JVM 进程上运行[2]。
追踪一下JMH的源码,发现每个fork进程是单独运行在Proccess
进程里的,这样就可以做完全的环境隔离,避免交叉影响。它的输入输出流,通过Socket连接的模式,发送到我们的执行终端。
如果需要更多的设置,可以看看 Fork.class 源码,上面还有 jvm 参数设置。
@Threads
上面的注解注重开启几个进程,这里就是开启几个线程,只有一个参数 value,指定注解的value,将会开启并行测试,如果设置的 value 过大,如 Threads.Max,则使用处理机的相同线程数。
@Benchmark
加在测试方法上,表示该方法是需要进行基准测试的,类似 JUnit5 中的 @Test 注解需要单元测试的方法一样。
@Setup
注解的作用就是我们需要在测试之前进行一些准备工作,比如对一些数据的初始化之类的,这个也和Junit的@Before
@Teardown
在测试之后进行一些结束工作,主要用于资源回收
开启测试
上述的学习中主要是相关注解,这里看看具体我们怎么用。
scss
public static void main(String[] args) throws RunnerException {
Options opts = new OptionsBuilder()
// 表示包含的测试类
.include(MyBenchmarkTest.class.getSimpleName())
// 最后结果输出文件的命名
.result("MyBenchmarkTest.json")
// 结果输出什么格式,可以是json, csv, text等
.resultFormat(ResultFormatType.JSON)
.build();
new Runner(opts).run(); // 运行
}
3.JMH可视化
作为程序开发人员,看懂测试结果没难度,测试结果文本能可视化更好。
好在我们拿到了JMH 结果后,根据文件格式,我们可以二次加工,就可以图表化展示[2]。
JMH 支持的几种输出格式:
- TEXT 导出文本文件。
- CSV 导出csv格式文件。
- SCSV 导出scsv等格式的文件。
- JSON 导出成json文件。
- LATEX 导出到latex,一种基于ΤΕΧ的排版系统。
比如 CSV 格式的文件,我们就可以通过 EXCEL 处理获取图表,当然也还有其他的一些作图工具:
参考: