文章目录

 


Java基准测试工具 —— JMH使用指南

一、基准测试

1.1 什么是基准测试

通过设计合理的测试方法,选用合适的测试工具和被测系统,实现对某个特定目的场景中某项性能指标进行定量的测试。

1.2 基准测试的前置准备

面对日益复杂的系统和不断增长的用户数,以及性能测试可能涉及到的多个业务系统,只有做到基准测试所涉及的业务场景、系统架构、测试环境等在可控状态下,才能得到相对准确的结果,为容量规划、缺陷定位、系统调优提供参考和依据。

在做基准测试之前,需要明确以下事宜:

  • 测试目的:明确测试的目的,测试什么?用什么测试方法、策略?
  • 测试环境:被测系统的环境是什么,SIT还是UAT或是PAT?
  • 测试限制:要执行测试有哪些限制因素,该如何解决?
  • 风险因素:测试可能存在哪些风险,解决方案是什么?
  • 结果分析:对测试结果如何分析?测试产生的数据如何分析、定位?

1.3 基准测试的意义

  • 为容量规划确定系统和应用程序的极限;
  • 为配置测试的参数和配置选项提供参考依据;
  • 为验收测试确定系统是否具备自己所宣称的能力;
  • 为性能基线的建立提供长期的数据统计来源以及比较基准;

二、走进JHM

2.1 JHM 介绍

JMH(Java Microbenchmark Harness)是Java用来做基准测试一个工具,该工具由openJDK提供并维护,精度可以达到纳秒级。该工具是由 Oracle 内部实现 JIT 的大牛们编写的,他们应该比任何人都了解 JIT 以及 JVM 对于基准测试的影响。

2.2 相关网站

2.3 快速使用

2.3.1 加入Maven依赖

因为 JMH 是 JDK9 自带的,如果是 JDK9 之前的版本需要加入如下依赖

<!-- https://mvnrepository.com/artifact/org.openjdk.jmh/jmh-core -->
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.23</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.openjdk.jmh/jmh-generator-annprocess  -->
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.23</version>
</dependency>

2.3.2 Hello, JMH!

@Benchmark
public void wellHelloThere() {
    // this method was intentionally left blank.
}

public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder()
        .include(JMHSample_01_HelloWorld.class.getSimpleName())
            .forks(1)
            .build();

    new Runner(opt).run();
}

小tips: 如果在IntelliJ IDEA中运行,必须使用RUN模式,DEBUG将会发生错误。

执行结果如下:

# JMH version: 1.20
# VM version: JDK 1.8.0_211, VM 25.211-b12
# VM invoker: C:\Program Files\Java\jdk1.8.0_211\jre\bin\java.exe
# VM options: -javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2019.1.1\lib\idea_rt.jar=14155:C:\Program Files\JetBrains\IntelliJ IDEA 2019.1.1\bin -Dfile.encoding=UTF-8
# Warmup: 20 iterations, 1 s each
# Measurement: 20 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: com.ryan.jmh.samples.JMHSample_01_HelloWorld.wellHelloThere

# Run progress: 0.00% complete, ETA 00:00:40
# Fork: 1 of 1
# Warmup Iteration   1: 3162763031.952 ops/s
# Warmup Iteration   2: 3445516182.594 ops/s
# Warmup Iteration   3: 3494280466.565 ops/s
# Warmup Iteration   4: 3514088963.742 ops/s
# Warmup Iteration   5: 3364436724.887 ops/s
# Warmup Iteration   6: 3469625274.580 ops/s
# Warmup Iteration   7: 3522140587.189 ops/s
# Warmup Iteration   8: 3519727888.803 ops/s
# Warmup Iteration   9: 3494162461.475 ops/s
# Warmup Iteration  10: 3538139413.054 ops/s
# Warmup Iteration  11: 3475422826.809 ops/s
# Warmup Iteration  12: 3504044391.761 ops/s
# Warmup Iteration  13: 3506663112.889 ops/s
# Warmup Iteration  14: 3475336002.975 ops/s
# Warmup Iteration  15: 3495949837.536 ops/s
# Warmup Iteration  16: 3521985966.441 ops/s
# Warmup Iteration  17: 3519070581.872 ops/s
# Warmup Iteration  18: 3510515044.570 ops/s
# Warmup Iteration  19: 3530753722.221 ops/s
# Warmup Iteration  20: 3513908786.259 ops/s
Iteration   1: 3494271330.746 ops/s
Iteration   2: 3511041932.322 ops/s
Iteration   3: 3511025906.431 ops/s
Iteration   4: 3502423705.566 ops/s
Iteration   5: 3531612857.201 ops/s
Iteration   6: 3511535011.853 ops/s
Iteration   7: 3501219396.429 ops/s
Iteration   8: 3514808802.605 ops/s
Iteration   9: 3514596263.300 ops/s
Iteration  10: 3499207067.515 ops/s
Iteration  11: 3498908587.286 ops/s
Iteration  12: 3532733045.721 ops/s
Iteration  13: 3522054271.841 ops/s
Iteration  14: 3504707678.264 ops/s
Iteration  15: 3507742082.234 ops/s
Iteration  16: 3513982947.949 ops/s
Iteration  17: 3510062787.570 ops/s
Iteration  18: 3543572680.067 ops/s
Iteration  19: 3517795501.455 ops/s
Iteration  20: 3508302153.586 ops/s


