您现在的位置是:首页 >技术杂谈 >测试工具之JMH详解网站首页技术杂谈

测试工具之JMH详解

爱吃牛肉的大老虎 2023-05-25 08:00:02
简介测试工具之JMH详解

1 JMH

1.1 引言

在日常开发中,我们对一些代码的调用或者工具的使用会存在多种选择方式,在不确定他们性能的时候,我们首先想要做的就是去测量它。大多数时候,我们会简单的采用多次计数的方式来测量,来看这个方法的总耗时。

但是,如果熟悉 JVM 类加载机制的话,应该知道 JVM 默认的执行模式是 JIT 编译与解释混合执行。

JVM 通过热点代码统计分析,识别高频方法的调用、循环体、公共模块等,基于 JIT 动态编译技术,会将热点代码转换成机器码,直接交给 CPU 执行。
在这里插入图片描述
也就是说,JVM 会不断的进行编译优化,这就使得很难确定重复多少次才能得到一个稳定的测试结果?所以,很多有经验的同学会在测试代码前写一段预热的逻辑。

1.2 简介

JMH,全称 Java Microbenchmark Harness(微基准测试框架),是专门用于 Java 代码微基准测试的一套测试工具 API,是由 OpenJDK/Oracle 官方发布的工具。

何谓 Micro Benchmark 呢?简单地说就是在 method 层面上的 benchmark,精度可以精确到微秒级。

Java 的基准测试需要注意的几个点:

  • 测试前需要预热
  • 防止无用代码进入测试方法中
  • 并发测试
  • 测试结果呈现

JMH 的使用场景:

  • 定量分析某个热点函数的优化效果
  • 想定量地知道某个函数需要执行多长时间,以及执行时间和输入变量的相关性
  • 对比一个函数的多种实现方式

1.3 DEMO演示

1.3.1 测试项目构建

JMH 是内置 Java9 及之后的版本。这里是以 Java17 进行说明。为了方便,这里直接介绍使用 maven 构建 JMH 测试项目的方式

第一种是使用命令行构建,在指定目录下执行以下命令:

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

对应目录下会出现一个 test 项目,打开项目后我们会看到这样的项目结构。
在这里插入图片描述

第二种方式就是直接在现有的 maven 项目中添加 jmh-corejmh-generator-annprocess 的依赖来集成 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>
    <scope>provided</scope>
</dependency>

1.3.2 编写性能测试

这里我以测试 LinkedList 通过 index 方式迭代和 foreach 方式迭代的性能差距为例子,编写测试类

@State(Scope.Benchmark)
@OutputTimeUnit(TimeUnit.SECONDS)
@Threads(Threads.MAX)
public class TestList{
    private static final int SIZE = 10000;

    private List<String> list = new LinkedList<>();

    @Setup
    public void setUp() {
        for (int i = 0; i < SIZE; i++) {
            list.add(String.valueOf(i));
        }
    }

    @Benchmark
    @BenchmarkMode(Mode.Throughput)
    public void forIndexIterate() {
        for (int i = 0; i < list.size(); i++) {
            list.get(i);
            System.out.print("");
        }
    }

    @Benchmark
    @BenchmarkMode(Mode.Throughput)
    public void forEachIterate() {
        for (String s : list) {
            System.out.print("");
        }
    }
}

1.3.3 执行测试

运行 JMH 基准测试有两种方式,一个是生产 jar 文件运行,另一个是直接写 main 函数或者放在单元测试中执行。

生成 jar 文件的形式主要是针对一些比较大的测试,可能对机器性能或者真实环境模拟有一些需求,需要将测试方法写好了放在 linux 环境执行。
具体命令如下:

$ mvn clean install
$ java -jar target/benchmarks.jar

我们日常中遇到的一般是一些小测试,比如我上面写的例子,直接在 IDE 中跑就好了。
启动方式如下:

