0. 引言

在开发工作中经常会遇到对代码运行速度调优的需要。如何迷信评估调优的成绩?则须要精确计量一个办法运行速度的快慢 -- 迷信的做法是进行微基准测试,从而得出量化的后果。JMH 是由 OpenJDK 提供的对 Java 语言程序进行基准测试的工具。本文将介绍的根本用法和一个实用示例。

相干概念

  • BenchMark:又叫做基准测试,次要用来测试一些办法的性能,能够依据不同的参数以不同的单位进行计算(例如能够应用吞吐量为单位,也能够应用均匀工夫作为单位,在 BenchmarkMode 外面进行调整)。
  • Micro Benchmark:简略地说就是在 method 层面上的 benchmark,精度能够准确到微秒级。
  • OPS, Opeartion Per Second: 每秒操作量,是掂量性能的重要指标,数值越大,性能越好。相似的有 TPS, QPS
  • Throughput 吞吐率:
  • Warmup 预热:为什么须要预热?因为 JVM 的 JIT 机制的存在,如果某个函数被调用屡次之后,JVM 会尝试将其编译成为机器码从而进步执行速度。程序理论运行中会收到 JVM 的主动优化,为了让 Benchmark 的后果更加靠近真实情况就须要进行预热。

1. 什么是 JMH ?

JMH 全称 Java Microbenchmark Harness,是用于构建、运行和剖析以 Java 和其余基于 JVM 的其余语言编写的 nano/micro/milli/macro 基准测试的 Java 工具。

JMH 官网介绍:“JMH is a Java harness for building, running, and analysing nano/micro/milli/macro benchmarks written in Java and other languages targetting the JVM.”

2. JMH 能做什么?

性能介绍

一句话概括 JMH 的作用是度量measure)某个办法的执行耗时,能够通过执行 JMH 测试得出办法执行耗时的量化后果。

应用场景

JMH 适用范围示例:

  • a. 度量某个办法执行耗时
  • b. 度量某个办法执行工夫和输出 n 的相关性
  • c. 评估一个办法的多种不同实现性能体现
  • d. 评估利用中调用的第三方库 API 的执行性能
  • e. b&c 综合利用

JMH 理论利用示例:

  • 评估 ArrayList 遍历性能与输出 n 的相关性
  • 比拟 ArrayList 和 LinkedList 遍历性能与输出 n 的相关性,并比拟差别
  • 评估 redis-client Java 库 put 办法的性能
  • 比拟实现求和的两种办法在 N 次输出下的性能差别,办法 methodSumA 应用了 Stream API,办法 methodSumB 应用了传统遍历累加,须要测试两种办法在不同数据量输出时的体现性能线性变动。通过 JMH 测试,能够对不同量级数据输出时如何抉择合适的求和实现起到指导作用。

3. 如何应用

以比拟 for 循环实现求和与 Stream API 实现求和的办法,在输出数据量级分为别为 10000, 100000, 1000000, 10000000 时求和的性能为例,介绍如何应用 JMH 实现这一测试。

测试程序执行过程形容伪代码:

