Java基准测试 Java Microbenchmark Harness (JMH)

382 阅读8分钟

前言

先提出一个问题,大家认为高铁快还是动车快?有人说高铁快,有人说动车快,为什么高铁快呢?因为技术上的指标看高铁是快的,但是为什么又有人说动车快呢?因为有时候会在12306上发现动车的车次比高铁的车次运行时间还长。那么到底谁快谁慢呢?要想回到这个问题,个人觉的要把比较的标准进行统一才能回答,如果以理论实验室速度来进行比较,显然高铁是快的。

gaotie vs dongche.jpeg

同样在实际的开发中,也经常会遇到谁快谁慢的问题,比如 foreach快还是stream.foreach亦或是fori快呢?各种序列化方法(protobuf、kory、hession)哪种更快呢?要想回答上面的问题,或者出自何种原因需要进行性能评估,量化指标同样是十分必要的。

在大部分场合,简单地回答谁快谁慢是远远不够的,通过我们需要提供具体的详细数据来阐述谁快谁慢,那么,如何将程序性能量化呢?本文通过Java Microbenchmark Harness (JMH) 来衡量,对性能指标进行量化。

什么是JMH

Java Microbenchmark Harness (JMH) 是一个 Java 工具,用于构建、运行和分析以 Java 和其他针对 JVM 的语言编写的 nano/micro/milli/macro 基准。

JMH可以做什么

JMH 为openjdk官方提供的基准测量工具,当通过类似于arthas等工具定位到具体热点方法后,通过分析调优,希望进一步优化方法性能的时候,就可以使用 JMH 对优化的结果进行量化的分析。

JMH 比较典型的应用场景如下:

  • 想准确地知道某个方法需要执行多长时间,以及执行时间和输入之间的相关性
  • 对比接口不同实现在给定条件下的吞吐量
  • 查看多少百分比的请求在多长时间内完成

JMH引入

JDK9JMH已被默认引入,但如果是JDK9之前的版本需要加入如下依赖(截止2022-09-23JMH的最新版本为1.35),如果需要获取最新版本数据,可以访问github查看:openjdk/jmh

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.35</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.35</version>
</dependency>

JMH的使用

实际案例

在介绍实际的使用语法前,首先来看个案例。

import com.netflix.hollow.core.memory.ByteArrayOrdinalMap;
import com.netflix.hollow.core.memory.ByteDataArray;
import java.util.SplittableRandom;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(1)
public class OrdinalMapResize {

    @Param("256")
    int n = 256;

    @Param("32")
    int contentSize = 32;

    ByteDataArray[] content;

    @Setup
    public void setUp() {
        SplittableRandom r = new SplittableRandom(0);

        content = new ByteDataArray[n];
        for (int i = 0; i < n; i++) {
            ByteDataArray buf = new ByteDataArray();
            for (int j = 0; j < contentSize; j++) {
                buf.write((byte) r.nextInt(0, 256));
            }
            content[i] = buf;
        }
    }

    @Benchmark
    public ByteArrayOrdinalMap defaultGet() {
        ByteArrayOrdinalMap map = new ByteArrayOrdinalMap();
        for (int i = 0; i < n; i++) {
            map.getOrAssignOrdinal(content[i]);
        }
        return map;
    }

    @Benchmark
    public ByteArrayOrdinalMap sizedGet() {
        ByteArrayOrdinalMap map = new ByteArrayOrdinalMap(n << 1);
        for (int i = 0; i < n; i++) {
            map.getOrAssignOrdinal(content[i]);
        }
        return map;
    }
}

运行结果:

