Java Benchmark使用

参考:https://blog.avenuecode.com/java-microbenchmarks-with-jmh-part-3

如何测量Java代码的性能

在 Java 中,可以使用多种方法来测量一段代码的执行性能。使用 System.currentTimeMillis()是最常见的方法

long startTime = System.currentTimeMillis();

// 需要测量的代码块
for (int i = 0; i < 1000000; i++) {
    // 示例代码
}

long endTime = System.currentTimeMillis();
long duration = endTime - startTime; // 执行时间(毫秒)
System.out.println("Execution time: " + duration + " ms");

但是这么测结果是不太准确的,因为Java存在代码优化以及JIT编译等等,通常更准确的方式是使用Benchmark的方式来评估性能。

JMH基本使用

参考:https://github.com/openjdk/jmh
Java Microbenchmark Harness (JMH) 是一个用于基准测试 Java 代码的工具。它能够准确测量代码的性能,帮助开发者了解不同实现的效率。

首先添加依赖,jmh一般用于test,所以scope可以指定为test

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

示例如下:

package org.example;

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.Arrays;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class JMHExample {

    private int[] numbers;

    public static void main(String[] args) throws Exception {
        Options opts = new OptionsBuilder()
                .include(JMHExample.class.getSimpleName()) // 指定测试的类
                .build();
        new Runner(opts).run();  // 运行
    }

    @Setup(Level.Trial)
    public void setup() {
        numbers = new int[1000];
        for (int i = 0; i < numbers.length; i++) {
            numbers[i] = i;
        }
    }

    @Benchmark
    public int sumArray() {
        int sum = 0;
        for (int number : numbers) {
            sum += number;
        }
        return sum;
    }

    @Benchmark
    public int sumArrayParallel() {
        return Arrays.stream(numbers).parallel().sum();
    }
}

在示例中,其实我们的目的就是比较两种对数组求和方式的性能,看看每秒的吞吐量如何,运行测试,可以在控制台得到相应输出。

常用注解

@Benchmark

@Benchmark 注解是用于标记测试方法的,类似 JUnit 中的 @Test 注解需要单元测试的方法一样,只有被这个注解标记的方法才会参与基准测试,且被标记的方法必须是 public 的。在一个基本测试类中至少包含一个被 @Benchmark 标记的方法,否则会抛出异常。

用于标记基准测试方法,其执行次数并非固定值,而是由 JMH 基于多种策略动态决定的,核心目标是:保证测试结果的统计显著性,同时兼顾测试效率。

@BenchmarkMode

@BenchmarkMode 注解用于指定基准测试的模式。JMH 共有四种模式:

  1. Throughput:整体吞吐量,例如“1 秒内可以执行多少次调用”。
  2. AverageTime:调用的平均时间,例如“每次调用平均耗时 xxx 毫秒”。如果需要测试某个方法的平均耗时,可以使用@BenchmarkMode 注解并指定基准测试的模式为 AverageTime。
  3. SampleTime:随机取样,最后输出取样结果的分布,例如“99%的调用在 xxx 毫秒以内,99.99%的调用在 xxx 毫秒以内”。
  4. SingleShotTime:以上模式都是默认一次 iteration 是 1s,唯有 SingleShotTime 是只运行一次。往往同时把 warmup 次数设为 0,用于测试冷启动时的性能。
  5. All:所有的指标全算一遍

JMH 支持多种测试模式,不同模式下执行逻辑完全不同:

模式(Mode)执行逻辑次数特征
Mode.Throughput(吞吐量)单位时间内执行多少次方法尽可能多,直到时间片耗尽
Mode.AverageTime(平均时间)执行足够多次,计算单次平均耗时次数≥预热 + 测量的最小样本数
Mode.SampleTime(采样时间)随机采样方法执行时间采样次数由参数控制
Mode.SingleShotTime(单次执行)执行 N 次(默认 1 次),测单次耗时固定次数(可配置)
Mode.All同时运行以上所有模式叠加各模式的执行次数

在使用时,@BenchmarkMode 注解可设置在类上也可以设置在基准方法上。例如:

@Benchmark
@BenchmarkMode(Mode.AverageTime)
public void methodToTest() {
    // 测试代码
}

