Java 微基准测试框架 JMH

青苗 青苗 | 114 | 2022-09-13

JMH 介绍
JMH 是一个 Java 工具,用于构建、运行和分析用 Java 和其他针对 JVM 语言编写的微基准测试。

依赖包

  • maven
<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>
  • gradle
implementation group: 'org.openjdk.jmh', name: 'jmh-core', version: '1.32'
implementation group: 'org.openjdk.jmh', name: 'jmh-generator-annprocess', version: '1.32'

使用 @Benchmark 注解进行测试

使用 @Benchmark 注解可以指定需要进行测试的方法。Runner 是 JMH 的入口,Options 是它的入口参数,通过 OptionsBuilder 生成。我们可以在 Options 中对测试进行各项配置。指定类名作为 OptionsBuilder::include 的参数即可设置需要进行测试的类。

package com.example;

import org.openjdk.jmh.annotations.Benchmark;
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;

public class Example_01_HelloJMH {

    @Benchmark
    public String sayHello() {
        return "HELLO JMH!";
    }

    public static void main(String[] args) throws RunnerException {
        Options options = new OptionsBuilder()
                .include(Example_01_HelloJMH.class.getSimpleName())
                .build();
        new Runner(options).run();
    }
}

使用 @BenchmarkMode 指定测试模式

使用 @BenchmarkMode 注解可以指定测试模式,参数是 Mode 类型数组。

@Benchmark
@BenchmarkMode(Mode.Throughput)
public void measureThroughput() throws InterruptedException {
    /* 仅测试吞吐量 */
    TimeUnit.MILLISECONDS.sleep(100);
}

@Benchmark
@BenchmarkMode({Mode.Throughput, Mode.AverageTime, Mode.SampleTime})
public void measureMultiple() throws InterruptedException {
    /* 测试吞吐量、平均时间和抽样时间 */
    TimeUnit.MILLISECONDS.sleep(100);
}

@Benchmark
@BenchmarkMode(Mode.All)
public void measureAll() throws InterruptedException {
    /* 测试所有,即吞吐量、平均时间、抽样时间和启动时间 */
    TimeUnit.MILLISECONDS.sleep(100);
}

Mode 枚举类枚举了 JMH 的测试模式,分别为:

模式介绍单位
Throughout吞吐量,一段时间内程序的执行次数op/time
AverageTime平均时间,执行程序的平均耗时time/op
SampleTime执行时间随机取样,输出执行时间的结果分布time/op
SingleShotTime运行一次,测试冷启动时间time/op
All所有模式/

单位中的 op 代表的是一次操作,默认一次操作指的是执行一次测试方法。但是我们可以指定调用多少次测试方法算作一次操作。在 JMH 中称作操作中的批处理次数,例如我们可以设置执行五次测试方法算作一次操作。

使用 @Measurement 指定测试次数
@Measurement 注解可作用于类或者方法上,用于指定测试的次数、时间和批处理数量,参数为:

  • iterations:测量次数,默认是 5 次。
  • time:单次测量持续时间,默认是 10。
  • timeUnit:时间单位,指定 time 的单位,默认是秒。
  • batchSize:每次操作的批处理次数,默认是 1,即调用一次测试方法算作一次操作。
    四个参数都可以在 Options 中单独指定,优先级是:类 < 方法 < Options
package com.example;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Measurement;
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;

import java.util.concurrent.TimeUnit;

@Measurement(iterations = 3, time = 500, timeUnit = TimeUnit.MILLISECONDS, batchSize = 1)
public class Example_03_Measurement {

    public static void main(String[] args) throws RunnerException {
        Options options = new OptionsBuilder()
                .include(Example_03_Measurement.class.getSimpleName())
                .build();
        new Runner(options).run();
    }

    @Benchmark
    public String hello01() {
        return "Java";
    }

    @Benchmark
    @Measurement(batchSize =  10)
    public String hello02() {
        return "Java";
    }

    @Benchmark
    @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS, batchSize = 1)
    public String hello03() {
        return "Java";
    }
}

使用 @WarmUp 指定预热次数