public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(LinkedListIterationBenchMark.class.getSimpleName())
                .forks(1)
                .warmupIterations(2)
                .measurementIterations(2)
                .output("E:/Benchmark.log")
                .build();

        new Runner(opt).run();
    }

1.3.4 报告结果

最后的输出结果如下:

Benchmark                  Mode  Cnt    Score   Error  Units
TestList.forEachIterate   thrpt    2  590.884          ops/s
TestList.forIndexIterate  thrpt    2  179.808          ops/s

整个文档:

# Detecting actual CPU count: 8 detected
# JMH version: 1.35
# VM version: JDK 17.0.6, Java HotSpot(TM) 64-Bit Server VM, 17.0.6+9-LTS-190
# VM invoker: D:SoftWareEnvironmentsjdk-17.0.6injava.exe
# VM options: -javaagent:D:SoftWareCodesIdeaIntelliJ IDEA 2022.1.4libidea_rt.jar=54795:D:SoftWareCodesIdeaIntelliJ IDEA 2022.1.4in -Dfile.encoding=UTF-8
# Blackhole mode: compiler (auto-detected, use -Djmh.blackhole.autoDetect=false to disable)
# Warmup: 2 iterations, 10 s each
# Measurement: 2 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 8 threads, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: cn.test.TestList.forEachIterate

# Run progress: 0.00% complete, ETA 00:01:20
# Fork: 1 of 1
# Warmup Iteration   1: 587.183 ops/s
# Warmup Iteration   2: 606.869 ops/s
Iteration   1: 582.747 ops/s
Iteration   2: 599.022 ops/s


Result "cn.test.TestList.forEachIterate":
  590.884 ops/s


# JMH version: 1.35
# VM version: JDK 17.0.6, Java HotSpot(TM) 64-Bit Server VM, 17.0.6+9-LTS-190
# VM invoker: D:SoftWareEnvironmentsjdk-17.0.6injava.exe
# VM options: -javaagent:D:SoftWareCodesIdeaIntelliJ IDEA 2022.1.4libidea_rt.jar=54795:D:SoftWareCodesIdeaIntelliJ IDEA 2022.1.4in -Dfile.encoding=UTF-8
# Blackhole mode: compiler (auto-detected, use -Djmh.blackhole.autoDetect=false to disable)
# Warmup: 2 iterations, 10 s each
# Measurement: 2 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 8 threads, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: cn.test.TestList.forIndexIterate

# Run progress: 50.00% complete, ETA 00:00:41
# Fork: 1 of 1
# Warmup Iteration   1: 178.333 ops/s
# Warmup Iteration   2: 178.797 ops/s
Iteration   1: 180.050 ops/s
Iteration   2: 179.565 ops/s


Result "cn.test.TestList.forIndexIterate":
  179.808 ops/s


# Run complete. Total time: 00:01:22

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.

NOTE: Current JVM experimentally supports Compiler Blackholes, and they are in use. Please exercise
extra caution when trusting the results, look into the generated code to check the benchmark still
works, and factor in a small probability of new VM bugs. Additionally, while comparisons between
different JVMs are already problematic, the performance difference caused by different Blackhole
modes can be very significant. Please make sure you use the consistent Blackhole mode for comparisons.

Benchmark                  Mode  Cnt    Score   Error  Units
TestList.forEachIterate   thrpt    2  590.884          ops/s
TestList.forIndexIterate  thrpt    2  179.808          ops/s

1.4 注解介绍

下面我们来详细介绍一下相关的注解

1.4.1 @BenchmarkMode

微基准测试类型。JMH 提供了以下几种类型进行支持:

类型描述
Throughput每段时间执行的次数,一般是秒
AverageTime平均时间,每次操作的平均耗时
SampleTime在测试中,随机进行采样执行的时间
SingleShotTime在每次执行中计算耗时
All所有模式

可以注释在方法级别,也可以注释在类级别