JMH 分预热阶段和测量阶段,两个阶段都会执行 @Benchmark 方法:

  1. 预热(Warmup):JVM 刚启动时存在类加载、JIT 编译、缓存预热等 “冷启动” 问题,预热阶段用于让 JVM 进入稳定状态,预热次数不计入最终结果。
  2. 测量(Measurement):真正统计性能数据的阶段,次数决定结果的可靠性。

@OutputTimeUnit

基准测试结果的时间类型。一般选择秒、毫秒、微秒,这里填入的是 TimeUnit 这个枚举类型,涉及单位很多从纳秒到天都有,按需选择,最终输出易读的结果。

@State

@State 指定了在类中变量的作用范围。@State 用于声明某个类是一个“状态”,可以用Scope 参数用来表示该状态的共享范围。这个注解必须加在类上,否则提示无法运行。它有三个取值。

  1. Benchmark:表示变量的作用范围是某个基准测试类。
  2. Thread:每个线程一份副本,如果配置了Threads注解,则每个Thread都拥有一份变量,它们互不影响。
  3. Group:联系上面的@Group注解,在同一个Group里,将会共享同一个变量实例。

本例中,相关变量的作用范围是 Benchmark。

@Warmup

预热,可以加在类上或者方法上,预热只是测试数据,是不作为测量结果的。

该注解一共有4个参数:

  1. iterations 预热阶段的迭代数
  2. time 每次预热时间
  3. timeUnit 时间单位,通常秒
  4. batchSize 批处理大小,指定每次操作调用几次方法

本例中,我们加在类上,让它迭代3次,每次1秒,时间单位秒。

@Setup

注解的作用就是我们需要在测试之前进行一些准备工作,比如对一些数据的初始化之类的,这个也和Junit的@Before等类似

@Teardown

在测试之后进行一些结束工作,主要用于资源回收

@Measurement

和预热类似,这个注解是会影响测试结果的,它的参数和 Warmup 一样,这里不多介绍。
本例中我们在迭代中设置的是5次,每次1秒。
通常 @Warmup 和 @Measurement 两个参数会一起使用。

@Fork

表示开启几个进程测试,通常我们设为1,如果数值大于1,则启用新的进程测试,如果设置为0,程序依然进行,但是在用户的 JVM 进程上运行。

@Threads

上面的注解注重开启几个进程,这里就是开启几个线程,只有一个参数 value,指定注解的value,将会开启并行测试,如果设置的 value 过大,如 Threads.Max,则使用处理机的相同线程数。

输出格式

public static void main(String[] args) throws RunnerException {
    Options opts = new OptionsBuilder()
            // 表示包含的测试类
            .include(JMHExample.class.getSimpleName()) 
            // 最后结果输出文件的命名,不指定默认为jmh-reuslt.json
            .result("benchmark.json")
            // 结果输出什么格式,可以是json, csv, text等
            .resultFormat(ResultFormatType.JSON)
            .build();
 
    new Runner(opts).run(); // 运行
}

作为开发人员,看懂测试结果没难度,但是测试结果文本能可视化更好。拿到了JMH 结果后,根据文件格式,我们可以二次加工,就可以图表化展示[2]。

JMH 支持的几种输出格式:

  1. TEXT 导出文本文件。
  2. CSV 导出csv格式文件。
  3. SCSV 导出scsv等格式的文件。
  4. JSON 导出成json文件。
  5. LATEX 导出到latex,一种基于ΤΕΧ的排版系统。

比如 CSV 格式的文件,我们就可以通过 EXCEL 处理获取图表,当然也还有其他的一些工具,例如:

  1. https://jmh.morethan.io/ ,参考:https://github.com/jzillmann/jmh-visualizer

在这里插入图片描述

https://jmh.morethan.io/ 这个网站要使用json格式的输出结果

在这里插入图片描述

jmh-generator

JMH生成测试代码的方式有多种,例如jmh-generator-annprocessjmh-generator-reflection 是 JMH(Java Microbenchmark Harness)中用于基准测试生成的两个核心组件。

  1. jmh-generator-annprocess:这个模块用于在编译时处理基准测试的注解。它通过注解处理器生成相应的基准测试代码。
  • 性能:由于是在编译时生成代码,运行时的开销较小,因此性能较好。

  • 类型安全:在编译时生成的代码是类型安全的,可以捕获潜在的错误。

