JAVA 如何利用 JMH 进行性能基准测试?从编写到结果分析全流程

Java 性能基准测试:JMH 从入门到精通

大家好,今天我们来聊聊 Java 性能基准测试,重点是如何利用 JMH (Java Microbenchmark Harness) 工具进行精确、可靠的性能测量。JMH 是 Oracle 官方提供的基准测试框架,它能帮助我们避免很多常见的性能测试陷阱,从而获得更准确的结果。

1. 为什么要进行性能基准测试?

在软件开发过程中,性能优化是一个重要的环节。但优化之前,我们需要知道:

  • 问题在哪里? 哪些代码是性能瓶颈?
  • 优化效果如何? 新算法比旧算法快多少?
  • 稳定性如何? 优化是否引入了新的问题?

性能基准测试就是为了回答这些问题。它可以帮助我们:

  • 量化性能指标: 比如吞吐量、延迟、CPU 使用率等。
  • 对比不同方案: 比较不同算法、数据结构、配置参数的性能差异。
  • 验证优化效果: 确保优化后的代码性能有所提升。
  • 识别性能瓶颈: 找出影响系统性能的关键因素。

2. JMH 简介

JMH (Java Microbenchmark Harness) 是一个专门为 Java 编写的微基准测试工具。 它旨在提供一种可靠且可重复的方式来衡量 Java 代码片段的性能。 与手动编写测试相比,JMH 具有以下优势:

  • 避免常见陷阱: JMH 会自动处理很多常见的性能测试陷阱,比如死代码消除、常量折叠、JIT 预热等。
  • 统计分析: JMH 会对测试结果进行统计分析,提供平均值、标准差、置信区间等指标,帮助我们更准确地评估性能。
  • 可配置性: JMH 提供了丰富的配置选项,可以根据不同的测试场景进行定制。
  • 易于集成: JMH 可以很容易地集成到现有的构建流程中,比如 Maven、Gradle 等。

3. JMH 核心概念

在开始编写 JMH 测试之前,我们需要了解一些核心概念:

  • Benchmark: 代表一个需要进行性能测试的方法。 使用 @Benchmark 注解标记。
  • State: 定义基准测试运行时的状态。 通过 @State 注解可以定义不同的状态范围,例如 Scope.Thread, Scope.Benchmark, Scope.Group
  • Scope: 状态的范围。
    • Thread: 每个线程拥有一个状态实例。
    • Benchmark: 所有线程共享一个状态实例。
    • Group: 每个线程组拥有一个状态实例。
  • Mode: 基准测试的模式。 常用的模式有 Mode.Throughput, Mode.AverageTime, Mode.SampleTime, Mode.SingleShotTime
  • Warmup: 预热阶段。 在正式测试之前,先运行一段时间,让 JIT 编译器进行优化。
  • Measurement: 正式测试阶段。 记录每次迭代的性能数据。
  • Fork: JMH 会 fork 一个新的 JVM 进程来运行每个基准测试,以避免测试之间的相互干扰。
  • Threads: 执行基准测试的线程数。
  • Iterations: 每次测量阶段的迭代次数。
  • Time Unit: 时间单位,用于报告测试结果。

4. JMH 使用步骤:Maven 项目集成

首先,我们需要创建一个 Maven 项目,并添加 JMH 依赖:

<dependencies>
    <dependency>
        <groupId>org.openjdk.jmh</groupId>
        <artifactId>jmh-core</artifactId>
        <version>1.36</version>
    </dependency>
    <dependency>
        <groupId>org.openjdk.jmh</groupId>
        <artifactId>jmh-generator-annprocess</artifactId>
        <version>1.36</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-shade-plugin</artifactId>
            <executions>
                <execution>
                    <phase>package</phase>
                    <goals>
                        <goal>shade</goal>
                    </goals>
                    <configuration>
                        <finalName>benchmark</finalName>
                        <transformers>
                            <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                <mainClass>org.openjdk.jmh.Main</mainClass>
                            </transformer>
                        </transformers>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

解释:

  • jmh-core: 包含 JMH 核心 API。
  • jmh-generator-annprocess: 用于生成 JMH 代码的注解处理器。 需要设置 scopeprovided,因为在运行时不需要它。
  • maven-compiler-plugin: 用于编译 Java 代码。
  • maven-shade-plugin: 用于创建一个包含所有依赖的 JAR 文件,方便运行 JMH 测试。finalName 指定了输出 JAR 文件的名称为 benchmark.jarmainClass 指定了 JMH 的入口类。

5. 编写 JMH 基准测试

