最近公司在搞新项目,由于是实验性质,且不会直接面对客户的项目,这次的技术选型非常激进,如,直接使用了 Java 17。
作为公司里练习两年半的个人练习生,我自然也是深度的参与到了技术选型的工作中。不知道大家在技术选型中有没有关注过技术组件给出的基准测试?比如说,HikariCP 的基准测试:
又或者是 Caffeine 的基准测试:
如果你仔细阅读过它们的基准测试报告,你会发现一项很有意思的技术:Java Microbenchmark Harness,简称 JMH。
Tips:有些技术只需要学会如何使用即可,没有必要非得“卷”源码;有些“小众”技术你没有听过,也不必慌,没有人是什么都会的。
认识 JMH
接触 JMH 之前,我通常用System.currentTimeMillis()来计算方法的执行时间:
long start = System.currentTimeMillis();......long duration = System.currentTimeMillis() - start;
复制代码
大部分时候这么做都很灵,但某些场景下 JVM 会进行 JIT 编译和内联优化,导致代码在优化前后的执行效率差别非常大,此时这个“土”方法就不灵了。那么该如何准确的计算方法的执行时间呢?
Java 团队为开发者提供了 JMH 基准测试套件:
JMH is a Java harness for building, running, and analysing nano/micro/milli/macro benchmarks written in Java and other languages targeting the JVM.
JMH 是用于构建,运行和分析 Java 和其它基于 JVM 的语言编写的程序的基准测试套件。JMH 提供了预热的能力,通过预热让 JVM 知道哪些是热点代码,除此之外,JMH 还提供了吞吐量的测试指标。
相较于“土”方法,JMH 可以支持更多种的测试场景,而且基于 JMH 得出的测试结果也会更全面,更准确。
使用 JMH
项目中引入 JMH 的依赖:
<dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-core</artifactId> <version>1.36</version> <scope>test</scope></dependency><dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-generator-annprocess</artifactId> <version>1.36</version> </dependency>
复制代码
引入依赖后就可以编写一个简单的基准测试了,这里使用简化后的 JMH 官方示例:
package org.openjdk.jmh.samples;
import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Mode; import org.openjdk.jmh.annotations.OutputTimeUnit; 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;
public class JMHSample_02_BenchmarkModes {
@Benchmark @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MILLISECONDS) public void measureAvgTime() throws InterruptedException { TimeUnit.MILLISECONDS.sleep(100); } public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(JMHSample_02_BenchmarkModes.class.getSimpleName()) .forks(1) .build(); new Runner(opt).run(); }}
复制代码
执行这个示例,会输出如下结果:
以空行为分割的话,JMH 的输出可以分为 3 个部分:
Tips:
启动测试
从示例中不难发现,在 IDEA 中执行测试需要先构建Options,并通过Runner去执行。我们来构建一个最简单的Options:
Options opt = new OptionsBuilder().build();
new Runner(opt).run();
复制代码
这样的Options会执行散落在程序各处的基准测试方法(使用Benchmark注解的方法)。如果不需要执行所有的基准测试方法,通常在构建Options时会指定测试的范围:
Options opt = new OptionsBuilder() .include(JMHSample_02_BenchmarkModes.class.getSimpleName()) .build();
复制代码
这时基准测试仅限于Test类中的基准测试方法。除此之外,你可能还会嫌弃控制台输出样式丑陋,或者要提交的基准测试报告中需要用图示来直观的表达,这个时候可以控制输出结果的格式并指定结果输出文件:
Options opt = new OptionsBuilder() .include(JMHSample_02_BenchmarkModes.class.getSimpleName()) .result("result.json") .resultFormat(ResultFormatType.JSON).build();
复制代码
再结合以下网站,可以很轻松的构建出测试结果图示:
例如,我通过 JMH Visual Chart 构建出的测试结果:
实际上,OptionsBuilder提供的功能远不止如此,不过其中大部分功能都可以通过下文中提到注解进行配置,在此就不进行多余的说明了。
常用注解
JMH 可以通过注解非常简单的完成基准测试的配置,接下来对其中常用的 15 个注解进行详细说明。
注解:Benchmark
注解Benchmark的声明:
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface Benchmark {}
复制代码
Benchmark 用于方法上且该方法必须使用public修饰,表明该方法为基准测试方法。
注解:BenchmarkMode
注解BenchmarkMode的声明:
@Inherited @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface BenchmarkMode { Mode[] value(); }
复制代码
BenchmarkMode 用于方法或类上,表明测试指标。枚举类 Mode 提供了 4 种测试指标:
Mode.Throughput,吞吐量,单位时间内执行的次数;
Mode.AverageTime,平均时间,执行方法的平均耗时;
Mode.SampleTime,操作时间采样,并输出结果分布;
Mode.SingleShotTime,单次操作时间,通常在不进行预热时测试冷启动的时间。
我们来看下Mode.SampleTime的输出结果:
除单独使用以上测试指标外,还可以指定Mode.All进行全部指标的基准测试。
注解:OutputTimeUnit
注解OutputTimeUnit的声明:
@Inherited@Target({ElementType.METHOD,ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)public @interface OutputTimeUnit { TimeUnit value();}
复制代码
OutputTimeUnit 用于方法或类上,表明输出结果的时间单位。好了,示例中的注解我们已经了解完毕,接下来我们看其它较为关键的注解。
注解:Timeout
注解Timeout的声明:
@Target({ElementType.METHOD,ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)@Inheritedpublic @interface Timeout {
int time(); TimeUnit timeUnit() default TimeUnit.SECONDS;}
复制代码
Timeout 用于方法或类上,指定了基准测试方法的超时时间。
注解:Warmup
注解Warmup的声明:
@Target({ElementType.METHOD,ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)@Inheritedpublic @interface Warmup { int BLANK_ITERATIONS = -1; int BLANK_TIME = -1; int BLANK_BATCHSIZE = -1;
int iterations() default BLANK_ITERATIONS;
int time() default BLANK_TIME;
TimeUnit timeUnit() default TimeUnit.SECONDS;
int batchSize() default BLANK_BATCHSIZE;}
复制代码
Warmup 用于方法或类上,用于做预热配置。提供了 4 个参数:
iterations,预热迭代的次数;
time,每个预热迭代的时间;
timeUnit,时间单位;
batchSize,每个操作调用的次数。
预热的执行结果并不会被统计到测试结果中,因为 JIT 机制的存在某些方法被反复调用后,JVM 会将其编译为机器码,使其执行效率大大提高。
注解:Measurement
注解Measurement的声明:
@Inherited@Target({ElementType.METHOD,ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)public @interface Measurement { int BLANK_ITERATIONS = -1; int BLANK_TIME = -1; int BLANK_BATCHSIZE = -1;
int iterations() default BLANK_ITERATIONS; int time() default BLANK_TIME; TimeUnit timeUnit() default TimeUnit.SECONDS; int batchSize() default BLANK_BATCHSIZE;}
复制代码
Measurement 与Warmup的使用方法完全一致,参数含义也完全相同,区别在于Measurement属于正式测试的配置,结果会被统计。
注解:Group
注解Group的声明:
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface Group { String value() default "group";}
复制代码
Group 用于方法上,为测试方法分组。
注解:State
注解State的声明:
@Inherited@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)public @interface State { Scope value();}
复制代码
State 用于类上,表明了类中变量的作用范围。枚举类Scope提供了 3 种作用域:
Scope.Benchmark,每个测试方法中使用一个变量;
Scope.Group,每个分组中使用同一个变量;
Scope.Thread,每个线程中使用同一个变量。
忘记了是在哪看到有人说Scope.Benchmark的作用域是所有的基准测试方法,这个是错误的,Scope.Benchmark会为每个基准测试方法生成一个对象,例如:
@State(Scope.Benchmark)public static class ThreadState {}
@Benchmark@BenchmarkMode(Mode.SingleShotTime)public void test1(State state) { System.out.println("test1执行" + VM.current().addressOf(state));}
@Benchmark@BenchmarkMode(Mode.SingleShotTime)public void test2(State state) { System.out.println("test2执行" + VM.current().addressOf(state));}
复制代码
这个例子中,test1和test2使用的是不同的 State 对象。
Tips:VM.current().addressOf()是jol-core中提供的功能。
注解:Setup
注解Setup的声明:
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface Setup { Level value() default Level.Trial;}
复制代码
Setup 用于方法上,基准测试前的初始化操作。枚举类Level提供了 3 个级别:
Tips:一次迭代中,可能会出现多次方法调用。
注解:TearDown
注解TearDown的声明:
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface TearDown { Level value() default Level.Trial;}
复制代码
TearDown 用于方法上,与Setup的作用相反,是基准测试后的操作,同样使用Level提供了 3 个级别。
注解:Param
注解Param的声明:
@Inherited@Target({ElementType.FIELD})@Retention(RetentionPolicy.RUNTIME)public @interface Param { String BLANK_ARGS = "blank_blank_blank_2014";
String[] value() default { BLANK_ARGS };}
复制代码
Param 用于字段上,用于指定不同的参数,需要搭配 State 注解来使用。举个例子:
@State(Scope.Benchmark)public class Test { @Param({"10", "100", "1000", "10000"}) int count;
@Benchmark @Warmup(iterations = 0) @BenchmarkMode(Mode.SingleShotTime) public void loop() throws InterruptedException { for(int i = 0; i < count; i++) { TimeUnit.MILLISECONDS.sleep(1); } }}
复制代码
上述代码测试了程序在循环 10 次,100 次,1000 次和 10000 次时的性能。
注解:Threads
注解Threads的声明:
@Inherited@Target({ElementType.METHOD,ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)public @interface Threads {
int MAX = -1;
int value(); }
复制代码
Threads 用于方法和类上,指定基准测试中的并行线程数。当使用 MAX 时,将会使用所有可用线程进行测试,即Runtime.getRuntime().availableProcessors()返回的线程数。
注解:GroupThreads
注解GroupThreads的声明:
@Inherited@Target({ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)public @interface GroupThreads { int value() default 1;}
复制代码
GroupThreads 用于方法上,指定基准测试分组中使用的线程数。
注解:Fork
注解Fork的声明:
@Inherited @Target({ElementType.METHOD,ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)public @interface Fork { int BLANK_FORKS = -1; String BLANK_ARGS = "blank_blank_blank_2014"; int value() default BLANK_FORKS; int warmups() default BLANK_FORKS; String jvm() default BLANK_ARGS;
String[] jvmArgs() default { BLANK_ARGS }; String[] jvmArgsPrepend() default { BLANK_ARGS };
String[] jvmArgsAppend() default { BLANK_ARGS };}
复制代码
Fork用于方法和类上,指定基准测试中 Fork 的子进程。Fork提供了 6 个参数:
将Fork设置为 0 时,JMH 会在当前 JVM 中运行基准测试。由于可能处于用户的 JVM 中,无法反应真实的服务端场景,无法准确的反应实际性能,因此 JMH 推荐进行 Fork设置。
另外可以利用Fork提供的 JVM 设置,将 JVM 设置为 Server 模式:
@Fork(value = 1, jvmArgsAppend = {"-Xmx1024m", "-server"})
复制代码
注解:CompilerControl
注解CompilerControl的声明:
@Target({ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface CompilerControl { Mode value(); enum Mode { BREAK("break"), PRINT("print"), EXCLUDE("exclude"), INLINE("inline"), DONT_INLINE("dontinline"), COMPILE_ONLY("compileonly"); } }
复制代码
CompilerControl 用于方法,构造器或类上,指定编译方式。其内部枚举类提供了 6 种编译方式:
BREAK,将断点插入到编译后的代码;
PRINT,打印方法及其配置;
EXCLUDE,禁止编译;
INLINE,使用内联;
DONT_INLINE,禁止内联;
COMPILE_ONLY,仅编译;
结语
关于 JMH 的使用,我们就聊到这里了,希望今天的内容能够帮助你学习并掌握一种更准确的性能测试方法。
最后提供一个练习使用 JMH 的思路:大家都看到了文章开头 Caffeine 给出的基准测试结果了,但由于是 Caffeine 作者自己提供的基准测试,难免有些“既当裁判又当选手”的嫌疑,或者说他选取了一些对 Caffeine 有利的角度来展示结果,那么可以结合你自己的实际使用场景,给 Caffeine 及其竞品做一次基准测试。
作者:王有志
链接:https://juejin.cn/post/7219189558902063164
来源:稀土掘金
评论