由于 JVM 会使用 JIT 即时编译器对热点代码进行编译,因此同一份代码可能由于执行次数的增加而导致执行时间差异太大,因此我们可以让代码先预热几轮,预热时间不算入测量计时。@WarmUp 的使用和 @Measurement 一致。

使用 @Fork 设置测试进程数

使用 @Thread 设置执行使用的线程数

避免 JIT 优化

第一点就是使用 @WarmUp 对代码进行预热,防止先后执行的时间差异。但这仅仅只是解决时间先后存在的问题,JIT 还存在着其他非常多的优化手段,这是 JVM 牛逼的地方,但却是 JMH 难受的地方。
JIT 优化技术列表:PerformanceTacticIndex - PerformanceTacticIndex - OpenJDK Wiki (java.net)

防止无用代码消除

对于一些 JIT 判定没有意义的代码,JIT 在执行时会直接将之删除,如:

@Benchmark
public void noCode() {}

@Benchmark
public void doSth() {
    double a = Math.sqrt(x);
}

二者执行结果对比为:

Benchmark                               Mode  Cnt     Score     Error   Units
Example_00_DeadCodeElimination.doSth   thrpt   15  4048.484 ± 129.437  ops/us
Example_00_DeadCodeElimination.noCode  thrpt   15  4117.765 ±  94.406  ops/us

二者执行结果非常相近,Math::sqrt 操作的消耗可是非常高的,但此处由于 a 变量没有任何作用,因此 doSth 中的代码直接被消除掉了,此时只需要将 a 作为返回值或者使用 jmh 提供的 Blackhole 对象接收就可以让代码不被消除:

@Benchmark
public void noCode() {}

@Benchmark
public void receiveByBlackHole(Blackhole blackhole) {
  blackhole.consume(Math.sqrt(x));
}

@Benchmark
public double returnRes() {
   return Math.sqrt(x);
}

执行结果:

Benchmark                                           Mode  Cnt     Score     Error   Units
Example_00_DeadCodeElimination.noCode              thrpt   15  4099.014 ± 110.712  ops/us
Example_00_DeadCodeElimination.receiveByBlackHole  thrpt   15   209.898 ±   4.028  ops/us
Example_00_DeadCodeElimination.returnRes           thrpt   15   209.714 ±   6.090  ops/us

使用 JMH 测试 SpringBoot

我们希望使用 JMH 测试 SpringBoot 的各个组件,比如我们编写的 Controller 或者 Service,由于 Spring Bean 会自动注入依赖,因此我们需要在测试程序中启动 SpringBoot。

package com.example.springbootjmhtest;

import com.example.springbootjmhtest.controller.TestController;
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;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;

import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class SpringBootBenchMark {
    private ConfigurableApplicationContext springContext;
    private TestController testController;

    public static void main(String[] args) throws RunnerException {
        Options options = new OptionsBuilder()
                .include(SpringBootBenchMark.class.getSimpleName())
                .warmupIterations(3)
                .measurementIterations(3)
                .forks(3)
                .build();

        new Runner(options).run();
    }

    @Setup
    public void setUp() {
        // 启动 SpringBoot
        springContext = SpringApplication.run(SpringbootJmhTestApplication.class);
        // 加载 Bean,此时会自动注入 AService 和 BService
        testController = springContext.getBean(TestController.class);
    }

    @TearDown
    public void tearDown() {
        springContext.close();
    }

    @Benchmark
    public void testStringBuffer() {
        // controller 调用 AService 的方法
        testController.testAService();
    }

    @Benchmark
    public void testStringBuilder() {
        // controller 调用 BService 的方法
        testController.testBService();
    }

}

测试结果:

Benchmark                              Mode  Cnt  Score   Error  Units
SpringBootBenchMark.testStringBuffer   avgt    9  1.455 ± 0.024  ms/op
SpringBootBenchMark.testStringBuilder  avgt    9  1.358 ± 0.029  ms/op
文章标签: Java
推荐指数:

真诚点赞 诚不我欺~

Java 微基准测试框架 JMH

点赞 收藏 评论