下面是一个简单的 JMH 基准测试示例,比较两种字符串拼接方法的性能:

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
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.Random;
import java.util.concurrent.TimeUnit;

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

    @Param({"10", "100", "1000"})
    public int length;

    private String string1;
    private String string2;

    @Setup(Level.Trial)
    public void setup() {
        Random random = new Random();
        string1 = randomString(length);
        string2 = randomString(length);
    }

    private String randomString(int length) {
        StringBuilder sb = new StringBuilder(length);
        Random random = new Random();
        for (int i = 0; i < length; i++) {
            sb.append((char) (random.nextInt(26) + 'a'));
        }
        return sb.toString();
    }

    @Benchmark
    public String concatWithPlus() {
        return string1 + string2;
    }

    @Benchmark
    public String concatWithStringBuilder() {
        return new StringBuilder(string1).append(string2).toString();
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(StringConcatBenchmark.class.getSimpleName())
                .forks(1)
                .build();

        new Runner(opt).run();
    }
}

解释:

  • @State(Scope.Thread): 定义了一个状态类,每个线程拥有一个 StringConcatBenchmark 实例。
  • @BenchmarkMode(Mode.AverageTime): 指定基准测试模式为平均时间。
  • @OutputTimeUnit(TimeUnit.NANOSECONDS): 指定输出结果的时间单位为纳秒。
  • @Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS): 预热 5 轮,每轮 1 秒。
  • @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS): 测量 5 轮,每轮 1 秒。
  • @Fork(1): fork 1 个 JVM 进程来运行测试。
  • @Param({"10", "100", "1000"}): 定义了参数 length,它的取值可以是 10, 100, 1000。
  • @Setup(Level.Trial): 在每次 Trial (一次完整的测试过程) 之前执行 setup 方法,用于初始化测试数据。
  • @Benchmark: 标记需要进行性能测试的方法。
  • concatWithPlus(): 使用 + 运算符进行字符串拼接。
  • concatWithStringBuilder(): 使用 StringBuilder 进行字符串拼接。
  • main(): 程序入口,用于配置和运行 JMH 测试。 OptionsBuilder 用于构建测试选项,include 方法指定要运行的基准测试类。

6. 运行 JMH 基准测试

  1. 编译项目: 在 Maven 项目的根目录下运行 mvn clean install 命令,编译项目并生成 JAR 文件。
  2. 运行基准测试: 进入 target 目录,找到生成的 benchmark.jar 文件,然后运行以下命令:

    java -jar benchmark.jar

    或者,如果使用了 maven-shade-plugin 并指定了 mainClassorg.openjdk.jmh.Main,可以直接运行:

    java -jar benchmark.jar StringConcatBenchmark

    其中 StringConcatBenchmark 是要运行的基准测试类的名字。

JMH 将会执行基准测试,并在控制台输出结果。

7. 分析 JMH 结果

JMH 的输出结果包含很多信息,我们需要关注以下几个关键指标:

  • Benchmark: 基准测试方法的名称。
  • Mode: 基准测试模式 (例如:thrpt 表示吞吐量,avgt 表示平均时间)。
  • Cnt: 测量轮数。
  • Score: 性能得分。 对于 Mode.Throughput,得分越高越好;对于 Mode.AverageTime,得分越低越好。
  • Error: 误差范围。
  • Units: 性能得分的单位。
  • Param: length: 参数值。

以下是一个可能的 JMH 输出结果示例:

# JMH version: 1.36
# VM version: JDK 1.8.0_202, Java HotSpot(TM) 64-Bit Server VM, 25.202-b08
# VM invoker: /Library/Java/JavaVirtualMachines/jdk1.8.0_202.jdk/Contents/Home/jre/bin/java
# Warmup: 5 iterations, 1 s each
# Measurement: 5 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: com.example.StringConcatBenchmark.concatWithPlus

# Run progress: 0.00% complete, ETA 00:00:20
# Fork: 1 of 1
# Warmup Iteration   1: 15.886 ns/op
# Warmup Iteration   2: 16.002 ns/op
# Warmup Iteration   3: 15.832 ns/op
# Warmup Iteration   4: 15.935 ns/op
# Warmup Iteration   5: 15.892 ns/op
Iteration   1: 15.913 ns/op
Iteration   2: 15.905 ns/op
Iteration   3: 15.868 ns/op
Iteration   4: 15.864 ns/op
Iteration   5: 15.832 ns/op

Result "com.example.StringConcatBenchmark.concatWithPlus":
  15.876 ±(99.9%) 0.089 ns/op [Average]
  (min, avg, max) = (15.832, 15.876, 15.913), stdev = 0.031
  CI (99.9%): [15.787, 15.965] (assumes normal distribution)

# JMH version: 1.36
# VM version: JDK 1.8.0_202, Java HotSpot(TM) 64-Bit Server VM, 25.202-b08
# VM invoker: /Library/Java/JavaVirtualMachines/jdk1.8.0_202.jdk/Contents/Home/jre/bin/java
# Warmup: 5 iterations, 1 s each
# Measurement: 5 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: com.example.StringConcatBenchmark.concatWithStringBuilder

# Run progress: 50.00% complete, ETA 00:00:10
# Fork: 1 of 1
# Warmup Iteration   1: 11.578 ns/op
# Warmup Iteration   2: 11.629 ns/op
# Warmup Iteration   3: 11.569 ns/op
# Warmup Iteration   4: 11.624 ns/op
# Warmup Iteration   5: 11.606 ns/op
Iteration   1: 11.567 ns/op
Iteration   2: 11.561 ns/op
Iteration   3: 11.577 ns/op
Iteration   4: 11.557 ns/op
Iteration   5: 11.584 ns/op

