使用JMH做Java微基准测试

背景

在使用Java编程过程中,我们对于一些代码调用的细节有多种编写方式,但是不确定它们性能时,往往采用重复多次计数的方式来解决。但是随着JVM不断的进化,随着代码执行次数的增加,JVM会不断的进行编译优化,使得重复多少次才能够得到一个稳定的测试结果变得让人疑惑,这时候有经验的同学就会在测试执行前先循环上万次并注释为预热。

没错!这样做确实可以获得一个偏向正确的测试结果,但是我们试想如果每到需要斟酌性能的时候,都要根据场景写一段预热的逻辑吗?当预热完成后,需要多少次迭代来进行正式内容的测量呢?每次测试结果的输出报告是不是都需要用System.out来输出呢?

其实这些工作都可以交给 JMH (the Java Microbenchmark Harness) ,它被作为Java9的一部分来发布,但是我们完全不需要等待Java9,而可以方便的使用它来简化我们测试,它能够照看好JVM的预热、代码优化,让你的测试过程变得更加简单。

使用

  1. 首先在项目中新增依赖,jmh-core以及jmh-generator-annprocess的依赖可以在maven仓库中找寻最新版本。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <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>
    </dependency>
  2. 编写基准测试
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    package com.dxy.keygen.core;

    import com.dxy.keygen.utils.KeyGeneratorUtils;
    import com.dxy.keygen.utils.OrderNoGeneratorUtils;
    import org.junit.Test;
    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;

    /**
    * 基准测试
    * @author: peijiepang
    * @date 2018-12-07
    * @Description:
    */
    public class BenchmarkTest {

    /**
    * 基准测试类
    * @throws RunnerException
    */
    @Test
    public void benchmarkTest() throws RunnerException {
    Options opt = new OptionsBuilder()
    .include("defaultKeyGeneratorBenchmarkTest")
    .include("DefaultGeneratorOrderNoBenchmarkTest")
    .include("TimestampGeneratorOrderNoBenchmarkTest")
    .warmupIterations(5)//预热做5轮
    .measurementIterations(10)//正式计量测试做10轮
    .forks(3)//做3轮测试
    .build();
    new Runner(opt).run();
    }

    /**
    * 分布式主键生成基准测试
    */
    @Benchmark
    @BenchmarkMode({Mode.Throughput,Mode.AverageTime})
    public void defaultKeyGeneratorBenchmarkTest() {
    KeyGeneratorUtils.generateKey();
    }

    /**
    * 默认订单号生成基准测试
    */
    @State(Scope.Benchmark)
    public static class DefaultGeneratorOrderNoBenchmarkTest{
    /**
    * 订单号生成init
    */
    @Setup
    public void init() {
    OrderNoGeneratorUtils.init(OrderNoGeneratorUtils.OrderNoGeneratorEnum.DEFAULT);
    System.out.println("init");
    }

    /**
    * 分布式主键生成基准测试
    */
    @Benchmark
    @BenchmarkMode({Mode.Throughput,Mode.AverageTime})
    public void orderNoGeneratorBenchmarkTest() {
    OrderNoGeneratorUtils.generateOrderNo();
    }
    }

    /**
    * 时间戳订单号生成基准测试
    */
    @State(Scope.Benchmark)
    public static class TimestampGeneratorOrderNoBenchmarkTest{
    /**
    * 订单号生成init
    */
    @Setup
    public void init() {
    OrderNoGeneratorUtils.init(OrderNoGeneratorUtils.OrderNoGeneratorEnum.TIMESTAMP);
    System.out.println("init");
    }

    /**
    * 分布式主键生成基准测试
    */
    @Benchmark
    @BenchmarkMode({Mode.Throughput,Mode.AverageTime})
    public void orderNoGeneratorBenchmarkTest() {
    OrderNoGeneratorUtils.generateOrderNo();
    }
    }

    }
  3. 基准测试结果如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    # Run complete. Total time: 00:06:32

    Benchmark Mode Cnt Score Error Units
    BenchmarkTest.DefaultGeneratorOrderNoBenchmarkTest.orderNoGeneratorBenchmarkTest thrpt 30 8612.597 ± 1864.319 ops/s
    BenchmarkTest.TimestampGeneratorOrderNoBenchmarkTest.orderNoGeneratorBenchmarkTest thrpt 30 8899.778 ± 2066.678 ops/s
    BenchmarkTest.defaultKeyGeneratorBenchmarkTest thrpt 30 1024568.969 ± 146.663 ops/s
    BenchmarkTest.DefaultGeneratorOrderNoBenchmarkTest.orderNoGeneratorBenchmarkTest avgt 3010⁻⁴ s/op
    BenchmarkTest.TimestampGeneratorOrderNoBenchmarkTest.orderNoGeneratorBenchmarkTest avgt 3010⁻⁴ s/op
    BenchmarkTest.defaultKeyGeneratorBenchmarkTest avgt 3010⁻⁶ s/op