使用场景:适合于需要大量基准测试并且希望在编译时进行优化的场景。

  1. jmh-generator-reflection:这个模块使用反射机制在运行时生成基准测试代码,在运行时读取类的结构,并生成相应的基准测试实现。
  • 优点

    • 灵活性:可以动态生成基准测试,适合那些在编译时无法确定的基准测试场景。
    • 简化:对于简单或快速的基准测试,开发者无需进行额外的编译配置。
  • 缺点

    • 性能开销:由于使用反射,运行时性能开销相对较大。
    • 类型安全:可能导致运行时错误,缺乏编译时检查。

如果需要高性能、类型安全的基准测试,并且可以接受编译时的复杂性。选择 jmh-generator-annprocess,如果希望动态生成基准测试,或者在快速原型开发时需要更灵活的解决方案。选择 jmh-generator-reflection

对应的maven依赖如下

参考:https://mvnrepository.com/artifact/org.openjdk.jmh

<!-- 基于注解处理器生成 -->
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.37</version>
    <scope>test</scope>
</dependency>
<!-- 基于反射生成 -->
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-reflection</artifactId>
    <version>1.37</version>
    <scope>test</scope>
</dependency>
<!-- 基于字节码生成 -->
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-bytecode</artifactId>
    <version>1.37</version>
    <scope>test</scope>
</dependency>

让Benchmark的结果更精确

JVM可能会对基准测试应用一系列优化,而这些优化在原始应用程序中并不会应用,从而使代码看起来比实际更快。这些陷阱可能会使我们的基准测试产生不准确的结果。

循环操作优化

JVM常见的优化就是循环操作, 你可能会觉得将代码放在循环中,这样在每次基准测试迭代中,代码就会被执行更多次,从而减少基准测试方法调用的开销。但这样做是有问题的。只有在循环是你想要基准测试的代码的一部分时,才应该使用循环,也就是说,循环应该位于代码内部,而不是围绕代码。

比如有两个方法需要对比性能,希望各自执行 100000 次,于是将迭代次数设为1,方法内使用循环来跑,其实这样是有问题的,因为循环可能被 JVM 优化

@Benchmark
public void testMethod() {
  for (int i = 0; i < 100000; i++) {
    testMethod();
  }
}

无用代码消除

@Benchmark
public void testMethod() {
    int a = 1;
    int b = 2;
    int sum = a + b;  // 未使用
}
  1. 返回基准方法中的操作结果
@Benchmark
public int testMethod() {
    int a = 1;
    int b = 2;
    int sum = a + b;

    return sum;
}

这种方式并不能解决方法内部存在死代码的问题,比如中间值或其他不会返回的计算结果。这些代码仍然会被消除。

  1. Blackhole
    对于这种情况,JMH提供了Blackhole类。将该值作为方法参数传递给JMH提供的Blackhole类的consume方法即可,如下所示:
@Benchmark
public void testMethod(Blackhole blackhole) {
    int a = 1;
    int b = 2;
    int sum = a + b;
    blackhole.consume(sum);
}

如果基准测试方法产生多个结果,则可以将每个结果传递给Blackhole,即对每个值调用Blackhole实例的consume方法。

常见问题

  1. ERROR: Unable to find the resource: /META-INF/BenchmarkList
Exception in thread "main" java.lang.RuntimeException: ERROR: Unable to find the resource: /META-INF/BenchmarkList
	at org.openjdk.jmh.runner.AbstractResourceReader.getReaders(AbstractResourceReader.java:98)
	at org.openjdk.jmh.runner.BenchmarkList.find(BenchmarkList.java:124)
	at org.openjdk.jmh.runner.Runner.internalRun(Runner.java:252)
	at org.openjdk.jmh.runner.Runner.run(Runner.java:208)
	at org.apache.ibatis.executor.resultset.MetaClassCacheBenchmark.main(MetaClassCacheBenchmark.java:92)

参考:https://stackoverflow.com/questions/38056899/jmh-unable-to-find-the-resource-meta-inf-benchmarklist

使用maven的话,需配置 maven-compiler-plugin 插件处理 JMH 相关注解:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <annotationProcessorPaths>
        <path>
            <groupId>org.openjdk.jmh</groupId>
            <artifactId>jmh-generator-annprocess</artifactId>
            <version>1.37</version>
        </path>
    </annotationProcessorPaths>
  </configuration>
</plugin>
posted @ 2025-11-14 20:56  vonlinee  阅读(18)  评论(0)    收藏  举报