前言
这是我2021计划中的第一篇文章,今年准备深入探索JUC并发编程。
也准备梳理出整个体系的JUC相关知识,还请大家多多支持!
第一篇文章为啥要选择JMH来展开学习呢?
在后面的原子类体系、并发工具体系、并发容器体系、线程池体系、Stream体系等都会用到此工具进行测试,这也是JVM团队推荐我们使用的测量工具。
JMH简介
JMH是 Java Micro Benchmark Harness 的缩写,是专门用于代码微基准测试的工具集(toolkit)
JMH是由现实的Java虚拟机的团队开发的,因此他们非常清楚开发者所编写的代码在虚拟机中将会如何执行。
由于现代的JVM已经变得越来越智能,在java文件的编译阶段、类的加载阶段,以及运行阶段都可能进行了不同程度的优化,因此开发者编写的代码在运行中未必会像自己所预期的那样具有相同的性能体现,JVM的开发者为了让普通的开发者能够了解自己所写的代码运行情况,JMH因此而生。
上文摘抄自《JAVA高并发编程详解——深入理解并发核心库》
如何使用JMH进行性能检测呢?
-
引入pom配置
<dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-core</artifactId> <version>1.19</version> </dependency> <dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-generator-annprocess</artifactId> <version>1.19</version> <scope>provided</scope> </dependency>
-
我们先简单对ArrayList、LinkedList进行一次数据添加性能测试
此处会使用大量的JMH API,也许暂时你还不太熟悉甚至还不知道它们的用法,不必担心,后面我会详细讲解!
@BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MICROSECONDS) @Fork(1) @State(Scope.Thread) @Warmup(iterations = 5) @Measurement(iterations = 10) public class JMHTest { private final static String DATA = "Data"; private List<String> arrayList; private List<String> linkedList; @Setup(Level.Iteration) public void setUp() { this.arrayList = new ArrayList<>(); this.linkedList = new LinkedList<>(); } @Benchmark public List<String> arrayListAdd() { this.arrayList.add(DATA); return this.arrayList; } @Benchmark public List<String> linkedListAdd() { this.linkedList.add(DATA); return this.linkedList; } public static void main(String[] args) throws RunnerException { final Options options = new OptionsBuilder().include(JMHTest.class.getSimpleName()) .build(); new Runner(options).run(); } }
当你运行main方法之后,会输出很多东西,我们先只看最后两行结果:
Benchmark Mode Cnt Score Error Units JMHTest.arrayListAdd avgt 10 0.013 ± 0.007 us/op JMHTest.linkedListAdd avgt 10 0.070 ± 0.058 us/op
Benchmark:微基准测试的方法
Mode:执行模式
Cnt: 度量次数
Score: 分数
Error: 误差
Units:单位
其表达的意思是:方法的执行结果分别统计了10次,arrayListAdd方法平均响应时间为0.013微秒,误差在0.007微秒;linkedListAdd方法平均响应时间为0.070微秒,误差在0.058微秒。
感觉是不是有点违背面试题平常所展现的结果?
事实上,ArrayList在不进行内部扩容数组复制的情况下,随机读写性能要好于LinkedList;LinkedList由于是链表设计,其delete操作性能肯定好于ArrayList
JMH的基本用法
-
@Benchmark注解
标记基准测试方法,类似于Junit4.x 版本的@Test注解
如果被此注解标记,该方法会进行基准测试
注意:如果一个类被OptionsBuilder().include添加为基准测试的class,但是没有一个方法被Benchmark注解标记,则运行会抛异常 -
@Warmup注解
直译:预热
主要作用是基准测试代码正式度量之前,对其进行预热,使代码的执行已经历过类的早期优化、JVM运行期编译、JIT优化之后的最终状态,从而让我们得到代码真实的性能数据
观察以上代码得知:我们的预热批次为5次 -
@Measurement注解
是我们真正需要度量的批次
观察以上代码得知:我们正式度量的批次为10次
不通过注解的方式也可以设置预热批次、正式度量批次
public static void main(String[] args) throws RunnerException { final Options options = new OptionsBuilder().include(JMH01.class.getSimpleName()) //10个批次 .measurementIterations(10) //在真正的度量之前,首先会进行5个批次的热身,使代码的运行达到jvm已经优化的效果 .warmupIterations(5) .build(); new Runner(options).run(); }
-
四大BenchmarkMode
JMH使用BenchmarkMode注解来声明运行用的哪一种模式,JMH为我们提供了四种运行模式
-
@BenchmarkMode(Mode.AverageTime)
Mode.AverageTime:平均响应时间,每一次调用的平均时间
这也是我们现在代码中所使用的模式
-
@BenchmarkMode(Mode.Throughput)
Mode.Throughput:方法吞吐量,单位时间内可以对该方法调用多少次
-
@BenchmarkMode(Mode.SampleTime)
Mode.SampleTime:时间采样,收集所有的性能数据,并将其分布在不用的区间
-
@BenchmarkMode(Mode.SingleShotTime)
Mode.SingleShotTime:冷测试,每一个基准测试方法只会被执行一次;一般情况下,我们会将Warmup的批次设置为0
-
-
@OutputTimeUnit(TimeUnit.MICROSECONDS)
设置统计结果输出时的单位,此处我们设置的微秒
-
@State注解
-
@State(Scope.Thread)
代表线程独享的State,每一个运行基准测试方法的线程都会持有一个独立的对象实例
一般用于非线程安全的类
-
@State(Scope.Benchmark)
线程共享的state,只有一个实例
一般用于我们需要测试在多线程情况下某个类被不同线程操作时的性能
-
@State(Scope.Group)
线程组共享的state
一般用于多线程、多方法的场景
@Group("linked") //线程组名称 @GroupThreads(5) //线程数量 @Benchmark public List<String> linkedListAdd() { this.linkedList.add(DATA); return this.linkedList; }
-
检测Map相关容器的性能
-
首先我们使用当前所学知识进行简单检测,其代码如下
private Map<Long, Long> concurrentMap; private Map<Long, Long> synchronizeMap; @Setup public void setUp() { concurrentMap = new ConcurrentHashMap<>(); synchronizeMap = Collections.synchronizedMap(new HashMap<>()); } @Benchmark public void testConcurrentMap() { this.concurrentMap.put(System.nanoTime(), System.nanoTime()); } @Benchmark public void testSynchronizedMap() { this.synchronizeMap.put(System.nanoTime(), System.nanoTime()); }
测量结果如下:
Benchmark Mode Cnt Score Error Units JMH_Map.testConcurrentMap avgt 10 4.139 ± 6.279 us/op JMH_Map.testSynchronizedMap avgt 10 6.063 ± 8.254 us/op
ConcurrentHashMap性能远高于SynchronizedMap;看到上面代码我们不难发现,当需要测量的集合增加时,我们需要再次定义集合属性、测量方法
此时我们引入Param注解的使用
/** * 定义参数 */ @Param({"1", "2", "3", "4"}) private int type; private Map<Long, Long> 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("参数错误"); } } @Benchmark public void testMap() { this.map.put(System.nanoTime(), System.nanoTime()); }
代码会按照我们设定的type进行逐一测试,测试结果如下:
Benchmark (type) Mode Cnt Score Error Units JMH_Param.testMap 1 avgt 10 5.465 ± 6.018 us/op JMH_Param.testMap 2 avgt 10 3.605 ± 10.453 us/op JMH_Param.testMap 3 avgt 10 34.221 ± 97.544 us/op JMH_Param.testMap 4 avgt 10 23.109 ± 81.828 us/op
JMH测试套件(Fixture)
当我们使用Junit编写单元测试时,我们可以使用它的很多套件,例如:@Before,@After,@BeforeClass,@AfterClass
在JMH中也是存在一些套件的,如下:
-
@Setup注解
标记的方法会在每个基准测试方法之前被调用,通常用于资源的初始化
@Setup(Level.Iteration) public void setUp() { this.arrayList = new ArrayList<>(); this.linkedList = new LinkedList<>(); }
-
@TearDown注解
标记的方法会在每个基准测试方法之后被调用,通常用于资源回收清理工作
@TearDown public void tearDown() { System.out.println("测试结束……"); }
以上两个注解均有三种等级:
@Setup(Level.Trial): 默认配置,会在基准方法执行前后执行
@Setup(Level.Iteration): 每个执行批次前后执行
@Setup(Level.Invocation) : 每一个批次的度量过程中,每一次对基准方法的调用前后执行
如何编写正确的微基准测试?
首先为什么会有这样的疑问呢?
因为现在的JAVA虚拟机已经发展得越来越智能了,它在类的早期编译阶段、加载阶段、运行阶段都可以为我们的代码进行一定的优化
例如:Dead Code的擦除、常量的折叠、循环打开、甚至进程的Profiler的优化等
-
避免DCE(Dead Code Elimination)
所谓的DCE是指JVM为我们擦去了上下文无关,甚至经过计算之后确定压根不会用到的代码
@Benchmark public void testNull() { } @Benchmark public void testNoReturn() { Math.log(PI); } @Benchmark public void testNoReturn02() { double result = Math.log(PI); Math.log(result); } @Benchmark public double testByReturn() { return Math.log(PI); }
以上我们定义了四个方法,执行结果如下:
Benchmark Mode Cnt Score Error Units JMHComplier.testNull avgt 5 ≈ 10⁻⁴ us/op JMHComplier.testNoReturn avgt 5 ≈ 10⁻⁴ us/op JMHComplier.testNoReturn02 avgt 5 ≈ 10⁻⁴ us/op JMHComplier.testByReturn avgt 5 0.002 ± 0.001 us/op
不难看出JMHComplier.testByReturn方法执行的结果是其余三个的10倍,说明其余三个方法的代码块被JVM擦除了
这是真的吗? 博主可不要骗我们哟!
接下来我们修改一下代码,使用CompilerControl(CompilerControl.Mode.EXCLUDE)注解来禁止JVM运行时优化
@Benchmark @CompilerControl(CompilerControl.Mode.EXCLUDE) public void testNull() { } @Benchmark @CompilerControl(CompilerControl.Mode.EXCLUDE) public void testNoReturn() { Math.log(PI); } @Benchmark @CompilerControl(CompilerControl.Mode.EXCLUDE) public void testNoReturn02() { double result = Math.log(PI); Math.log(result); } @Benchmark @CompilerControl(CompilerControl.Mode.EXCLUDE) public double testByReturn() { return Math.log(PI); }
执行结果如下:
Benchmark Mode Cnt Score Error Units JMHComplier.testNull avgt 5 0.009 ± 0.001 us/op JMHComplier.testNoReturn avgt 5 0.026 ± 0.001 us/op JMHComplier.testNoReturn02 avgt 5 0.047 ± 0.003 us/op JMHComplier.testByReturn avgt 5 0.027 ± 0.002 us/op
不难看出JMHComplier.testNoReturn和JMHComplier.testNoReturn02方法的执行结果与上次差别很大
综上所述,JVM会对我们的代码进行一定的优化
为了测试,我们还可以使用两种方式来禁止JVM优化:
- 通过编写程序的方式禁止JVM运行时期动态编译和优化 java.lang.Compiler.disable();
- 在启动JVM时增加参数 -Djava.complier=NODE
-
使用Blackhole
直译:黑洞
用于接收我们想要返回的值
/** * @param blackhole 黑洞,用于接收我们的返回值 */ @Benchmark public void testByBlackhole(Blackhole blackhole) { blackhole.consume(Math.log(PI)); }
-
避免常量折叠
是指JAVA编译器早期的一种优化——编译优化
主要是将我们部分需要返回的值进行了预处理,相当于就是已经计算出来了
private final double a1 = 124.456; private final double a2 = 342.456; private double b1 = 124.456; private double b2 = 342.456; @Benchmark public void returnDirect(Blackhole blackhole) { blackhole.consume(42620.703936d); } @Benchmark public void returnCalculate01(Blackhole blackhole) { blackhole.consume(a1 * a2); } @Benchmark public void returnCalculate02(Blackhole blackhole) { blackhole.consume(log(a1) * log(a2)); } @Benchmark public void returnCalculate03(Blackhole blackhole) { blackhole.consume(log(b1) * log(b2)); }
执行结果如下:
Benchmark Mode Cnt Score Error Units JMHComplier02.returnDirect avgt 5 0.002 ± 0.001 us/op JMHComplier02.returnCalculate01 avgt 5 0.002 ± 0.001 us/op JMHComplier02.returnCalculate02 avgt 5 0.002 ± 0.001 us/op JMHComplier02.returnCalculate03 avgt 5 0.032 ± 0.001 us/op
不难看出前三个方法执行结果相同,说明此处final修饰常量,在方法中进行计算操作时,在编译期就进行了优化
-
避免循环展开(Loop Unwinding)
是指我们的循环操作中其实是会发送多次CPU指令的,JVM对于这种情况进行了后期优化,会将我们指令生成批量形式一次性发送
-
利用Fork用于避免Profile-guided optimizations
我们在前面的代码中,有使用到Fork(1)注解
是指我们的每个基准测试方法执行都会开辟一个全新的JVM进程进行测试,避免强大的JVM带来的Profiler优化,那么多个基准测试之间将不会存在干扰
JMH的一些高级用法
-
Asymmetric Benchmark
以上我们的基准测试都是串行执行的
当我们需要多线程对一个变量进行读写操作时,那该如何操作呢?
请看以下代码:private AtomicInteger i; @Setup public void init(){ this.i = new AtomicInteger(); } @GroupThreads(5) @Group("atomicGroup") @Benchmark public void inc(){ this.i.incrementAndGet(); } @GroupThreads(5) @Group("atomicGroup") @Benchmark public void get(Blackhole blackhole){ blackhole.consume(this.i.get()); }
执行结果如下:
Benchmark Mode Cnt Score Error Units JmhHigh.atomicGroup avgt 5 0.087 ± 0.011 us/op JmhHigh.atomicGroup:get avgt 5 0.037 ± 0.007 us/op JmhHigh.atomicGroup:inc avgt 5 0.137 ± 0.022 us/op
线程组名称atomicGroup(5个读线程、5个写线程)
-
我们在“Asymmetric Benchmark”处的代码基础上,并发对BlockingQueue队列进行take/put操作时,在某些情况下程序会出现长时间的阻塞(此处我就不贴代码了)
解决方案:通过配置每一个批次的超时时间
public static void main(String[] args) throws Exception { final Options options = new OptionsBuilder().include(JmhHighBlockingQueue.class.getSimpleName()) //每一批次的超时时间设置为10秒 .timeout(TimeValue.seconds(10)) .build(); new Runner(options).run(); }
JMH的Profiler
-
StackProfiler
可以输出线程堆栈信息,能统计程序在执行的过程中线程状态的数据
例如:RUNNING状态、WAIT状态所占用的百分比等
public static void main(String[] args) throws Exception { final Options options = new OptionsBuilder().include(JmhHigh.class.getSimpleName()) //添加StackProfiler .addProfiler(StackProfiler.class) .build(); new Runner(options).run(); }
执行结果如下:
....[Thread state distributions].................................................................... 98.5% RUNNABLE 1.5% WAITING ....[Thread state: RUNNABLE]........................................................................ 43.9% 44.6% com.xiaozhi.high.JmhHigh.get 43.7% 44.4% com.xiaozhi.high.generated.JmhHigh_atomic_jmhTest.inc_avgt_jmhStub 9.1% 9.2% java.net.SocketInputStream.socketRead0 0.7% 0.7% com.xiaozhi.high.generated.JmhHigh_atomic_jmhTest.atomic_AverageTime 0.5% 0.5% com.xiaozhi.high.generated.JmhHigh_atomic_jmhTest.get_avgt_jmhStub 0.2% 0.2% java.lang.Thread.isInterrupted 0.1% 0.1% sun.misc.Unsafe.unpark 0.1% 0.1% java.util.concurrent.CountDownLatch$Sync.tryReleaseShared 0.0% 0.0% java.util.concurrent.CountDownLatch$Sync.tryAcquireShared 0.0% 0.0% java.lang.Thread.currentThread 0.1% 0.1% <other> ....[Thread state: WAITING]......................................................................... 1.5% 100.0% sun.misc.Unsafe.park
可以看出98.5%的线程处于RUNNABLE状态,1.5%的线程处于WAITING状态
-
-
GcProfiler
可用于分析出在测试方法中垃圾回收器在JVM每个内存空间上所花费的时间
public static void main(String[] args) throws Exception { final Options options = new OptionsBuilder().include(JmhHigh.class.getSimpleName()) //添加GCProfiler .addProfiler(GCProfiler.class) //设置最大堆内存为128M .jvmArgsAppend("-Xmx128m") .build(); new Runner(options).run(); }
-
ClassLoaderProfiler
用于分析有多少类被加载和卸载,此时我们需要将Warmup设置为0
-
ComplierProfiler
用于查看代码执行过程中JIT编译器所花费的优化时间
感谢各位读者的支持,我会尽快更新完毕并发编程整个体系相关知识,一起期待吧!
公众号传送门——《并发编程之JMH》
以下是我码云的地址,会上传并发编程系列的所有代码:
https://gitee.com/songyanzhi/JUC
扫描下方二维码获得更多精彩:
最后祝大家工作愉快、身体健康!