@BenchmarkMode(Mode.All)
public class LinkedListIterationBenchMark {
    ...
}
@Benchmark
@BenchmarkMode({Mode.Throughput, Mode.SingleShotTime})
public void m() {
    ...
}

1.4.2 @Warmup

这个单词的意思就是预热iterations = 3 就是指预热轮数

@Benchmark
@BenchmarkMode({Mode.Throughput, Mode.SingleShotTime})
@Warmup(iterations = 3)
public void m() {
    ...
}

1.4.3 @Measurement

正式度量计算的轮数:

  • iterations:进行测试的轮次
  • time:每轮进行的时长
  • timeUnit:时长单位
@Benchmark
@BenchmarkMode({Mode.Throughput, Mode.SingleShotTime})
@Measurement(iterations = 3)
public void m() {
    ...
}

1.4.4 @Threads

每个进程中的测试线程

@Threads(Threads.MAX)
public class LinkedListIterationBenchMark {
    ...
}

1.4.5 @Fork

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

@Benchmark
@BenchmarkMode({Mode.Throughput, Mode.SingleShotTime})
@Fork(value = 3)
public void m() {
    ...
}

1.4.6 @OutputTimeUnit

基准测试结果的时间类型。一般选择秒、毫秒、微秒

@OutputTimeUnit(TimeUnit.SECONDS)
public class LinkedListIterationBenchMark {
    ...
}

1.4.7 @Benchmark

方法级注解,表示该方法是需要进行 benchmark 的对象,用法和 JUnit@Test 类似

1.4.8 @Param

属性级注解,@Param 可以用来指定某项参数的多种情况。特别适合用来测试一个函数在不同的参数输入的情况下的性能

1.4.9 @Setup

方法级注解,这个注解的作用就是我们需要在测试之前进行一些准备工作,比如对一些数据的初始化之类的

1.4.10 @TearDown

方法级注解,这个注解的作用就是我们需要在测试之后进行一些结束工作,比如关闭线程池,数据库连接等的,主要用于资源的回收等。

1.4.11 @State

当使用 @Setup 参数的时候,必须在类上加这个参数,不然会提示无法运行。就比如我上面的例子中,就必须设置 state

State 用于声明某个类是一个状态,然后接受一个 Scope 参数用来表示该状态的共享范围。
因为很多 benchmark 会需要一些表示状态的类,JMH 允许你把这些类以依赖注入的方式注入到 benchmark 函数里。

其中的参数Scope 主要分为三种:

  • Thread:该状态为每个线程独享。
  • Group:该状态为同一个组里面所有线程共享。
  • Benchmark:该状态在所有线程间共享。

1.5 启动方法解释

在启动方法中,可以直接指定上述说到的一些参数,并且能将测试结果输出到指定文件中。

/**
* 仅限于IDE中运行
* 命令行模式 则是 build 然后 java -jar 启动
*
* 1. 这是benchmark 启动的入口
* 2. 这里同时还完成了JMH测试的一些配置工作
* 3. 默认场景下,JMH会去找寻标注了@Benchmark的方法,可以通过include和exclude两个方法来完成包含以及排除的语义
*/
public static void main(String[] args) throws RunnerException {
   Options opt = new OptionsBuilder()
           // 包含语义
           // 可以用方法名,也可以用XXX.class.getSimpleName()
           .include("Helloworld")
           // 排除语义
           .exclude("Pref")
           // 预热10轮
           .warmupIterations(10)
           // 代表正式计量测试做10轮,
           // 而每次都是先执行完预热再执行正式计量,
           // 内容都是调用标注了@Benchmark的代码。
           .measurementIterations(10)
           //  forks(3)指的是做3轮测试,
           // 因为一次测试无法有效的代表结果,
           // 所以通过3轮测试较为全面的测试,
           // 而每一轮都是先预热,再正式计量。
           .forks(3)
           .output("E:/Benchmark.log")
           .build();

   new Runner(opt).run();
}
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。