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 代码的注解处理器。 需要设置scope为provided,因为在运行时不需要它。maven-compiler-plugin: 用于编译 Java 代码。maven-shade-plugin: 用于创建一个包含所有依赖的 JAR 文件,方便运行 JMH 测试。finalName指定了输出 JAR 文件的名称为benchmark.jar,mainClass指定了 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 基准测试
- 编译项目:  在 Maven 项目的根目录下运行 
mvn clean install命令,编译项目并生成 JAR 文件。 - 
运行基准测试: 进入
target目录,找到生成的benchmark.jar文件,然后运行以下命令:java -jar benchmark.jar或者,如果使用了
maven-shade-plugin并指定了mainClass为org.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. 实战案例:集合性能比较
假设我们需要比较 ArrayList 和 LinkedList 的插入性能。  我们可以编写以下 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,我们可以量化代码的性能指标,找出性能瓶颈,并验证优化效果。