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,我们可以量化代码的性能指标,找出性能瓶颈,并验证优化效果。