Benchmark                    (contentSize)     (n)  Mode  Cnt          Score          Error  Units
OrdinalMapResize.defaultGet              8    2048  avgt    5     524669.559 ±    94661.993  ns/op
OrdinalMapResize.defaultGet              8    8192  avgt    5    2864984.097 ±   973573.341  ns/op
OrdinalMapResize.defaultGet              8   32768  avgt    5   14644216.493 ±   461996.760  ns/op
OrdinalMapResize.defaultGet              8  131072  avgt    5   64906816.288 ±  5527376.480  ns/op
OrdinalMapResize.defaultGet             16    2048  avgt    5     579804.853 ±    35227.500  ns/op
OrdinalMapResize.defaultGet             16    8192  avgt    5    3226875.857 ±  1200816.411  ns/op
OrdinalMapResize.defaultGet             16   32768  avgt    5   16333522.740 ±  1645155.319  ns/op
OrdinalMapResize.defaultGet             16  131072  avgt    5   72300430.931 ±  8243987.662  ns/op
OrdinalMapResize.defaultGet             32    2048  avgt    5     708348.638 ±    48480.615  ns/op
OrdinalMapResize.defaultGet             32    8192  avgt    5    3845712.806 ±   659749.886  ns/op
OrdinalMapResize.defaultGet             32   32768  avgt    5   19132132.938 ±  2578367.604  ns/op
OrdinalMapResize.defaultGet             32  131072  avgt    5   81810115.046 ±  5377543.308  ns/op
OrdinalMapResize.defaultGet             64    2048  avgt    5     988812.959 ±   139916.394  ns/op
OrdinalMapResize.defaultGet             64    8192  avgt    5    5247170.001 ±   961661.753  ns/op
OrdinalMapResize.defaultGet             64   32768  avgt    5   24915997.247 ±  5133110.667  ns/op
OrdinalMapResize.defaultGet             64  131072  avgt    5  106995398.542 ± 14459051.669  ns/op
OrdinalMapResize.defaultGet            128    2048  avgt    5    1575062.220 ±   110460.192  ns/op
OrdinalMapResize.defaultGet            128    8192  avgt    5    7735092.080 ±   411007.955  ns/op
OrdinalMapResize.defaultGet            128   32768  avgt    5   35920602.082 ±  7833800.705  ns/op
OrdinalMapResize.defaultGet            128  131072  avgt    5  156354984.171 ± 13402008.695  ns/op
OrdinalMapResize.sizedGet                8    2048  avgt    5     196358.861 ±    15371.176  ns/op
OrdinalMapResize.sizedGet                8    8192  avgt    5    1250185.898 ±   337449.745  ns/op
OrdinalMapResize.sizedGet                8   32768  avgt    5    7569535.480 ±   768681.941  ns/op
OrdinalMapResize.sizedGet                8  131072  avgt    5   34166531.026 ±  2601826.357  ns/op
OrdinalMapResize.sizedGet               16    2048  avgt    5     233474.915 ±     4107.039  ns/op
OrdinalMapResize.sizedGet               16    8192  avgt    5    1618315.403 ±   503515.257  ns/op
OrdinalMapResize.sizedGet               16   32768  avgt    5    8423144.202 ±  1531274.146  ns/op
OrdinalMapResize.sizedGet               16  131072  avgt    5   37380396.196 ±  3756140.945  ns/op
OrdinalMapResize.sizedGet               32    2048  avgt    5     291975.497 ±    30660.463  ns/op
OrdinalMapResize.sizedGet               32    8192  avgt    5    1930925.813 ±   162060.695  ns/op
OrdinalMapResize.sizedGet               32   32768  avgt    5    9944128.175 ±  2468853.190  ns/op
OrdinalMapResize.sizedGet               32  131072  avgt    5   43925098.941 ±  3336023.251  ns/op
OrdinalMapResize.sizedGet               64    2048  avgt    5     423357.848 ±    60510.201  ns/op
OrdinalMapResize.sizedGet               64    8192  avgt    5    2553086.582 ±   298923.441  ns/op
OrdinalMapResize.sizedGet               64   32768  avgt    5   12388266.041 ±  4230163.664  ns/op
OrdinalMapResize.sizedGet               64  131072  avgt    5   55145939.840 ±  6016040.570  ns/op
OrdinalMapResize.sizedGet              128    2048  avgt    5     687408.861 ±    39354.473  ns/op
OrdinalMapResize.sizedGet              128    8192  avgt    5    4023545.068 ±   124931.913  ns/op
OrdinalMapResize.sizedGet              128   32768  avgt    5   17758789.247 ±  5952902.676  ns/op
OrdinalMapResize.sizedGet              128  131072  avgt    5   77575714.649 ± 10985599.044  ns/op

以上是在hollow工程中的一个jmh测试的案例。

同样,官方也提供了几十个sample以供参考,地址:jmh-samples

使用jar包执行基准测试

对于一些小测试,直接用上文类似于jmh-samples写一个 main 函数手动执行就好了。

对于大型的测试,需要测试的时间比较久、线程数比较多,加上测试的服务器需要,一般要放在服务器里去执行。

运行 JMH 基准测试的推荐方法是使用 Maven 设置依赖于应用程序的 jar 文件的独立项目。

这种方法是首选,以确保正确初始化基准并产生可靠的结果。 同样,可以在现有项目中运行基准测试,甚至可以从 IDE 中运行,但是设置更复杂,结果也不太可靠。