Result "com.ryan.jmh.samples.JMHSample_01_HelloWorld.wellHelloThere":
  3512580200.497 ±(99.9%) 10739043.714 ops/s [Average]
  (min, avg, max) = (3494271330.746, 3512580200.497, 3543572680.067), stdev = 12367098.712
  CI (99.9%): [3501841156.783, 3523319244.211] (assumes normal distribution)


# Run complete. Total time: 00:00:40

Benchmark                                Mode  Cnt           Score          Error  Units
JMHSample_01_HelloWorld.wellHelloThere  thrpt   20  3512580200.497 ± 10739043.714  ops/s

通过观察日志可以得出wellHelloThere()函数一秒可以执行3512580200.497(误差 ±10739043.714)次。

三、核心概念

3.1 测试模式

JMH中有四种测试模式,分别是Mode.Throughput、Mode.AverageTime、Mode.SampleTime和Mode.SingleShotTime。

名称 描述
Mode.Throughput 计算吞吐量
Mode.AverageTime 计算平均运行时间
Mode.SampleTime 在测试中,随机进行采样执行的时间
Mode.SingleShotTime 测量单次操作的时间
Mode.All 所有模式依次运行

3.1.1 @BenchmarkMode

在测试方法上添加@BenchmarkMode注解可以指定多种测试模式。

@Benchmark
@BenchmarkMode(Mode.Throughput)
public void wellHelloThere() {
    // this method was intentionally left blank.
}

3.2.2 OptionsBuilder::mode

OptionsBuilder中也有mode方法来添加指定的测试模式。

Options opt = new OptionsBuilder()
        .include(JMHSample_01_HelloWorld.class.getSimpleName())
        .mode(Mode.Throughput)
        .forks(1)
        .build();

3.2 输出时间单位

JMH提供了两种方式来修改默认的输出时间单位。需要注意的是如果在一个测试中指定了多种测试模式,给定的时间单位将用于所有的测试。

3.2.1 @OutputTimeUnit

在测试方法上添加@BenchmarkMode注解可以设置时间单位,它需要一个标准Java类型java.util.concurrent.TimeUnit作为参数。

@Benchmark
@OutputTimeUnit(TimeUnit.SECONDS)
public void measureThroughput() throws InterruptedException {
    TimeUnit.MILLISECONDS.sleep(100);
}

3.2.2 OptionsBuilder::timeout

OptionsBuilder中有timeout方法来添加指定的测试模式。

Options opt = new OptionsBuilder()
        .include(JMHSample_01_HelloWorld.class.getSimpleName())
        .timeout(TimeUnit.SECONDS)
        .forks(1)
        .build();

3.3 分支

默认JMH为每个试验(迭代集合)fork一个新的java进程。这样可以防止前面收集的“资料”——其他被加载类以及它们执行的信息对当前测试的影响。比如,实现了相同接口的两个类,测试它们的性能,那么第一个实现(目标测试类)可能比第二个快,因为JIT发现第二个实现类后就把第一个实现的直接方法调用替换为接口方法调用。

因此,不要把forks设为0,除非你清楚这样做的目的。

极少数情况下需要指定JVM分支数量时,使用@Fork对方法注解,就可以设置分支数量,预热(warmup)迭代数量和JVM分支的其他参数。

可能通过JMH API调用来指定JVM分支参数也有优势——可以使用一些JVM -XX:参数,通过JMH API访问不到它。这样就可以根据你的代码自动选择最佳的JVM设置(new Runner(opt).run()以简便的形式返回了所有的测试结果)。

3.3.1 @Fork

@Benchmark
@Fork(1)
public void foo() {
    //...
}

3.3.2 OptionsBuilder::forks

Options opt = new OptionsBuilder()
        .include(JMHSample_01_HelloWorld.class.getSimpleName())
        .forks(1)
        .build();

3.4 测试阶段其他配置

测试阶段可以指定迭代的次数,每次迭代的运行时间和每次迭代测试调用的数量(通常使用@BenchmarkMode(Mode.SingleShotTime)测试一组操作的开销——而不使用循环)。

3.4.1 @Measurement

配置名称 注解属性
测量迭代次数 @Measurement.iteration
每次测量迭代的时长 @Measurement.time
每次测量迭代的时长单位 @Measurement.timeUnit
每次操作基准方法的调用数 @Measurement.batchSize

3.4.2 OptionsBuilder::measurement*

配置名称 对应方法
测量迭代次数 OptionsBuilder::measurementIterations()
每次测量迭代的时长 OptionsBuilder::measurementTime()
每次测量迭代的时长单位 OptionsBuilder::timeUnit()
每次操作基准方法的调用数 OptionsBuilder::measurementBatchSize()

3.5 预热阶段配置

3.5.1 @Warmup

与@Measurement相同,但是用于预热阶段