注解介绍

好了,当你对JMH有了一个基本认识后,现在来详细解释一下前面代码中的各个注解含义。

@BenchmarkMode

基准测试类型。这里选择的是Throughput也就是吞吐量。根据源码点进去,每种类型后面都有对应的解释,比较好理解,吞吐量会得到单位时间内可以进行的操作数。

  • Throughput: 整体吞吐量,例如“1秒内可以执行多少次调用”。
  • AverageTime: 调用的平均时间,例如“每次调用平均耗时xxx毫秒”。
  • SampleTime: 随机取样,最后输出取样结果的分布,例如“99%的调用在xxx毫秒以内,99.99%的调用在xxx毫秒以内”
  • SingleShotTime: 以上模式都是默认一次 iteration 是 1s,唯有 SingleShotTime 是只运行一次。往往同时把 warmup 次数设为0,用于测试冷启动时的性能。
  • All(“all”, “All benchmark modes”);

@Warmup

上面我们提到了,进行基准测试前需要进行预热。一般我们前几次进行程序测试的时候都会比较慢, 所以要让程序进行几轮预热,保证测试的准确性。其中的参数iterations也就非常好理解了,就是预热轮数。

为什么需要预热?因为 JVM 的 JIT 机制的存在,如果某个函数被调用多次之后,JVM 会尝试将其编译成为机器码从而提高执行速度。所以为了让 benchmark 的结果更加接近真实情况就需要进行预热。

@Measurement

度量,其实就是一些基本的测试参数。

  1. iterations 进行测试的轮次
  2. time 每轮进行的时长
  3. timeUnit 时长单位

都是一些基本的参数,可以根据具体情况调整。一般比较重的东西可以进行大量的测试,放到服务器上运行。

@Threads

每个进程中的测试线程,这个非常好理解,根据具体情况选择,一般为cpu乘以2。

@Fork

进行 fork 的次数。如果 fork 数是2的话,则 JMH 会 fork 出两个进程来进行测试。

@OutputTimeUnit

这个比较简单了,基准测试结果的时间类型。一般选择秒、毫秒、微秒。

@Benchmark

方法级注解,表示该方法是需要进行 benchmark 的对象,用法和 JUnit 的 @Test 类似。

@Param

属性级注解,@Param 可以用来指定某项参数的多种情况。特别适合用来测试一个函数在不同的参数输入的情况下的性能。

@Setup

方法级注解,这个注解的作用就是我们需要在测试之前进行一些准备工作,比如对一些数据的初始化之类的。

@TearDown

方法级注解,这个注解的作用就是我们需要在测试之后进行一些结束工作,比如关闭线程池,数据库连接等的,主要用于资源的回收等。

@State

当使用@Setup参数的时候,必须在类上加这个参数,不然会提示无法运行。

State 用于声明某个类是一个“状态”,然后接受一个 Scope 参数用来表示该状态的共享范围。 因为很多 benchmark 会需要一些表示状态的类,JMH 允许你把这些类以依赖注入的方式注入到 benchmark 函数里。Scope 主要分为三种。

  1. Thread: 该状态为每个线程独享。
  2. Group: 该状态为同一个组里面所有线程共享。
  3. Benchmark: 该状态在所有线程间共享。