在所有情况下,使用 JMH 的关键是启用注释或字节码处理器来生成综合基准代码。Maven 原型是用于引导具有正确构建配置的项目的主要机制。 JMH官方强烈建议新用户使用原型来设置正确的环境。

JMH 官方提供了生成 jar 包的方式来执行,我们需要在 maven 里增加一个 plugin,具体配置如下:

<plugins>
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <version>2.4.1</version>
        <executions>
            <execution>
                <phase>package</phase>
                <goals>
                    <goal>shade</goal>
                </goals>
                <configuration>
                    <finalName>jmh-demo</finalName>
                    <transformers>
                        <transformer
                                implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                            <mainClass>org.openjdk.jmh.Main</mainClass>
                        </transformer>
                    </transformers>
                </configuration>
            </execution>
        </executions>
    </plugin>
</plugins>

如果使用的是gradle构建项目工程,改如何做呢?可以按照如下方法:

plugins {
  id "me.champeau.gradle.jmh" version "0.5.3"
}

apply plugin: 'java'
apply plugin: 'me.champeau.gradle.jmh'

dependencies {
  implementation project(':hollow')

  implementation 'org.openjdk.jmh:jmh-core:1.21'
  compileOnly 'org.openjdk.jmh:jmh-generator-annprocess:1.21'
}

compileJava {
  options.compilerArgs << '-XDignore.symbol.file'
  options.fork = true
  options.forkOptions.executable = 'javac'
}

jmh {
  duplicateClassesStrategy = 'warn'
}

命令行执行

创建基准测试项目

以下命令将在 DartifactId 文件夹中生成新的 JMH 驱动项目:

$ mvn archetype:generate \
  -DinteractiveMode=false \
  -DarchetypeGroupId=org.openjdk.jmh \
  -DarchetypeArtifactId=jmh-java-benchmark-archetype \
  -DgroupId=org.sample \
  -DartifactId=test \
  -Dversion=1.0

JMH 支持任意运行可以运行在JVM的编程语言,通过修改DarchetypeArtifactId参数可以调整不同的语言实现。支持的开发语言清单:artifactId list 。除Java外,还支持groovy、kotlin、scala等语言,具体artifactId如下:

  • jmh-java-benchmark-archetype
  • jmh-groovy-benchmark-archetype
  • jmh-kotlin-benchmark-archetype
  • jmh-scala-benchmark-archetype

建立基准测试

项目生成后,可以使用以下 Maven 命令构建它:

$ cd test/
$ mvn clean verify

运行基准测试

构建完成后,将获得独立的可执行jar,其中包含基准测试和所有基本的 JMH 基础架构代码:

$ java -jar target/benchmarks.jar

jmh的指令支持一些参数,如下:

  • 打印帮助信息
    • java -jar hollow-perf/build/libs/hollow-perf-*-jmh.jar -h
  • 执行基准测试
    • java -jar hollow-perf/build/libs/hollow-perf-*-jmh.jar -l

在处理大型项目时,通常将基准测试保存在单独的子项目中,然后通过通常的构建依赖项依赖于测试模块。

JMH基本语法

JMH有提供了20中不同的注解,每种注解都有各自的功能。全部源码地址:jmh-annotations,本小节选择其中的的8中稍微介绍下,

@Benchmark

基准测试注解,在需要测试的方法上添加即可。

@Benchmark
public int checkSum() {
    return typeReadState.getChecksum(schema).intValue();
}

@BenchmarkMode

用来配置 Mode 选项,可用于类或者方法上.

这个注解的 value 是一个数组,可以把几种 Mode 集合在一起执行,如:@BenchmarkMode({Mode.SampleTime, Mode.AverageTime})

还可以设置为 Mode.All,即全部执行一遍。

  • Throughput:整体吞吐量,每秒执行了多少次调用,单位为 ops/time
  • AverageTime:用的平均时间,每次操作的平均时间,单位为 time/op
  • SampleTime:随机取样,最后输出取样结果的分布
  • SingleShotTime:只运行一次,往往同时把 Warmup 次数设为 0,用于测试冷启动时的性能
  • All:上面的所有模式都执行一次

