1.4 编写正确的微基准测试以及高级用法
虽然JMH可以帮我们更好地了解我们所编写的代码,但是如果我们所编写的JMH基准测试方法本身就有问题,那么就会很难起到指导的作用,甚至还会可能会产生误导,本节将介绍如何避免编写错误的微基准测试方法,同时学习一些较为高级的基准测试案例。
1.4.1 编写正确的微基准测试用例
现代的Java虚拟机已经发展得越来越智能了,它在类的早期编译阶段、加载阶段以及后期的运行时都可以为我们的代码进行相关的优化,比如Dead Code的擦除、常量的折叠,还有循环的打开,甚至是进程Profiler的优化,等等,因此要掌握如何编写良好的微基准测试方法,首先我们要知道什么样的基准测试代码是有问题的。
1. 避免DCE(Dead Code Elimination)
所谓Dead Code Elimination是指JVM为我们擦去了一些上下文无关,甚至经过计算之后确定压根不会用到的代码,比如下面这样的代码片段。
public void test(){ int x=10; int y=10; int z=x+y; }
我们在test方法中分别定义了x和y,并且经过相加运算得到了z,但是在该方法的下文中再也没有其他地方使用到z(既没有对z进行返回,也没有对其进行二次使用,z甚至不是一个全局的变量),JVM很有可能会将test()方法当作一个空的方法来看待,也就是说会擦除对x、y的定义,以及计算z的相关代码。下面通过一个基准测试来验证一下在Java代码的执行过程中虚拟机是否会擦除与上下文无关的代码。
程序代码:JMHExample13.java
package com.wangwenjun.concurrent.jmh; 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.concurrent.TimeUnit; import static java.lang.Math.PI; import static java.lang.Math.log; @BenchmarkMode(Mode.AverageTime) @Fork(1) @Warmup(iterations = 5) @Measurement(iterations = 5) @OutputTimeUnit(TimeUnit.MICROSECONDS) @State(Scope.Thread) public class JMHExample13 { @Benchmark public void baseline(){ // 空的方法 } @Benchmark public void measureLog1(){ // 进行数学运算,但是在局部方法内 Math.log(PI); } @Benchmark public void measureLog2(){ // result是通过数学运算所得并且在下一行代码中得到了使用 double result = Math.log(PI); // 对result进行数学运算,但是结果既不保存也不返回,更不会进行二次运算 Math.log(result); } @Benchmark public double measureLog3(){ // 返回数学运算结果 return Math.log(PI); } public static void main(String[] args) throws RunnerException { final Options opts = new OptionsBuilder() .include(JMHExample13.class.getSimpleName()) .build(); new Runner(opts).run(); } }
▪ baseline方法作为一个空的方法,主要用于做基准数据。
▪ measureLog1中虽然进行了log运算,但是结果既没有再进行二次使用,也没有进行返回。
▪ measureLog2中同样进行了log运算,虽然第一次的运算结果是作为第二次入参来使用的,但是第二次执行结束后也再没有对其有更进一步的使用。
▪ measureLog3方法与measureLog1的方法类似,但是该方法对运算结果进行了返回操作。
下面就来运行一下这段JMHExample13,将会得到如下的输出结果。
程序输出:JMHExample13.java
Benchmark Mode Cnt Score Error Units JMHExample13.baseline avgt 5 0.001 ± 0.001 us/op JMHExample13.measureLog1 avgt 5 0.001 ± 0.001 us/op JMHExample13.measureLog2 avgt 5 0.001 ± 0.001 us/op JMHExample13.measureLog3 avgt 5 0.010 ± 0.012 us/op
从输出结果可以看出,measureLog1和measureLog2方法的基准性能与baseline几乎完全一致,因此我们可以肯定的是,这两个方法中的代码进行过擦除操作,这样的代码被称为Dead Code(死代码,其他地方都没有用到的代码片段),而measureLog3则与上述两个方法不同,由于它对结果进行了返回,因此Math.log(PI)不会被认为它是Dead Code,因此它将占用一定的CPU时间。
通过这个例子我们可以发现,若想要编写性能良好的微基准测试方法,则不要让方法存在Dead Code,最好每一个基准测试方法都有返回值。
2. 使用Blackhole
假设在基准测试方法中,需要将两个计算结果作为返回值,那么我们该如何去做呢?我们第一时间想到的可能是将结果存放到某个数组或者容器当中作为返回值,但是这种对数组或者容器的操作会对性能统计造成干扰,因为对数组或者容器的写操作也是需要花费一定的CPU时间的。
JMH提供了一个称为Blackhole的类,可以在不作任何返回的情况下避免Dead Code的发生,Blackhole直译为“黑洞”,与Linux系统下的黑洞设备/dev/null非常相似,请看下面的代码示例。
程序代码:JMHExample14.java
package com.wangwenjun.concurrent.jmh; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; 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.AverageTime) @Fork(1) @Warmup(iterations = 5) @Measurement(iterations = 5) @OutputTimeUnit(TimeUnit.NANOSECONDS) @State(Scope.Thread) public class JMHExample14 { double x1 = Math.PI; double x2 = Math.PI * 2; @Benchmark public double baseline() { // 不是Dead Code,因为对结果进行了返回 return Math.pow(x1, 2); } @Benchmark public double powButReturnOne() { // Dead Code会被擦除 Math.pow(x1, 2); // 不会被擦除,因为对结果进行了返回 return Math.pow(x2, 2); } @Benchmark public double powThenAdd() { // 通过加法运算对两个结果进行了合并,因此两次的计算都会生效 return Math.pow(x1, 2) + Math.pow(x2, 2); } @Benchmark public void useBlackhole(Blackhole hole) { // 将结果存放至black hole中,因此两次pow操作都会生效 hole.consume(Math.pow(x1, 2)); hole.consume(Math.pow(x2, 2)); } public static void main(String[] args) throws RunnerException { final Options opts = new OptionsBuilder() .include(JMHExample14.class.getSimpleName()) .build(); new Runner(opts).run(); } }
▪ baseline方法中对x1进行了pow运算,之后返回,因此这个基准测试方法是非常合理的。
▪ powButReturnOne方法中的第一个pow运算仍然避免不了被当作Dead Code的命运,因此我们很难得到两次pow计算的方法耗时,但是对x2的pow运算会作为返回值返回,因此不是dead code。
▪ powThenAdd方法就比较聪明,它同样会有返回值,两次pow操作也会被正常执行,但是由于采取的是加法运算,因此相加操作的CPU耗时也被计算到了两次pow操作中。
▪ useBlackhole方法中两次pow方法都会被执行,但是我们并没有对其进行返回操作,而是将其写入了black hole之中。
下面我们来看一下执行结果。
程序输出:JMHExample14.java
Benchmark Mode Cnt Score Error Units JMHExample14.baseline avgt 5 8.219 ± 0.697 ns/op JMHExample14.powButReturnOne avgt 5 8.597 ± 1.712 ns/op JMHExample14.powThenAdd avgt 5 12.206 ± 10.492 ns/op JMHExample14.useBlackhole avgt 5 18.971 ± 6.608 ns/op
输出结果表明,baseline和putButReturnOne方法的性能几乎是一样的,powThenAdd的性能相比前两个方法占用CPU的时间要稍微长一些,原因是该方法执行了两次pow操作。在useBlackhole中虽然没有对两个参数进行任何的合并操作,但是由于执行了black hole的consume方法,因此也会占用一定的CPU资源。虽然blackhole的consume方法会占用一定的CPU资源,但是如果在无返回值的基准测试方法中针对局部变量的使用都统一通过blackhole进行consume,那么就可以确保同样的基准执行条件,就好比拳击比赛时,对抗的拳手之间需要统一的体重量级一样。
总结起来,Blackhole可以帮助你在无返回值的基准测试方法中避免DC(Dead Code)情况的发生。
3. 避免常量折叠(Constant Folding)
常量折叠是Java编译器早期的一种优化——编译优化。在javac对源文件进行编译的过程中,通过词法分析可以发现某些常量是可以被折叠的,也就是可以直接将计算结果存放到声明中,而不需要在执行阶段再次进行运算。比如:
private final int x = 10; private final int y = x*20;
在编译阶段,y的值将被直接赋予200,这就是所谓的常量折叠,我们来看一下下面的基准测试代码示例。
程序代码:JMHExample15.java
package com.wangwenjun.concurrent.jmh; 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.concurrent.TimeUnit; import static java.lang.Math.log; @BenchmarkMode(Mode.AverageTime) @Fork(1) @Warmup(iterations = 5) @Measurement(iterations = 5) @OutputTimeUnit(TimeUnit.NANOSECONDS) @State(Scope.Thread) public class JMHExample15 { // x1和x2是使用final修饰的常量 private final double x1 = 124.456; private final double x2 = 342.456; // y1和y2则是普通的成员变量 private double y1 = 124.456; private double y2 = 342.456; // 直接返回124.456×342.456的计算结果,主要用它来作基准 @Benchmark public double returnDirect() { return 42_620.703936d; } // 两个常量相乘,我们需要验证在编译器的早期优化阶段是否直接计算出了x1乘以x2的值 @Benchmark public double returnCaculate_1() { return x1 * x2; } // 较为复杂的计算,计算两个未被final修饰的变量,主要也是用它来作为对比的基准 @Benchmark public double returnCaculate_2() { return log(y1) * log(y2); } // 较为复杂的计算,操作的同样是final修饰的常量,查看是否在编译器优化阶段进行了常量的折叠行为 @Benchmark public double returnCaculate_3() { return log(x1) * log(x2); } public static void main(String[] args) throws RunnerException { final Options opts = new OptionsBuilder() .include(JMHExample15.class.getSimpleName()) .build(); new Runner(opts).run(); } }
执行上面的基准测试代码,会得到如下的性能统计数据。
程序输出:JMHExample15.java
Benchmark Mode Cnt Score Error Units JMHExample15.returnCaculate_1 avgt 5 7.858 ± 1.057 ns/op JMHExample15.returnCaculate_2 avgt 5 133.570 ± 93.166 ns/op JMHExample15.returnCaculate_3 avgt 5 8.077 ± 1.578 ns/op JMHExample15.returnDirect avgt 5 10.353 ± 11.582 ns/op
我们可以看到,1、3、4三个方法的统计数据几乎相差无几,这也就意味着在编译器优化的时候发生了常量折叠,这些方法在运行阶段根本不需要再进行计算,直接将结果返回即可,而第二个方法的统计数据就没那么好看了,因为早期的编译阶段不会对其进行任何的优化。
4. 避免循环展开(Loop Unwinding)
我们在编写JMH代码的时候,除了要避免Dead Code以及减少对常量的引用之外,还要尽可能地避免或者减少在基准测试方法中出现循环,因为循环代码在运行阶段(JVM后期优化)极有可能被“痛下杀手”进行相关的优化,这种优化被称为循环展开,下面我们来看一下什么是循环展开(Loop Unwinding)。
int sum=0; for(int i = 0;i<100;i++){ sum+=i; }
上面的例子中,sum=sum+i这样的代码会被执行100次,也就是说,JVM会向CPU发送100次这样的计算指令,这看起来并没有什么,但是JVM的设计者们会认为这样的方式可以被优化成如下形式(可能)。
int sum=0; for(int i = 0;i<20; i+=5){ sum+=i; sum+=i+1; sum+=i+2; sum+=i+3; sum+=i+4; }
优化后将循环体中的计算指令批量发送给CPU,这种批量的方式可以提高计算的效率,假设1+2这样的运算执行一次需要1纳秒的CPU时间,那么在一个10次循环的计算中,我们觉得它可能是10纳秒的CPU时间,但是真实的计算情况可能不足10纳秒甚至更低,下面来看一下JMH的代码示例。
程序代码:JMHExample16.java
package com.wangwenjun.concurrent.jmh; 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.concurrent.TimeUnit; @BenchmarkMode(Mode.AverageTime) @State(Scope.Thread) @Warmup(iterations = 5) @Measurement(iterations = 10) @OutputTimeUnit(TimeUnit.NANOSECONDS) @Fork(1) public class JMHExample16 { private int x = 1; private int y = 2; @Benchmark public int measure() { return (x + y); } private int loopCompute(int times) { int result = 0; for (int i = 0; i < times; i++) { result += (x + y); } return result; } @OperationsPerInvocation @Benchmark public int measureLoop_1() { return loopCompute(1); } @OperationsPerInvocation(10) @Benchmark public int measureLoop_10() { return loopCompute(10); } @OperationsPerInvocation(100) @Benchmark public int measureLoop_100() { return loopCompute(100); } @OperationsPerInvocation(1000) @Benchmark public int measureLoop_1000() { return loopCompute(1000); } public static void main(String[] args) throws RunnerException { final Options opts = new OptionsBuilder() .include(JMHExample16.class.getSimpleName()) .build(); new Runner(opts).run(); } }
在上面的代码中,measure()方法进行了x+y的计算,measureLoop_1()方法与measure()方法几乎是等价的,也是进行了x+y的计算,但是measureLoop_10()方法对result+=(x+y)进行了10次这样的操作,其实说白了就是调用了10次measure()或者loopCompute(times=1)。但是我们肯定不能直接拿10次的运算和1次运算所耗费的CPU时间去做比较,因此@OperationsPerInvocation(10)注解的作用就是在每一次对measureLoop_10()方法进行基准调用的时候将op操作记为10次。下面来看一下JMH执行后的性能数据。
程序输出:JMHExample16.java
Benchmark Mode Cnt Score Error Units JMHExample16.measure avgt 10 8.291 ± 2.982 ns/op JMHExample16.measureLoop_1 avgt 10 7.227 ± 0.204 ns/op JMHExample16.measureLoop_10 avgt 10 1.298 ± 0.339 ns/op JMHExample16.measureLoop_100 avgt 10 0.153 ± 0.054 ns/op JMHExample16.measureLoop_1000 avgt 10 0.106 ± 0.003 ns/op
通过JMH的基准测试我们不难发现,在循环次数多的情况下,折叠的情况也比较多,因此性能会比较好,说明JVM在运行期对我们的代码进行了优化。
5. Fork用于避免Profile-guided optimizations
我们在所有的基准测试代码中几乎都使用到了Fork,那么它到底是用来干什么的呢?似乎前文一直没有交代,这是笔者故意这样安排的,本节将会为大家介绍Fork的作用以及JVM的Profile-guided optimizations。
在开始解释Fork之前,我们想象一下平时是如何进行应用性能测试的,比如我们要测试一下Redis分别在50、100、200个线程中同时进行共计一亿次的写操作时的响应速度,一般会怎样做?首先,我们会将Redis库清空,尽可能地保证每一次测试的时候,不同的测试用例站在同样的起跑线上,比如,服务器内存的大小、服务器磁盘的大小、服务器CPU的大小等基本上相同,这样的对比才是有意义的,然后根据测试用例对其进行测试,接着清理Redis服务器资源,使其回到测试之前的状态,最后统计测试结果做出测试报告。
Fork的引入也是考虑到了这个问题,虽然Java支持多线程,但是不支持多进程,这就导致了所有的代码都在一个进程中运行,相同的代码在不同时刻的执行可能会引入前一阶段对进程profiler的优化,甚至会混入其他代码profiler优化时的参数,这很有可能会导致我们所编写的微基准测试出现不准确的问题。对于这种说法大家可能会觉得有些抽象,下面我们还是通过代码实例为大家进行演示。
程序代码:JMHExample17.java
package com.wangwenjun.concurrent.jmh; 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.concurrent.TimeUnit; @BenchmarkMode(Mode.AverageTime) // 将Fork设置为0 @Fork(0) @Warmup(iterations = 5) @Measurement(iterations = 5) @OutputTimeUnit(TimeUnit.MICROSECONDS) @State(Scope.Thread) public class JMHExample17 { // Inc1 和Inc2的实现完全一样 interface Inc { int inc(); } public static class Inc1 implements Inc { private int i = 0; @Override public int inc() { return ++i; } } public static class Inc2 implements Inc { private int i = 0; @Override public int inc() { return ++i; } } private Inc inc1 = new Inc1(); private Inc inc2 = new Inc2(); private int measure(Inc inc) { int result = 0; for (int i = 0; i < 10; i++) { result += inc.inc(); } return result; } @Benchmark public int measure_inc_1() { return this.measure(inc1); } @Benchmark public int measure_inc_2() { return this.measure(inc2); } @Benchmark public int measure_inc_3() { return this.measure(inc1); } public static void main(String[] args) throws RunnerException { final Options opts = new OptionsBuilder() .include(JMHExample17.class.getSimpleName()) .build(); new Runner(opts).run(); } }
将Fork设置为0,每一个基准测试方法都将会与JMHExample17使用同一个JVM进程,因此基准测试方法可能会混入JMHExample17进程的Profiler,运行基准测试将会得出如下的结果。
程序输出:JMHExample17.java
Benchmark Mode Cnt Score Error Units JMHExample17.measure_inc_1 avgt 5 0.008 ± 0.001 us/op JMHExample17.measure_inc_2 avgt 5 0.048 ± 0.012 us/op JMHExample17.measure_inc_3 avgt 5 0.042 ± 0.001 us/op
measure_inc_1和measure_inc_2的实现方式几乎是一致的,它们的性能却存在着较大的差距,虽然measure_inc_1和measure_inc_3的代码实现完全相同,但还是存在着不同的性能数据,这其实就是JVM Profiler-guided optimizations导致的,由于我们所有的基准测试方法都与JMHExample17的JVM进程共享,因此难免在其中混入JMHExample17进程的Profiler,但是在将Fork设置为1的时候,也就是说每一次运行基准测试时都会开辟一个全新的JVM进程对其进行测试,那么多个基准测试之间将不会再存在干扰。
程序输出:JMHExample17.java(将Fork设置为1)
Benchmark Mode Cnt Score Error Units JMHExample17.measure_inc_1 avgt 5 0.014 ± 0.001 us/op JMHExample17.measure_inc_2 avgt 5 0.013 ± 0.001 us/op JMHExample17.measure_inc_3 avgt 5 0.013 ± 0.001 us/op
以上输出是将Fork设置为1的结果,是不是合理了很多,若将Fork设置为0,则会与运行基准测试的类共享同样的进程Profiler,若设置为1则会为每一个基准测试方法开辟新的进程去运行,当然,你可以将Fork设置为大于1的数值,那么它将多次运行在不同的进程中,不过一般情况下,我们只需要将Fork设置为1即可。
1.4.2 一些高级的用法
1. Asymmetric Benchmark
除了1.3.5节之外,我们编写的所有基准测试都会被JMH框架根据方法名的字典顺序排序之后串行执行,然而有些时候我们会想要对某个类的读写方法并行执行,比如,我们想要在修改某个原子变量的时候又有其他线程对其进行读取操作,下面来看一个示例代码。
程序代码:JMHExample18.java
package com.wangwenjun.concurrent.jmh; 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.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @BenchmarkMode(Mode.AverageTime) @Fork(1) @Warmup(iterations = 5) @Measurement(iterations = 5) @OutputTimeUnit(TimeUnit.MICROSECONDS) @State(Scope.Group) public class JMHExample18 { private AtomicInteger counter; @Setup public void init() { this.counter = new AtomicInteger(); } @GroupThreads(5) @Group("q") @Benchmark public void inc() { this.counter.incrementAndGet(); } @GroupThreads(5) @Group("q") @Benchmark public int get() { return this.counter.get(); } public static void main(String[] args) throws RunnerException { final Options opts = new OptionsBuilder() .include(JMHExample18.class.getSimpleName()) .build(); new Runner(opts).run(); } }
我们在对AtomicInteger进行自增操作的同时又会对其进行读取操作,这就是我们经常见到的高并发环境中某些API的操作方式,同样也是线程安全存在隐患的地方。下面我们来看看5个线程对AtomicInteger执行自增操作,5个线程对AtomicInteger执行读取时的性能是怎样的。
程序输出:JMHExample18.java
Benchmark Mode Cnt Score Error Units JMHExample18.q avgt 5 0.052 ± 0.014 us/op JMHExample18.q:get avgt 5 0.033 ± 0.010 us/op JMHExample18.q:inc avgt 5 0.072 ± 0.025 us/op
输出说明:
▪ group q(5个读线程,5个写线程)的平均响应时间为0.052us,误差为0.014。
▪ group q(5个读线程)同时读取atomicintoger变量的速度为0.033us,误差为0.010。
▪ group q(5个写线程)同时修改atomicintoger变量的速度为0.072us,误差为0.025。
2. Interrupts Benchmark
前面的例子中为大家演示了多线程情况下同时对AtomicInteger执行读写操作的情况,虽然基准测试能够顺利地运行,但是有些时候我们想要执行某些容器的读写操作时可能会引起阻塞,这种阻塞并不是容器无法保证线程安全问题引起的,而是由JMH框架的机制引起的,下面我们来看一下代码示例。
程序代码:JMHExample19.java
package com.wangwenjun.concurrent.jmh; 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.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; @BenchmarkMode(Mode.AverageTime) @Fork(1) @Warmup(iterations = 5) @Measurement(iterations = 5) @OutputTimeUnit(TimeUnit.MICROSECONDS) @State(Scope.Group) public class JMHExample19 { private BlockingQueue<Integer> queue; private final static int VALUE = Integer.MAX_VALUE; @Setup public void init() { this.queue = new ArrayBlockingQueue<>(10); } @GroupThreads(5) @Group("blockingQueue") @Benchmark public void put() throws InterruptedException { this.queue.put(VALUE); } @GroupThreads(5) @Group("blockingQueue") @Benchmark public int take() throws InterruptedException { return this.queue.take(); } public static void main(String[] args) throws RunnerException { final Options opts = new OptionsBuilder() .include(JMHExample19.class.getSimpleName()) .build(); new Runner(opts).run(); } }
在JMHExample19.java中我们针对BlockingQueue同时进行读(take)和写(put)的操作,但是很遗憾,在某些情况下(或许是第一次运行时)程序会出现长时间的阻塞,对于每一批次的Measurement,当然也包括Warmup中,put和take方法都会同时被多线程执行。想象一下,假设put方法最先执行结束,take方法无法再次从blocking queue中获取元素的时候将会一直阻塞下去,同样,take方法最先执行结束后,put方法在放满10个元素后再也无法存入新的元素,进而进入了阻塞状态,这两种情况都会等到每一次iteration(批次)超时(默认是10分钟)后才能继续往下执行。
难道我们就没有办法测试高并发容器在线程挂起(详见《Java高并发编程详解:多线程与架构设计》一书中的第20章“Guarded Suspension设计模式”)时的性能了吗?事实上,JMH的设计者们早就为我们想好了对应的解决方案,我们可以通过设置Options的timeout来强制让每一个批次的度量超时,超时的基准测试数据将不会被纳入统计之中,这也是JMH的另外一个严谨之处,下面对JMHExample19.java进行简单的修改。
程序代码:增加超时参数的JMHExample19.java
package com.wangwenjun.concurrent.jmh; 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.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; @BenchmarkMode(Mode.AverageTime) @Fork(1) @Warmup(iterations = 5) @Measurement(iterations = 5) @OutputTimeUnit(TimeUnit.MICROSECONDS) @State(Scope.Thread) public class JMHExample19 { private BlockingQueue<Integer> queue; private final static int VALUE = Integer.MAX_VALUE; @Setup public void init() { this.queue = new ArrayBlockingQueue<>(10); } @GroupThreads(5) @Group("blockingQueue") @Benchmark public void put() throws InterruptedException { this.queue.put(VALUE); } @GroupThreads(5) @Group("blockingQueue") @Benchmark public int take() throws InterruptedException { return this.queue.take(); } public static void main(String[] args) throws RunnerException { final Options opts = new OptionsBuilder() .include(JMHExample19.class.getSimpleName()) // 将每个批次的超时时间设置为10秒 .timeout(TimeValue.seconds(10)) .build(); new Runner(opts).run(); } }
好了,我们再来执行增加了超时设置的基准测试,观察输出结果会发现当出现阻塞时,jmh最多等待指定的超时时间会继续执行而不是像之前那样陷入长时间的阻塞。
程序输出:JMHExample19.java
# Warmup: 5 iterations, 1 s each # Measurement: 5 iterations, 1 s each #经过修改后的超时时间为10秒 # Timeout: 10 s per iteration # Threads: 10 threads (1 group; 5x "put", 5x "take" in each group), will synchronize iterations # Benchmark mode: Average time, time/op # Benchmark: com.wangwenjun.concurrent.jmh.JMHExample19.blockingQueue # Run progress: 0.00% complete, ETA 00:00:10 # Fork: 1 of 1 # Warmup Iteration 1: 9398.321 ±(99.9%) 43512.136 us/op # Warmup Iteration 2: 646.275 ±(99.9%) 2700.579 us/op # Warmup Iteration 3: 142.994 ±(99.9%) 122.541 us/op # Warmup Iteration 4: 201.472 ±(99.9%) 434.094 us/op # Warmup Iteration 5: 75839.559 ±(99.9%) 362113.018 us/op #第一个批次的执行由于阻塞超时被中断,但是阻塞所耗费的CPU时间并未纳入统计 Iteration 1: (*interrupt*) 33.928 ±(99.9%) 44.357 us/op put: 49.706 ±(99.9%) 125.505 us/op take: 18.150 ±(99.9%) 61.137 us/op Iteration 2: 34077.674 ±(99.9%) 161932.262 us/op put: 160.946 ±(99.9%) 663.212 us/op take: 67994.403 ±(99.9%) 583171.944 us/op Iteration 3: 227371.340 ±(99.9%) 709173.365 us/op put: 160945.927 ±(99.9%) 884624.490 us/op take: 293796.752 ±(99.9%) 2528733.979 us/op Iteration 4: 60.196 ±(99.9%) 77.277 us/op put: 82.283 ±(99.9%) 257.164 us/op take: 38.109 ±(99.9%) 54.278 us/op Iteration 5: 170459.751 ±(99.9%) 814010.180 us/op put: 340725.138 ±(99.9%) 2932016.811 us/op take: 194.365 ±(99.9%) 866.759 us/op Result "com.wangwenjun.concurrent.jmh.JMHExample19.blockingQueue": 86400.578 ±(99.9%) 406558.740 us/op [Average] (min, avg, max) = (33.928, 86400.578, 227371.340), stdev = 105582.014 CI (99.9%): [≈ 0, 492959.317] (assumes normal distribution) Secondary result "com.wangwenjun.concurrent.jmh.JMHExample19.blockingQueue:put": 100392.800 ±(99.9%) 582719.851 us/op [Average] (min, avg, max) = (49.706, 100392.800, 340725.138), stdev = 151330.495 CI (99.9%): [≈ 0, 683112.651] (assumes normal distribution) Secondary result "com.wangwenjun.concurrent.jmh.JMHExample19.blockingQueue:take": 72408.356 ±(99.9%) 489823.099 us/op [Average] (min, avg, max) = (18.150, 72408.356, 293796.752), stdev = 127205.503 CI (99.9%): [≈ 0, 562231.454] (assumes normal distribution) # Run complete. Total time: 00:00:39 Benchmark Mode Cnt Score Error Units JMHExample19.blockingQueue avgt 5 86400.578 ± 406558.740 us/op JMHExample19.blockingQueue:put avgt 5 100392.800 ± 582719.851 us/op JMHExample19.blockingQueue:take avgt 5 72408.356 ± 489823.099 us/op
3. 几大线程安全Map的性能对比
好了,现在可以根据学习到的JMH的知识来重新对比一下1.3.6节中关于几大Map的多线程下的读写性能了。
程序代码:JMHExample20.java
package com.wangwenjun.concurrent.jmh; 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.Collections; import java.util.HashMap; import java.util.Hashtable; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentSkipListMap; import java.util.concurrent.TimeUnit; @BenchmarkMode(Mode.AverageTime) @Fork(1) @Warmup(iterations = 5) @Measurement(iterations = 5) @OutputTimeUnit(TimeUnit.MICROSECONDS) @State(Scope.Group) public class JMHExample20 { @Param({"1", "2", "3", "4"}) private int type; private Map<Integer, Integer> map; @Setup public void setUp() { switch (type) { case 1: this.map = new ConcurrentHashMap<>(); break; case 2: this.map = new ConcurrentSkipListMap<>(); break; case 3: this.map = new Hashtable<>(); break; case 4: this.map = Collections.synchronizedMap( new HashMap<>()); break; default: throw new IllegalArgumentException("Illegal map type."); } } @Group("g") @GroupThreads(5) @Benchmark public void putMap() { int random = randomIntValue(); this.map.put(random, random); } @Group("g") @GroupThreads(5) @Benchmark public Integer getMap() { return this.map.get(randomIntValue()); } // 计算一个随机值用作Map中的Key和Value private int randomIntValue() { return (int) Math.ceil(Math.random() * 600000); } public static void main(String[] args) throws RunnerException { final Options opts = new OptionsBuilder() .include(JMHExample20.class.getSimpleName()) .build(); new Runner(opts).run(); } }
大家可以看到,在putMap和getMap方法中,通过随机值的方式将取值作为key和value存入map中,同样也是通过随机值的方式将取值作为key从map中进行数据读取(当然读取的值可能并不存在)。还有我们在基准方法中进行了随机值的运算,虽然随机值计算所耗费的CPU时间也会被纳入基准结果的统计中,但是每一个map都进行了相关的计算,因此,我们可以认为大家还是站在了同样的起跑线上,故而可以对其忽略不计。运行上面的基准测试将会得到如下的结果。
程序输出:JMHExample20.java
JMHExample20.g 1 avgt 5 2.524 ± 0.171 us/op JMHExample20.g:getMap 1 avgt 5 2.519 ± 0.870 us/op JMHExample20.g:putMap 1 avgt 5 2.530 ± 0.821 us/op JMHExample20.g 2 avgt 5 13.162 ± 5.167 us/op JMHExample20.g:getMap 2 avgt 5 13.672 ± 9.094 us/op JMHExample20.g:putMap 2 avgt 5 12.652 ± 2.736 us/op JMHExample20.g 3 avgt 5 8.140 ± 2.245 us/op JMHExample20.g:getMap 3 avgt 5 12.362 ± 5.691 us/op JMHExample20.g:putMap 3 avgt 5 3.917 ± 1.915 us/op JMHExample20.g 4 avgt 5 11.080 ± 11.900 us/op JMHExample20.g:getMap 4 avgt 5 18.726 ± 25.655 us/op JMHExample20.g:putMap 4 avgt 5 3.433 ± 2.079 us/op
基准测试的数据可以表明,在5个线程同时进行map写操作,5个线程同时进行读操作时,参数type=1的性能是最佳的,也就是ConcurrentHashMap。