Result "com.example.StringConcatBenchmark.concatWithStringBuilder":
  11.569 ±(99.9%) 0.034 ns/op [Average]
  (min, avg, max) = (11.557, 11.569, 11.584), stdev = 0.012
  CI (99.9%): [11.535, 11.603] (assumes normal distribution)

[INFO] done.
[INFO] exiting normally.

从上面的结果可以看出,使用 StringBuilder 进行字符串拼接的平均时间 (11.569 ns/op) 比使用 + 运算符 (15.876 ns/op) 更短,说明 StringBuilder 的性能更好。

结论:

  • 对于长度为10的字符串,使用 StringBuilder 比使用 + 运算符快大约 27%。
  • 误差范围很小,说明测试结果比较稳定。

8. 常见问题与解决方案

  • 死代码消除 (Dead Code Elimination): JIT 编译器可能会认为某些代码没有实际作用,从而将其优化掉。 为了避免这种情况,可以使用 Blackhole 来消耗结果。
  • 常量折叠 (Constant Folding): JIT 编译器可能会在编译时计算常量表达式的值。 为了避免这种情况,可以使用变量来代替常量。
  • JIT 预热 (JIT Warmup): JIT 编译器需要一段时间才能将代码优化到最佳状态。 因此,在正式测试之前,需要进行预热。
  • 资源争用 (Resource Contention): 如果多个线程同时访问共享资源,可能会导致资源争用。 为了避免这种情况,可以使用线程本地变量或者增加锁的粒度。
  • 不稳定的测试结果: 可能由于系统负载、GC 等因素导致测试结果不稳定。 可以增加测试轮数、预热轮数,或者使用更长的测试时间来减少误差。

9. 高级用法

  • 自定义 State: 可以定义自己的 State 类,用于管理测试数据。
  • Setup/TearDown: 可以使用 @Setup@TearDown 注解来在测试之前和之后执行一些初始化和清理工作。
  • Parametrized Tests: 可以使用 @Param 注解来定义参数,从而运行参数化的测试。
  • Thread Groups: 可以使用 @Group 注解来定义线程组,从而模拟并发场景。
  • Custom Metrics: 可以使用 org.openjdk.jmh.infra.BenchmarkParams 来访问基准测试的参数,并根据参数值来计算自定义的性能指标。
  • 使用 Profiler: JMH 可以集成各种 Profiler 工具,例如 JFR (Java Flight Recorder)、AsyncProfiler 等,来分析代码的性能瓶颈。

10. 实战案例:集合性能比较

假设我们需要比较 ArrayListLinkedList 的插入性能。 我们可以编写以下 JMH 测试:

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 java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;

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

    @Param({"1000", "10000", "100000"})
    public int size;

    private List<Integer> arrayList;
    private List<Integer> linkedList;

    @Setup(Level.Invocation)
    public void setup() {
        arrayList = new ArrayList<>();
        linkedList = new LinkedList<>();
    }

    @Benchmark
    public void arrayListInsert() {
        Random random = new Random();
        arrayList.add(random.nextInt());
    }

    @Benchmark
    public void linkedListInsert() {
        Random random = new Random();
        linkedList.add(random.nextInt());
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(ListInsertBenchmark.class.getSimpleName())
                .forks(1)
                .build();

        new Runner(opt).run();
    }
}

运行结果分析 (简化):

Size ArrayList Insert (ns/op) LinkedList Insert (ns/op)
1000 20 40
10000 25 50
100000 30 60

从结果可以看出,ArrayList 的插入性能优于 LinkedList。 这是因为 ArrayList 使用数组实现,插入元素时只需要移动少量元素;而 LinkedList 使用链表实现,插入元素时需要创建新的节点。

11. 最佳实践

  • 明确测试目标: 在编写基准测试之前,需要明确测试目标,例如要比较哪些算法的性能、要优化哪些代码。
  • 选择合适的基准测试模式: 根据测试目标选择合适的基准测试模式。 例如,如果关注吞吐量,可以使用 Mode.Throughput;如果关注延迟,可以使用 Mode.AverageTime
  • 设置合理的预热和测量参数: 预热和测量参数会影响测试结果的准确性。 需要根据实际情况进行调整。
  • 避免常见的性能测试陷阱: 例如死代码消除、常量折叠、JIT 预热等。
  • 分析测试结果: 仔细分析测试结果,找出性能瓶颈,并提出优化方案。
  • 持续进行性能测试: 在代码修改之后,需要持续进行性能测试,以确保优化效果。

性能测试的总结

今天我们学习了如何使用 JMH 进行 Java 性能基准测试。 从 Maven 项目集成到编写测试代码,再到分析测试结果,希望大家对 JMH 有了更深入的了解。 通过 JMH,我们可以量化代码的性能指标,找出性能瓶颈,并验证优化效果。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注