forLoopMethod() {    loop (size in (10000, 100000, 1000000, 10000000)) {//遍历不同输出数量        doSumFromZeroTo(size);//以 for 循环形式累加 0~size 求和    }}streamMethod() {    loop (size in (10000, 100000, 1000000, 10000000)) {//遍历不同输出数量        doSumFromZeroTo(size);//以 stream sum API 形式累加 0~size 求和    }}

3.1. 创立工程

创立工程

以 Maven 构建的工程为例

应用的 JDK 版本为 1.8

增加以下 dependency 节点向工程中引入依赖

<properties>        <!-- 尽量抉择最新版本 -->    <jmh.version>1.28</jmh.version></properties><properties>    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>    <maven.compiler.source>8</maven.compiler.source>    <maven.compiler.target>8</maven.compiler.target></properties><dependencies>    <dependency>        <groupId>org.openjdk.jmh</groupId>        <artifactId>jmh-core</artifactId>        <version>${jmh.version}</version>    </dependency>    <dependency>        <groupId>org.openjdk.jmh</groupId>        <artifactId>jmh-generator-annprocess</artifactId>        <version>${jmh.version}</version>        <scope>provided</scope>    </dependency></dependencies>

3.2. 参数阐明

运行 JMH 前须进行肯定的设置,JMH 的设置项参数能够通过 new Runner(org.openjdk.jmh.runner.options.Options) 形式注入。

JMH 实现了JSR269标准,即注解处理器,能在编译Java源码的时候,辨认的到须要解决的注解,如@Beanmark,JMH能依据@Beanmark的配置生成一系列测试辅助类。因而也能够通过注解形式注入设置项参数。

以下以注解为例介绍每个设置项参数的作用。

  • @BenchmarkMode

    Mode 示意 JMH 进行 Benchmark 时所应用的模式。通常是测量的维度不同,或是测量的形式不同。目前 JMH 共有四种模式:

    1. Throughput: 整体吞吐量,例如“1秒内能够执行多少次调用”,单位是操作数/工夫。
    2. AverageTime: 调用的均匀工夫,例如“每次调用均匀耗时xxx毫秒”,单位是工夫/操作数。
    3. SampleTime: 随机取样,最初输入取样后果的散布,例如“99%的调用在xxx毫秒以内,99.99%的调用在xxx毫秒以内”。
    4. SingleShotTime: 以上模式都是默认一次 iteration 是 1s,唯有 SingleShotTime 是只运行一次。往往同时把 warmup 次数设为0,用于测试冷启动时的性能。
  • @OutputTimeUnit

    输入的工夫单位。

  • @Iteration

    Iteration 是 JMH 进行测试的最小单位。在大部分模式下,一次 iteration 代表的是一秒,JMH 会在这一秒内一直调用须要 Benchmark 的办法,而后依据模式对其采样,计算吞吐量,计算均匀执行工夫等。

  • @WarmUp

    Warmup 是指在理论进行 Benchmark 前先进行预热的行为。

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

  • @State

    类注解,JMH 测试类必须应用 @State 注解,它定义了一个类实例的生命周期,能够类比 Spring Bean 的 Scope。因为 JMH 容许多线程同时执行测试,不同的选项含意如下:

    1. Scope.Thread:默认的 State,每个测试线程调配一个实例;
    2. Scope.Benchmark:所有测试线程共享一个实例,用于测试有状态实例在多线程共享下的性能;
    3. Scope.Group:每个线程组共享一个实例;
  • @Fork

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

  • @Meansurement

    提供真正的测试阶段参数。指定迭代的次数,每次迭代的运行工夫和每次迭代测试调用的数量(通常应用 @BenchmarkMode(Mode.SingleShotTime) 测试一组操作的开销——而不应用循环)

  • @Setup

    办法注解,会在执行 benchmark 之前被执行,正如其名,次要用于初始化。

  • @TearDown

    办法注解,与@Setup 绝对的,会在所有 benchmark 执行完结当前执行,次要用于资源的回收等。

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

    名称形容
    Level.Trial默认level。全副benchmark运行(一组迭代)之前/之后
    Level.Iteration一次迭代之前/之后(一组调用)
    Level.Invocation每个办法调用之前/之后(不举荐应用,除非你分明这样做的目标)
  • @Benchmark

    办法注解,示意该办法是须要进行 benchmark 的对象。

  • @Param

    成员注解,能够用来指定某项参数的多种状况。特地适宜用来测试一个函数在不同的参数输出的状况下的性能。@Param 注解接管一个String数组,在 @Setup 办法执行前转化为为对应的数据类型。多个 @Param 注解的成员之间是乘积关系,譬如有两个用 @Param 注解的字段,第一个有5个值,第二个字段有2个值,那么每个测试方法会跑5*2=10次。

3.3. 编写测试类

蕴含 main 办法的类和测试内容封装类
  • 蕴含 main 办法的类 TestsMain
package org.example.jmh;import org.example.jmh.tests.IntegerSumTests;import org.openjdk.jmh.annotations.Mode;import org.openjdk.jmh.results.format.ResultFormatType;import org.openjdk.jmh.runner.Runner;import org.openjdk.jmh.runner.RunnerException;import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;import org.openjdk.jmh.runner.options.OptionsBuilder;import java.nio.file.Files;import java.nio.file.Paths;import java.util.concurrent.TimeUnit;import static org.junit.Assert.assertTrue;/** * OptionsTests * created at 2021/4/25 * * @author weny * @since 1.0.0 */public class TestsMain {    //生成的文件门路:{工程根目录}/{reportFileDir}/{XXX.class.getSimpleName()}.json    // e.g. jmh-reports/EmptyMethod.json    private static final String reportFileDir = "jmh-reports/";//    private static final String reportPath = "sample-options-result.json";//生成的文件在工程根目录    /*     * ============================== HOW TO RUN THIS TEST: ====================================     * 1. 批改 Class<IntegerSumTests> targetClazz = IntegerSumTests.class;//须要运行 JMH 测试的类     * 2. 在 IDE 中运行 main 办法     */    public static void main(String[] args) throws RunnerException {        Class<IntegerSumTests> targetClazz = IntegerSumTests.class;//须要运行 JMH 测试的类        String reportFilePath = setupStandardOptions(targetClazz);        assertTrue(Files.exists(Paths.get(reportFilePath)));    }    /**     * 最根底的配置,目标是以最短的耗时测试 JMH 是否能够失常运行     *     * @param targetClazz 要运行 JMH 测试的类     * @throws RunnerException See:{@link RunnerException}     */    @SuppressWarnings({"unused"})    private static String setupBasicOptions(Class<?> targetClazz) throws RunnerException {        // number of iterations is kept to a minimum just to verify that the benchmarks work without spending extra        // time during builds.        String reportFilePath = resolvePath(targetClazz);        ChainedOptionsBuilder optionsBuilder =                new OptionsBuilder()                        .include(targetClazz.getSimpleName())                        .forks(1)                        .warmupIterations(0)                        .measurementBatchSize(1)                        .measurementIterations(1)                        .shouldFailOnError(true)                        .result(reportFilePath)                        .timeUnit(TimeUnit.MICROSECONDS)                        .resultFormat(ResultFormatType.JSON);        new Runner(optionsBuilder.build()).run();        return reportFilePath;    }    /**     * 一份规范的配置,依据理论需要配置预热和迭代等参数     *     * @param targetClazz 要运行 JMH 测试的类     * @throws RunnerException See:{@link RunnerException}     */    private static String setupStandardOptions(Class<?> targetClazz) throws RunnerException {        String reportFilePath = resolvePath(targetClazz);        ChainedOptionsBuilder optionsBuilder =                new OptionsBuilder()                        .include(targetClazz.getSimpleName())                        .mode(Mode.Throughput)//模式-吞吐量 | 注解形式 @BenchmarkMode(Mode.Throughput)                        .forks(1)//Fork进行的数目 | 注解形式 @Fork(2)                        .warmupIterations(1)//预热轮数 | 注解形式 @Warmup(iterations = 1)                        .measurementIterations(3)//度量轮数 | 注解形式 @Measurement(iterations = 3)                        .timeUnit(TimeUnit.MICROSECONDS)//后果所应用的工夫单位 | 注解形式 @OutputTimeUnit(TimeUnit.MILLISECONDS)                        .shouldFailOnError(true)                        .result(reportFilePath)//后果报告文件输入门路                        .resultFormat(ResultFormatType.JSON);//后果报告文件输入格局 JSON        new Runner(optionsBuilder.build()).run();        return reportFilePath;    }    private static String resolvePath(Class<?> targetClazz) {        return reportFileDir + targetClazz.getSimpleName() + ".json";    }}
  • 测试内容封装类 EmptyMethod 蕴含空的办法,用于测试配置是否能够失常运行
package org.example.jmh.tests;import org.openjdk.jmh.annotations.Benchmark;/** * EmptyMethod * created at 2021/4/25 * * @author weny * @since 1.0.0 */public class EmptyMethod {    @Benchmark    public void hello() {        // this method was intentionally left blank.    }}
  • 测试内容封装类 IntegerSumTests 蕴含待度量评估的办法streamSummingInt, forEachPlus
package org.example.jmh.tests;import org.openjdk.jmh.annotations.*;import java.util.Arrays;import java.util.stream.IntStream;/** * IntegerSumTests * created at 2021/4/25 * * @author weny * @since 1.0.0 */public class IntegerSumTests {    // Implementation using stream summingInt    @Benchmark    public int streamSummingInt(Params params) {        return Arrays.stream(params.items).sum();    }    // Implementation using forEach    @Benchmark    public int forEachPlus(Params params) {        int res = 0;        for (int item : params.items) {            res += item;        }        return res;    }    // Define benchmarks parameters with @State    @State(Scope.Benchmark)    public static class Params {        // Run with given size parameters of//        @Param({"1000", "10000", "100000", "1000000"})        @Param({"10000", "100000", "1000000", "10000000"})        public int size;        // Items to run benchmark on        public int[] items;        // Setup test data, will be run once and will not affect our results        @Setup        public void setUp() {            items = IntStream.range(0, size).toArray();        }    }}

3.4. 运行测试

运行测试得出后果数据

运行Main 办法org.example.jmh.TestsMain#main

3.5. 后果剖析

对测试后果数据项进行剖析

运行开始 - 参数打印

# JMH version: 1.28# VM version: JDK 1.8.0_162, Java HotSpot(TM) 64-Bit Server VM, 25.162-b12# VM invoker: /Library/Java/JavaVirtualMachines/jdk1.8.0_162.jdk/Contents/Home/jre/bin/java# VM options: -javaagent:/Applications/IntelliJ IDEA.app/Contents/lib/idea_rt.jar=58962:/Applications/IntelliJ IDEA.app/Contents/bin -Dfile.encoding=UTF-8# Blackhole mode: full + dont-inline hint# Warmup: 1 iterations, 10 s each   ------------------------------ 预热5个迭代,每个迭代10s# Measurement: 3 iterations, 10 s each --------------------------- 正式测试5个迭代,每个迭代10s# Timeout: 10 min per iteration ---------------------------------- 每个迭代的超时工夫10min# Threads: 1 thread, will synchronize iterations ----------------- 应用1个线程测试# Benchmark mode: Throughput, ops/time --------------------------- 应用吞吐量作为测试指标# Benchmark: org.example.jmh.tests.IntegerSumTests.forEachPlus --- 本次迭代测试的指标办法名# Parameters: (size = 10000) ------------------------------------- 本次迭代注入的参数值

运行中 - 阶段信息打印

# Run progress: 12.50% complete, ETA 00:04:47 ---------------------- 运行进度 12.50%# Fork: 1 of 1# Warmup Iteration   1: 32206.476 ops/sIteration   1: 32631.226 ops/sIteration   2: 32725.618 ops/sIteration   3: 32681.244 ops/sResult "org.example.jmh.tests.IntegerSumTests.forEachPlus": -------- 阶段后果统计  32679.362 ±(99.9%) 861.539 ops/s [Average]  (min, avg, max) = (32631.226, 32679.362, 32725.618), stdev = 47.224  CI (99.9%): [31817.824, 33540.901] (assumes normal distribution)    # 统计后果给出了屡次测试后的最小值,最大值和均值,以及标准差 (stdev),置信区间(CI,Confidence interval)# 标准差(stdev)反映了数值绝对于均匀值得离散水平,置信区间是指由样本统计量所结构的总体参数的预计区间。在统计学中,一个概率样本的置信区间(Confidence interval)是对这个样本的某个总体参数的区间预计

运行完结 - 后果打印

# Run complete. Total time: 00:05:26REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up onwhy the numbers are the way they are. Use profilers (see -prof, -lprof), design factorialexperiments, perform baseline and negative tests that provide experimental control, make surethe benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts.Do not assume the numbers tell you what you want them to tell.Benchmark                           (size)   Mode  Cnt       Score       Error  UnitsIntegerSumTests.forEachPlus          10000  thrpt    3  328440.729 ± 92008.121  ops/sIntegerSumTests.forEachPlus         100000  thrpt    3   32679.362 ±   861.539  ops/sIntegerSumTests.forEachPlus        1000000  thrpt    3    2796.351 ±  3273.468  ops/sIntegerSumTests.forEachPlus       10000000  thrpt    3     218.404 ±    36.500  ops/sIntegerSumTests.streamSummingInt     10000  thrpt    3   50423.644 ± 47232.940  ops/sIntegerSumTests.streamSummingInt    100000  thrpt    3    7521.114 ± 47042.316  ops/sIntegerSumTests.streamSummingInt   1000000  thrpt    3     480.979 ±   112.349  ops/sIntegerSumTests.streamSummingInt  10000000  thrpt    3     132.339 ±  1514.091  ops/sBenchmark result is saved to jmh-reports/IntegerSumTests.jsonProcess finished with exit code 0# Benchmark 列示意这次测试比照的办法。# Mode 列表上后果的统计纬度。# Cnt 列示意采样次数,Cnt=Fork*Iteration. # Score 是对这次评测的打分,对于输出数据量 size=10000 时 forEachPlus 操作数为 328440.729 ops/s,streamSummingInt 时 操作数为 50423.644 ops/s, 意味着 size=10000 时 for 循环求和性能优于 stream.sum() 求和。# Error 这里示意性能统计上的误差,咱们不须要关怀这个数据,次要查看 Score

3.6. 后果报告可视化

将后果生成可视化的报表

可选形式:

一、将后果 json 文件上传至 JMH Visualizer 主动生成报表,参考:JMH Visualizer

二、将后果通过第三方报表组件自定义出现,此处不作开展。

4. 注意事项

4.1. 须要思考到虚拟机的优化

编写 JHM 代码,须要思考到虚拟机的优化,而使得测试失真,如下 measureWrong 代码就是所谓的 Dead-Code 代码

@State(Scope.Thread)@BenchmarkMode(Mode.AverageTime)@OutputTimeUnit(TimeUnit.NANOSECONDS)public class JMHSample_08_DeadCode {  private double x = Math.PI;  @Benchmark  public void baseline() {    //基准  }  @Benchmark  public void measureWrong() {    //虚构机会优化掉这部分,性能同baseline    Math.log(x);  }  @Benchmark  public double measureRight() {    // 真正的性能测试    return Math.log(x);  }}

测试后果如下

Benchmark                                               Mode     Score    Units    c.i.c.c.c.i.c.c.j.JMHSample_08_DeadCode.baseline        avgt     0.358    ns/op    c.i.c.c.c.i.c.c.j.JMHSample_08_DeadCode.measureRight    avgt    24.605    ns/op    c.i.c.c.c.i.c.c.j.JMHSample_08_DeadCode.measureWrong    avgt     0.366    ns/op    

在测试 measureWrong 办法,JIT 能揣测出办法体能够被优化调而不影响零碎,measureRight 因为定义了返回值,JIT 不会优化。

4.2. 常量折叠

对于常量折叠,JIT 认为办法计算结果为常量,从而优化间接返回常量给调用者

private double x = Math.PI; private final double wrongX = Math.PI;  @Benchmark  public double baseline() {    // 基准测试    return Math.PI;  }  @Benchmark  public double measureWrong_1() {    // JIT认为是个常量    return Math.log(Math.PI);  }  @Benchmark  public double measureWrong_2() {    // JIT认为办法调用后果是个常量.    return Math.log(wrongX);  }  @Benchmark  public double measureRight() {    // 正确的测试    return Math.log(x);  }

如下是测试后果

Benchmark                                                     Mode    Score   Units           c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.baseline          avgt    1.175   ns/op           c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.measureRight      avgt   25.805   ns/op           c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.measureWrong_1    avgt    1.116   ns/op           c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.measureWrong_2    avgt    1.031   ns/op           

思考到 inline 对性能影响很大,JMH 反对 @CompilerControl 来管制是否容许内联

public class Inline {  int x=0,y=0;  @Benchmark  @CompilerControl(CompilerControl.Mode.DONT_INLINE)  public  int   add(){    return dataAdd(x,y);  }  @Benchmark  public  int  addInline(){    return dataAdd(x,y);  }  private int  dataAdd(int x,int y){    return x+y;  }  @Setup  public void init() {    x = 1;    y = 2;  }}

add 和 addInline 办法都会调用 dataAdd 办法,前者应用 CompilerControl 类,能够用在办法或者类上,来提供编译选项

  • DONT_INLINE,调用办法不内联
  • INLINE,调用办法内联
  • BREAK,插入一个调试断点(TODO,如何调试,参考11章)
  • PRINT,打印办法被 JIT 编译后的机器码信息

开发人员可能感觉下面的测试,add 办法太简略,会习惯性的在 add 办法里方一个循环,以缩小 JMH 调用 add 办法的老本。JMH 不倡议这么做,因为 JIT 会实际上对这种循环会做优化,以打消循环调用老本。如下是个例子能够看到循环测试后果不精确

int x = 1;int y = 2;/** 正确测试*/@Benchmarkpublic int measureRight() {  return (x + y);}private int reps(int reps) {  int s = 0;  for (int i = 0; i < reps; i++) {    s += (x + y);  }  return s;}@Benchmark@OperationsPerInvocation(1)public int measureWrong_1() {  return reps(1);}@Benchmark@OperationsPerInvocation(10)public int measureWrong_10() {  return reps(10);}@Benchmark@OperationsPerInvocation(100)public int measureWrong_100() {  return reps(100);}@Benchmark@OperationsPerInvocation(1000)public int measureWrong_1000() {  return reps(1000);}

注解 OperationsPerInvocation 通知JMH统计性能的时候须要做修改,比方 @OperationsPerInvocation(10) 调用了10次。

性能测试后果如下

编写性能测试的一个好习惯是先编写一个单元测试用例,以确保性能测试准确性,x  Benchmark                                                   Mode   Score   Units    c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureRight           avgt   1.114   ns/op    c.i.c.c.c.i.c.c.j.JMHSample_11_oops.measureWrong_1         avgt   1.057   ns/op    c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureWrong_10        avgt   0.139   ns/op    c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureWrong_100       avgt   0.018   ns/op    c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureWrong_1000      avgt   0.035   ns/op    java

5. 结语

通过 JMH 测试,能够和迷信的评估办法的执行耗时,评估后果能够对性能调优、算法性能预测、服务器基础设施容量布局等行为起到指导作用。量化的后果更有说服力,对后果进一步可视化,将更加直观。

6. 扩大浏览

  • 官网的 Code Sample 写得浅显易懂,举荐在须要具体理解 JMH 的用法时能够通读一遍。
  • 如果应用 IDEA,IntelliJ IDEA 有 JMH 的插件,提供 benchmark 办法的主动生成等便当性能。
  • RPC benchmark实现:java rpc benchmark https://github.com/hank-whu/r...

7. 参考

Reference List:

  • JMH Github Repo
  • OpenJDK JMH Samples
  • JMH Samples 中文版
  • 应用JMH做Benchmark基准测试
  • Java 并发编程笔记:JMH 性能测试框架
  • Java性能优化-把握JMH
  • JMH - Java Microbenchmark Harness
  • JMH Visualizer
  • jmh-visual-chart