JMH是Java Micro Benchmark Harness的简写,是专门用于代码微基准测试的工具集(toolkit)。JMH是由实现Java虚拟机的团队开发的,因此他们非常清楚开发者所编写的代码在虚拟机中将会如何执行。由于现代JVM已经变得越来越智能,在Java文件的编译阶段、类的加载阶段,以及运行阶段都可能进行了不同程度的优化,因此开发者编写的代码在运行中未必会像自己所预期的那样具有相同的性能体现,JVM的开发者为了让普通开发者能够了解自己所编写的代码运行的情况,JMH便因此而生。本文意在实际学习和了解下JMF的使用流程及实际开发中如何运用。第一节主要了解JMH的基本用法。
首先,要将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>Mode 表示 JMH 进行 Benchmark 时所使用的模式。通常是测量的维度不同,或是测量的方式不同。目前 JMH 共有四种模式:
Throughput: 方法吞吐量,它的输出信息表明了在单位时间内可以对该方法调用多少次。AverageTime: 每次调用的平均耗时时间,它主要用于输出基准测试方法每调用一次所耗费的时间,也就是elapsed time/operation。SampleTime: 随机进行采样执行的时间,最后输出取样结果的分布SingleShotTime: 主要可用来进行冷测试,不论是Warmup还是Measurement,在每一个批次中基准测试方法只会被执行一次,一般情况下,我们会将Warmup的批次设置为0。BenchmarkMode既可以在class上进行注解设置,也可以在基准方法上进行注解设置,方法中设置的模式将会覆盖class注解上的设置,同样,在Options中也可以进行设置,它将会覆盖所有基准方法上的设置。
benchmark 结果所使用的时间单位,可用于类或者方法注解,使用java.util.concurrent.TimeUnit中的标准时间单位。
类注解,JMH测试类必须使用@State注解,State定义了一个类实例的生命周期,可以类比Spring Bean的Scope。由于JMH允许多线程同时执行测试,不同的选项含义如下:
Scope.Thread: 默认的State,每个测试线程分配一个实例 。所谓线程独享的State是指,每一个运行基准测试方法的线程都会持有一个独立的对象实例,该实例既可能是作为基准测试方法参数传入的,也可能是运行基准方法所在的宿主class,将State设置为Scope.Thread一般主要是针对非线程安全的类。Scope.Benchmark: 所有测试线程共享一个实例,用于测试有状态实例在多线程共享下的性能。有时候,我们需要测试在多线程的情况下某个类被不同线程操作时的性能,比如,多线程访问某个共享数据时,我们需要让多个线程使用同一个实例才可以。因此JMH提供了多线程共享的一种状态Scope.BenchmarkScope.Group: 每个线程组共享一个实例。如果想要测试某个共享数据或共享资源在多线程的情况下同时被读写的行为,比如在多线程高并发的环境中,多个线程同时对一个ConcurrentHashMap进行读写。就需要使用Scope.Group来实现。方法注解,会在执行 benchmark 之前被执行,正如其名,主要用于初始化。与@TearDown可选参数一致
Trial:Setup和TearDown默认的配置,该套件方法会在每一个基准测试方法的所有批次执行的前后被执行
Iteration:每个benchmark方法每次迭代前后
Invocation:每个benchmark方法每次调用前后,谨慎使用,需留意javadoc注释
需要注意的是,套件方法的执行也会产生CPU时间的消耗,但是JMH并不会将这部分时间纳入基准方法的统计之中,这一点更进一步地说明了JMH的严谨之处。
方法注解,与@Setup 相对的,会在所有 benchmark 执行结束以后执行,主要用于资源的回收等。
成员注解,可以用来指定某项参数的多种情况。特别适合用来测试一个函数在不同的参数输入的情况下的性能。@Param注解接收一个String数组,在@setup方法执行前转化为为对应的数据类型。多个@Param注解的成员之间是乘积关系,譬如有两个用@Param注解的字段,第一个有5个值,第二个字段有2个值,那么每个测试方法会跑5*2=10次。
与Junit4.x版本需要使用@Test注解标记单元测试方法一样,JMH对基准测试的方法需要使用@Benchmark注解进行标记,否则方法将被视为普通方法,并且不会对其执行基准测试。如果一个类中没有任何基准测试方法(被@Benchmark标记的方法),那么对其进行基准测试则会出现异常。因此请务必使用@Benchmark标记需要进行基准测试的方法
Warmup可直译为“预热”的意思,在JMH中,Warmup所做的就是在基准测试代码正式度量之前,先对其进行预热,使得代码的执行是经历过了类的早期优化、JVM运行期编译、JIT优化之后的最终状态,从而能够获得代码真实的性能数据。
Measurement则是真正的度量操作,在每一轮的度量中,所有的度量数据会被纳入统计之中(预热数据不会纳入统计之中)。
设置全局的Warmup和Measurement执行批次,既可以通过构造Options时设置,也可以在对应的class上用相应的注解进行设置。 我们除了可以设置全局的Warmup和Measurement参数之外,还可以在方法上设置对应基准测试方法的批次参数。
笔者经过测试发现,通过类注解的方式设置的全局Measurement和Warmup参数是可以被基准测试方法通过同样的方式覆盖的,但是通过Options进行的全局设置则无法被覆盖,也就是说,通过Options设置的参数会应用于所有的基准测试方法且无法被修改(当然不同的版本可能会存在差异)。
度量次数
预热次数
注意: 通过类注解的方式设置的全局Measurement和Warmup参数是可以被基准测试方法通过同样的方式覆盖的,但是通过Options进行的全局设置则无法被覆盖,也就是说,通过Options设置的参数会应用于所有的基准测试方法且无法被修改(当然不同的版本可能会存在差异)。
官方demo:http://hg.openjdk.java.net/code-tools/jmh/file/tip/jmh-samples/src/main/java/org/openjdk/jmh/samples/
/** * @author haichi * @version 1.0 * @date 2020/10/22 19:23 */ @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MICROSECONDS) @State(Scope.Thread) //@Measurement(iterations = 10) //@Warmup(iterations = 3) public class JMHExample { private final static String dummyData = "池池池"; private List<String> arrayList; private List<String> linkedList; @Setup(Level.Iteration) public void test(){ this.arrayList = new ArrayList<>(); this.linkedList = new LinkedList<>(); } @Benchmark // @Measurement(iterations = 10) // @Warmup(iterations = 3) public List<String> arrayListAdd(){ arrayList.add(dummyData); return arrayList; } @Benchmark public List<String> linkedListAdd(){ linkedList.add(dummyData); return linkedList; } public static void main(String[] args) throws RunnerException { final Options options = new OptionsBuilder().include(JMHExample.class.getSimpleName()).forks(1) .measurementIterations(10) // 度量执行的批次为5,也就是说这5个批次中,对基准方法的执行与调用将会纳入统计 .warmupIterations(3) // 在真正的度量之前,首先会对代码进行3个批次的热身,使代码的运行已达到jvm已经优化的效果 .build(); new Runner(options).run(); } }include(): 表我要测试的是哪个类的方法 forks(1) :指的是做1轮测试,在一轮测试无法得出最满意的结果时,可以多测几轮以便得出更全面的测试结果,而每一轮都是先预热,再正式计量。
http://hg.openjdk.java.net/code-tools/jmh/file/tip/jmh-samples/src/main/java/org/openjdk/jmh/samples/
# JMH version: 1.26 # VM version: JDK 1.8.0_231, Java HotSpot(TM) 64-Bit Server VM, 25.231-b11 # VM invoker: C:\Program Files\Java\jdk1.8.0_231\jre\bin\java.exe # VM options: -javaagent:F:\idea\IntelliJ IDEA 2019.3.1\IntelliJ IDEA 2020.2.2\lib\idea_rt.jar=61732:F:\idea\IntelliJ IDEA 2019.3.1\IntelliJ IDEA 2020.2.2\bin -Dfile.encoding=UTF-8 ++++JVM运行指定参数 # Warmup: 3 iterations, 10 s each ++++热身批次为3,每一个批次都会不间断的调用,每一个批次的执行时间为10s # Measurement: 10 iterations, 10 s each ++++真正度量批次为10,这10个批次产生的性能数据才会真正纳入统计 # Timeout: 10 min per iteration ++++每一个批次的超时时间 # Threads: 1 thread, will synchronize iterations ++++执行基准测试的线程数量 # Benchmark mode: Average time, time/op ++++Benchmark的mode这里表明的是方法调用一次所消耗的单位时间 # Benchmark: vip.chihai.test.JMHExample.arrayListAdd ++++Benchmark方法的绝对路径 # Run progress: 0.00% complete, ETA 00:04:20 ++++执行进度 # Fork: 1 of 1 # Warmup Iteration 1: 0.031 us/op ++++这里是记录三个批次热身的平均耗时 # Warmup Iteration 2: 0.025 us/op # Warmup Iteration 3: 0.019 us/op Iteration 1: 0.016 us/op ++++这里是具体十次度量的时间统计,我们明显可以看到在经过一系列优化后正式度量比刚开始执行快了不少 Iteration 2: 0.016 us/op Iteration 3: 0.015 us/op Iteration 4: 0.015 us/op Iteration 5: 0.015 us/op Iteration 6: 0.015 us/op Iteration 7: 0.014 us/op Iteration 8: 0.014 us/op Iteration 9: 0.020 us/op Iteration 10: 0.013 us/op Result "vip.chihai.test.JMHExample.arrayListAdd": ++++最终统计结果 0.015 ±(99.9%) 0.003 us/op [Average] (min, avg, max) = (0.013, 0.015, 0.020), stdev = 0.002 ++++分别是:最小值、平均值、最大值、标准误差 CI (99.9%): [0.013, 0.018] (assumes normal distribution) # JMH version: 1.26 # VM version: JDK 1.8.0_231, Java HotSpot(TM) 64-Bit Server VM, 25.231-b11 # VM invoker: C:\Program Files\Java\jdk1.8.0_231\jre\bin\java.exe # VM options: -javaagent:F:\idea\IntelliJ IDEA 2019.3.1\IntelliJ IDEA 2020.2.2\lib\idea_rt.jar=61732:F:\idea\IntelliJ IDEA 2019.3.1\IntelliJ IDEA 2020.2.2\bin -Dfile.encoding=UTF-8 # Warmup: 3 iterations, 10 s each # Measurement: 10 iterations, 10 s each # Timeout: 10 min per iteration # Threads: 1 thread, will synchronize iterations # Benchmark mode: Average time, time/op # Benchmark: vip.chihai.test.JMHExample.linkedListAdd # Run progress: 50.00% complete, ETA 00:02:15 # Fork: 1 of 1 # Warmup Iteration 1: 0.341 us/op # Warmup Iteration 2: 0.119 us/op # Warmup Iteration 3: 0.123 us/op Iteration 1: 0.108 us/op Iteration 2: 0.152 us/op Iteration 3: 0.146 us/op Iteration 4: 0.146 us/op Iteration 5: 0.146 us/op Iteration 6: 0.145 us/op Iteration 7: 0.145 us/op Iteration 8: 0.146 us/op Iteration 9: 0.145 us/op Iteration 10: 0.145 us/op Result "vip.chihai.test.JMHExample.linkedListAdd": 0.142 ±(99.9%) 0.019 us/op [Average] (min, avg, max) = (0.108, 0.142, 0.152), stdev = 0.012 CI (99.9%): [0.124, 0.161] (assumes normal distribution) # Run complete. Total time: 00:05:35 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 JMHExample.arrayListAdd avgt 10 0.015 ± 0.003 us/op JMHExample.linkedListAdd avgt 10 0.142 ± 0.019 us/op Process finished with exit code 0虽然输出的信息很多,但是目前我们只需要查看输出的最后两行,大体上,我们从这两行信息可以发现arrayListAdd方法的调用平均响应时间为0.015微秒,误差在0.003微秒,而linkedListAdd方法的调用平均响应时间为0.142微秒,误差在0.019微秒,很明显,前者的性能是要高于后者的,虽然从结果上来看,这与我们之前利用main函数的测试方法得出的结论是一致的,但是显然JMH要严谨科学很多。
1.@CompilerControl(CompilerControl.Mode.Exclude)
2.JVM启动参数:-Djava.compiler=NONE