3.5.2 OptionsBuilder::warmup*

OptionsBuilder中有5个与@Warmup属性一一对应设置方法,分别为warmupTime(),warmupMode(),warmupIterations(),warmupBatchSize()和warmupForks()。

3.6 线程配置

JMH提供了设置运行基准测试的线程数的实现。

3.6.1 @Threads

该测试使用的线程数。默认是Runtime.getRuntime().availableProcessors()

3.6.2 OptionsBuilder::threads

Options opt = new OptionsBuilder()
       .include(JMHSample_01_HelloWorld.class.getSimpleName())
       .threads(10)
       .build();

3.7 测试参数

测试方法可以接收参数,这需要提供单个的参数类。

@State(Scope.Benchmark)
public static class BenchmarkState {
    volatile double x = Math.PI;
}

@Benchmark
public void measureUnshared(ThreadState state) {
     state.x++;
}

这个类必须遵循以下四条规则:

  • 有无参构造函数(默认构造函数)
  • 必须公共类
  • 如果是内部类,需要是静态内部类
  • 必须使用 @State 注解

3.7.1 测试参数状态

@State注解定义了给定类实例的可用范围。JMH可以在多线程同时运行的环境测试,因此需要选择正确的状态。

名称 描述
Scope.Thread 默认状态。每个测试线程分配一个实例;
Scope.Benchmark 所有测试线程共享一个实例,用于测试有状态实例在多线程共享下的性能;
Scope.Group 每个线程组共享一个实例

除了将单独的参数类标记@State,也可以将你自己的benchmark类使用@State标记。上面所有的规则对这种情况也适用。

@State(Scope.Thread)
public class JMHSample_04_DefaultState {
    //.................
}

3.7.2 状态设置和清理

与JUnit测试类似,使用@Setup和@TearDown注解标记状态类的方法(这些方法在JMH文档中称为fixtures)。setup/teardown方法的数量是任意的。这些方法不会影响测试时间(但是Level.Invocation可能影响测量精度)。

@Setup/@TearDown注解使用Level参数来指定何时调用fixture:

名称 描述
Level.Trial 默认level。全部benchmark运行(一组迭代)之前/之后
Level.Iteration 一次迭代之前/之后(一组调用)
Level.Invocation 每个方法调用之前/之后(不推荐使用,除非你清楚这样做的目的)

3.8 多参数的测试运行

很多情况下测试代码包含多个参数集合。幸运的是,要测试不同参数集合时JMH不会要求写多个测试方法。或者准确来说,测试参数是基本类型,基本包装类型或者String时,JMH提供了解决方法。

程序需要完成:

  • 定义@State对象
  • 在其中定义所有的参数字段
  • 每个字段都使用@Param注解

@Param注解使用String数组作为参数。这些字符串在任何@Setup方法被调用前转换为字段类型。然而,JMH文档中声称这些字段值在@Setup方法中不能被访问。

JMH使用所有@Param字段的输出结果。因此,如果第一个字段有2个参数,第二个字段有5个参数,测试将运行2 * 5 * Forks次。

@State(Scope.Benchmark)
public static class Data {

    @Param({"1", "16", "256"})
    int count;

    byte[] arr;

    @Setup
    public void setup() {
        arr = new byte[count];
        Random random = new Random(1234);
        random.nextBytes(arr);
    }
}

 

附录B. 官方用例

官方用例: JMHSample_01_HelloWorld
官方用例: JMHSample_02_BenchmarkModes
官方用例: JMHSample_03_States
官方用例: JMHSample_04_DefaultState
官方用例: JMHSample_05_StateFixtures
官方用例: JMHSample_06_FixtureLevel
官方用例: JMHSample_07_FixtureLevelInvocation
官方用例: JMHSample_08_DeadCode
官方用例: JMHSample_09_Blackholes
官方用例: JMHSample_10_ConstantFold
官方用例: JMHSample_11_Loops
官方用例: JMHSample_12_Forking
官方用例: JMHSample_13_RunToRun
官方用例: JMHSample_15_Asymmetric
官方用例: JMHSample_16_CompilerControl
官方用例: JMHSample_17_SyncIterations
官方用例: JMHSample_18_Control
官方用例: JMHSample_20_Annotations
官方用例: JMHSample_21_ConsumeCPU
官方用例: JMHSample_22_FalseSharing
官方用例: JMHSample_23_AuxCounters
官方用例: JMHSample_24_Inheritance
官方用例: JMHSample_25_API_GA
官方用例: JMHSample_26_BatchSize
官方用例: JMHSample_27_Params
官方用例: JMHSample_28_BlackholeHelpers
官方用例: JMHSample_29_StatesDAG
官方用例: JMHSample_30_Interrupts
官方用例: JMHSample_31_InfraParams
官方用例: JMHSample_32_BulkWarmup
官方用例: JMHSample_33_SecurityManager
官方用例: JMHSample_34_SafeLooping
官方用例: JMHSample_35_Profilers
官方用例: JMHSample_36_BranchPrediction
官方用例: JMHSample_37_CacheAccess
官方用例: JMHSample_38_PerInvokeSetup