并发编程之JMH

前言

这是我2021计划中的第一篇文章,今年准备深入探索JUC并发编程。
也准备梳理出整个体系的JUC相关知识,还请大家多多支持!

第一篇文章为啥要选择JMH来展开学习呢?
在后面的原子类体系、并发工具体系、并发容器体系、线程池体系、Stream体系等都会用到此工具进行测试,这也是JVM团队推荐我们使用的测量工具。

JMH简介

JMH是 Java Micro Benchmark Harness 的缩写,是专门用于代码微基准测试的工具集(toolkit)

JMH是由现实的Java虚拟机的团队开发的,因此他们非常清楚开发者所编写的代码在虚拟机中将会如何执行。

由于现代的JVM已经变得越来越智能,在java文件的编译阶段、类的加载阶段,以及运行阶段都可能进行了不同程度的优化,因此开发者编写的代码在运行中未必会像自己所预期的那样具有相同的性能体现,JVM的开发者为了让普通的开发者能够了解自己所写的代码运行情况,JMH因此而生。

上文摘抄自《JAVA高并发编程详解——深入理解并发核心库》

如何使用JMH进行性能检测呢?

  1. 引入pom配置

    <dependency>
        <groupId>org.openjdk.jmhgroupId>
        <artifactId>jmh-coreartifactId>
        <version>1.19version>
    dependency>
    <dependency>
        <groupId>org.openjdk.jmhgroupId>
        <artifactId>jmh-generator-annprocessartifactId>
        <version>1.19version>
        <scope>providedscope>
    dependency>
    
  2. 我们先简单对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在不进行内部扩容数组复制的情况下,随机读写性能要好于LinkedListLinkedList由于是链表设计,其delete操作性能肯定好于ArrayList

JMH的基本用法

  1. @Benchmark注解

    标记基准测试方法,类似于Junit4.x 版本的@Test注解

    如果被此注解标记,该方法会进行基准测试

    注意:如果一个类被OptionsBuilder().include添加为基准测试的class,但是没有一个方法被Benchmark注解标记,则运行会抛异常

  2. @Warmup注解

    直译:预热

    主要作用是基准测试代码正式度量之前,对其进行预热,使代码的执行已经历过类的早期优化、JVM运行期编译、JIT优化之后的最终状态,从而让我们得到代码真实的性能数据

    观察以上代码得知:我们的预热批次为5次

  3. @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();
    }
    
  4. 四大BenchmarkMode

    JMH使用BenchmarkMode注解来声明运行用的哪一种模式,JMH为我们提供了四种运行模式

    1. @BenchmarkMode(Mode.AverageTime)

      Mode.AverageTime:平均响应时间,每一次调用的平均时间

      这也是我们现在代码中所使用的模式

    2. @BenchmarkMode(Mode.Throughput)

      Mode.Throughput:方法吞吐量,单位时间内可以对该方法调用多少次

    3. @BenchmarkMode(Mode.SampleTime)

      Mode.SampleTime:时间采样,收集所有的性能数据,并将其分布在不用的区间

    4. @BenchmarkMode(Mode.SingleShotTime)

      Mode.SingleShotTime:冷测试,每一个基准测试方法只会被执行一次;一般情况下,我们会将Warmup的批次设置为0

  5. @OutputTimeUnit(TimeUnit.MICROSECONDS)

    设置统计结果输出时的单位,此处我们设置的微秒

  6. @State注解

    1. @State(Scope.Thread)

      代表线程独享的State,每一个运行基准测试方法的线程都会持有一个独立的对象实例

      一般用于非线程安全的类

    2. @State(Scope.Benchmark)

      线程共享的state,只有一个实例

      一般用于我们需要测试在多线程情况下某个类被不同线程操作时的性能

    3. @State(Scope.Group)

    线程组共享的state

    一般用于多线程、多方法的场景

    @Group("linked")  //线程组名称
    @GroupThreads(5)  //线程数量
    @Benchmark
    public List<String> linkedListAdd() {
        this.linkedList.add(DATA);
        return this.linkedList;
    }
    

检测Map相关容器的性能

  1. 首先我们使用当前所学知识进行简单检测,其代码如下

    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中也是存在一些套件的,如下:

  1. @Setup注解

    标记的方法会在每个基准测试方法之前被调用,通常用于资源的初始化

    @Setup(Level.Iteration)
    public void setUp() {
        this.arrayList = new ArrayList<>();
        this.linkedList = new LinkedList<>();
    }
    
  2. @TearDown注解

    标记的方法会在每个基准测试方法之后被调用,通常用于资源回收清理工作

    @TearDown
    public void tearDown() {
        System.out.println("测试结束……");
    }
    

​ 以上两个注解均有三种等级:

@Setup(Level.Trial): 默认配置,会在基准方法执行前后执行

@Setup(Level.Iteration): 每个执行批次前后执行

@Setup(Level.Invocation) : 每一个批次的度量过程中,每一次对基准方法的调用前后执行

如何编写正确的微基准测试?

首先为什么会有这样的疑问呢?

因为现在的JAVA虚拟机已经发展得越来越智能了,它在类的早期编译阶段、加载阶段、运行阶段都可以为我们的代码进行一定的优化

例如:Dead Code的擦除、常量的折叠、循环打开、甚至进程的Profiler的优化等

  1. 避免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    510⁻⁴           us/op
    JMHComplier.testNoReturn    avgt    510⁻⁴           us/op
    JMHComplier.testNoReturn02  avgt    510⁻⁴           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.testNoReturnJMHComplier.testNoReturn02方法的执行结果与上次差别很大

    综上所述,JVM会对我们的代码进行一定的优化

    为了测试,我们还可以使用两种方式来禁止JVM优化:

    1. 通过编写程序的方式禁止JVM运行时期动态编译和优化 java.lang.Compiler.disable();
    2. 在启动JVM时增加参数 -Djava.complier=NODE
  2. 使用Blackhole

    直译:黑洞

    用于接收我们想要返回的值

    /**
      * @param blackhole 黑洞,用于接收我们的返回值
      */
    @Benchmark
    public void testByBlackhole(Blackhole blackhole) {
        blackhole.consume(Math.log(PI));
    }
    
  3. 避免常量折叠

    是指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修饰常量,在方法中进行计算操作时,在编译期就进行了优化

  4. 避免循环展开(Loop Unwinding)

    是指我们的循环操作中其实是会发送多次CPU指令的,JVM对于这种情况进行了后期优化,会将我们指令生成批量形式一次性发送

  5. 利用Fork用于避免Profile-guided optimizations

    我们在前面的代码中,有使用到Fork(1)注解

    是指我们的每个基准测试方法执行都会开辟一个全新的JVM进程进行测试,避免强大的JVM带来的Profiler优化,那么多个基准测试之间将不会存在干扰

JMH的一些高级用法

  1. 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个写线程)

  2. 我们在“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

    1. 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状态

  3. 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();
       }
    
  4. ClassLoaderProfiler

    用于分析有多少类被加载和卸载,此时我们需要将Warmup设置为0

  5. ComplierProfiler

    用于查看代码执行过程中JIT编译器所花费的优化时间

感谢各位读者的支持,我会尽快更新完毕并发编程整个体系相关知识,一起期待吧!
公众号传送门——《并发编程之JMH》


以下是我码云的地址,会上传并发编程系列的所有代码:
https://gitee.com/songyanzhi/JUC


扫描下方二维码获得更多精彩:
并发编程之JMH_第1张图片

最后祝大家工作愉快、身体健康!

你可能感兴趣的:(并发编程,java)