2

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 能做什么?

功能介绍

"If you cannot measure it, you cannot improve it".

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/s
Iteration   1: 32631.226 ops/s
Iteration   2: 32725.618 ops/s
Iteration   3: 32681.244 ops/s


Result "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:26

REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on
why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial
experiments, perform baseline and negative tests that provide experimental control, make sure
the 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  Units
IntegerSumTests.forEachPlus          10000  thrpt    3  328440.729 ± 92008.121  ops/s
IntegerSumTests.forEachPlus         100000  thrpt    3   32679.362 ±   861.539  ops/s
IntegerSumTests.forEachPlus        1000000  thrpt    3    2796.351 ±  3273.468  ops/s
IntegerSumTests.forEachPlus       10000000  thrpt    3     218.404 ±    36.500  ops/s
IntegerSumTests.streamSummingInt     10000  thrpt    3   50423.644 ± 47232.940  ops/s
IntegerSumTests.streamSummingInt    100000  thrpt    3    7521.114 ± 47042.316  ops/s
IntegerSumTests.streamSummingInt   1000000  thrpt    3     480.979 ±   112.349  ops/s
IntegerSumTests.streamSummingInt  10000000  thrpt    3     132.339 ±  1514.091  ops/s

Benchmark result is saved to jmh-reports/IntegerSumTests.json

Process 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;

/** 正确测试
*/
@Benchmark
public 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. 扩展阅读

7. 参考

Reference List:


Jooohn
27 声望3 粉丝