这里有一段源码,这部分源码中的注解也会在后文中有所体现。

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(1)
public class CheckSumCollections {

@Param

指定某项参数的多种情况,特别适合用来测试一个函数在不同的参数输入的情况下的性能,只能作用在字段上,使用该注解必须定义 @State 注解。

@Param("List")
private Type type = Type.Map;

@Param("100")
private int n = 1000;

@Param("100")
private int size = 1000;

@Param("8")
private int shards = 8;

@Param("false")
private boolean remove = false;

@Setup

@Setup注解可以在进行基准测试前生成一些运行的数据。

@Setup
public void setUp() {
    SplittableRandom r = new SplittableRandom(0);

    content = new ByteDataArray[n];
    for (int i = 0; i < n; i++) {
        ByteDataArray buf = new ByteDataArray();
        for (int j = 0; j < contentSize; j++) {
            buf.write((byte) r.nextInt(0, 256));
        }
        content[i] = buf;
    }
}

@State

通过 State 可以指定一个对象的作用范围,JMH 根据 scope 来进行实例化和共享操作。

@State 可以被继承使用,如果父类定义了该注解,子类则无需定义。

由于 JMH 允许多线程同时执行测试,不同的选项含义如下:

  • Scope.Benchmark:所有测试线程共享一个实例,测试有状态实例在多线程共享下的性能
  • Scope.Group:同一个线程在同一个 group 里共享实例
  • Scope.Thread:默认的 State,每个测试线程分配一个实例

@OutputTimeUnit

为统计结果的时间单位,可用于类或者方法注解。

@Warmup

预热所需要配置的一些基本测试参数,可用于类或者方法上。一般前几次进行程序测试的时候都会比较慢,所以要让程序进行几轮预热,保证测试的准确性。参数如下所示:

  • iterations:预热的次数
  • time:每次预热的时间
  • timeUnit:时间的单位,默认秒
  • batchSize:批处理大小,每次操作调用几次方法
  • 为什么需要预热?
    • 因为 JVM 的 JIT 机制的存在,如果某个函数被调用多次之后,JVM 会尝试将其编译为机器码,从而提高执行速度,所以为了让 benchmark 的结果更加接近真实情况就需要进行预热。

@Measurement

实际调用方法所需要配置的一些基本测试参数,可用于类或者方法上,参数和 @Warmup 相同。

@Threads

每个进程中的测试线程,可用于类或者方法上。

@Fork

进行 fork 的次数,可用于类或者方法上。

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

JMH Plugin

openjdk官方提供了jmh的基础能力,但是很多时候并不完善或者官方并不计划提供,这时候社区的大牛会开发很多的扩展插件,本小节将介绍这部分的内容。

Gradle JMH Plugin

JMH官方不为Maven以外的构建系统提供构建脚本。那么对于其他构建工具的项目如何使用呢?这部分的功能有很多社区的大牛创建了不同构建工具的项目,这里我们着重说下Gradle的构建。

官网地址:github.com/melix/jmh-g…

引入依赖

build.gradle的添加jmh的插件。

plugins {
  id "me.champeau.jmh" version "0.6.8"
}

新建jmh目录

由于特定的配置,该插件可以轻松集成到现有项目中。特别是,基准源文件预计可以在 src/jmh 目录中找到:

src/jmh
     |- java       : java sources for benchmarks
     |- resources  : resources for benchmarks

依赖升级

最新的me.champeau.jmh插件使用 JMH 1.35。也可以通过更改依赖项块中的版本来升级版本:

dependencies {
    jmh 'org.openjdk.jmh:jmh-core:0.9'
    jmh 'org.openjdk.jmh:jmh-generator-annprocess:0.9'
}

一个案例

这里列举一个完整的build.gradle文件。

plugins {
    id 'java-library'
    id "me.champeau.jmh" version "0.6.7"
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
}

tasks.named('test') {
    useJUnitPlatform()
}

jmh {
    warmupIterations = 2
    iterations = 5
    fork = 1
}

Intellij IDEA JMH Plugin

Intellij IDEA JMH Plugin插件,允许您以与 JUnit 相同的方式使用 JMH。官网地址:github.com/artyushov/i…

以下是已经实现的功能:

  • @Benchmark方法生成
  • 运行单独的@Benchmark方法
  • 在一个类中运行所有基准测试

image.png

image.png

JMH可视化

如果你想将测试结果以图表的形式可视化,可以试下这些网站:

进入可视化页面,上传JMH的JSON格式结果文件result.json(下载result.json示例文件)进行图表渲染:

image.png

支持切换横向视图和垂直视图。 image.png

总结

Java Microbenchmark Harness (JMH) 提供了强大的基准测试能力,但是实际的开发中使用的并不多,很多时候会使用简单的接口性能对比,然后就可以了,并没有深入的分析每个放的性能具体是多少。

编写更多的性能测试代码显然是需要花费更多的时间精力,但个人觉得是很有必要的,在项目不那么紧张的情况下,建议大家都尝试使用JMH对性能进行一定的研究和深入,这样对于提升自己的能力至关重要。

image.png

参考文献