使用 JMH 进行 Java 代码性能基准测试:一场代码性能的探索之旅
大家好!今天我们来聊聊如何使用 JMH(Java Microbenchmark Harness)进行 Java 代码的性能基准测试。作为一名程序员,优化代码性能是我们的职责之一,而 JMH 则是我们手中的利器,帮助我们客观、准确地衡量代码的性能。
为什么要进行性能基准测试?
在深入 JMH 之前,我们先来探讨一下为什么要进行性能基准测试。原因有很多:
- 优化代码: 通过基准测试,我们可以了解代码的瓶颈所在,从而有针对性地进行优化。
- 比较算法: 当面临多种算法选择时,基准测试可以帮助我们选择性能最佳的算法。
- 评估框架/库: 在选择第三方框架或库时,基准测试可以帮助我们评估它们的性能表现,避免引入性能瓶颈。
- 验证优化效果: 在进行代码优化后,基准测试可以帮助我们验证优化效果是否达到预期。
- 避免微优化: 避免将时间浪费在对性能影响甚微的优化上。
然而,编写可靠的性能测试并非易事。一些常见的陷阱包括:
- 不正确的测试环境: 测试环境与实际生产环境差异过大,导致测试结果不准确。
- 不充分的预热: JVM 需要时间来预热,如果测试时间过短,会导致结果不准确。
- 忽略 JIT 编译: JVM 的 JIT (Just-In-Time) 编译器会将热点代码编译为机器码,从而提高性能。如果测试没有充分考虑 JIT 编译,会导致结果不准确。
- 死代码消除: JVM 可能会优化掉没有实际作用的代码,导致测试结果失真。
- 统计偏差: 由于系统噪音和 JVM 的不确定性,单次测试结果可能存在偏差。
JMH 正是为了解决这些问题而诞生的。它提供了一套完整的框架,帮助我们编写可靠、准确的性能基准测试。
JMH 简介
JMH 是一个由 Oracle 开发的 Java 微基准测试工具包。它旨在消除常见的性能测试陷阱,并提供一套易于使用的 API,帮助我们编写高质量的性能基准测试。JMH 的主要特点包括:
- 避免死代码消除: JMH 会确保测试代码被实际执行,避免被 JVM 优化掉。
- 自动预热: JMH 会自动进行预热,让 JVM 充分进行 JIT 编译。
- 统计分析: JMH 会进行多次迭代测试,并对结果进行统计分析,提供可靠的性能指标。
- 多种测试模式: JMH 支持多种测试模式,例如吞吐量、平均执行时间、单次执行时间等。
- 可配置性: JMH 提供了丰富的配置选项,可以根据需要调整测试参数。
- 易于使用: JMH 提供了简单的注解 API,使得编写基准测试代码非常方便。
JMH 基础:注解与配置
JMH 的核心是基于注解的。通过使用不同的注解,我们可以定义基准测试的方法、测试模式、预热策略等。以下是一些常用的 JMH 注解:
注解 | 描述 |
---|---|
@Benchmark |
标记一个方法为基准测试方法。 |
@Setup |
标记一个方法为 setup 方法,在每次测试迭代前执行。可以指定 Level 来控制执行时机(Trial 、Iteration 、Invocation )。 |
@TearDown |
标记一个方法为 teardown 方法,在每次测试迭代后执行。同样可以指定 Level 。 |
@State |
标记一个类为状态类,用于保存测试所需的状态。可以指定 Scope 来控制状态的共享范围(Thread 、Benchmark 、Group )。 |
@Param |
标记一个字段为参数,JMH 会自动生成不同参数值的测试。 |
@BenchmarkMode |
指定基准测试的模式,例如 Mode.Throughput (吞吐量)、Mode.AverageTime (平均执行时间)、Mode.SampleTime (采样时间)等。 |
@OutputTimeUnit |
指定测试结果的时间单位。 |
@Warmup |
配置预热参数,例如预热迭代次数、预热时间等。 |
@Measurement |
配置测试迭代参数,例如测试迭代次数、测试时间等。 |
@Fork |
配置 fork 进程的数量,用于隔离测试环境。 |
除了注解,我们还可以通过 Runner
类来配置 JMH 运行参数。例如,我们可以指定报告的输出格式、过滤器等。
JMH 实战:第一个基准测试
让我们通过一个简单的例子来了解如何使用 JMH 进行基准测试。假设我们想比较两种字符串拼接方法的性能:使用 +
运算符和使用 StringBuilder
。
首先,我们需要创建一个 Maven 项目,并在 pom.xml
文件中添加 JMH 的依赖:
<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>
然后,我们可以创建一个名为 StringBenchmark
的类,并编写基准测试代码:
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.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(1)
public class StringBenchmark {
@Param({"10", "100", "1000"})
public int length;
private String baseString;
@Setup(Level.Trial)
public void setup() {
Random random = new Random();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append((char) (random.nextInt(26) + 'a'));
}
baseString = sb.toString();
}
@Benchmark
public void testStringConcat(Blackhole blackhole) {
String result = "";
for (int i = 0; i < length; i++) {
result += baseString;
}
blackhole.consume(result); // Prevent dead code elimination
}
@Benchmark
public void testStringBuilder(Blackhole blackhole) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append(baseString);
}
String result = sb.toString();
blackhole.consume(result); // Prevent dead code elimination
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(StringBenchmark.class.getSimpleName())
.forks(1)
.build();
new Runner(options).run();
}
}
让我们来分析一下这段代码:
@State(Scope.Thread)
:表示StringBenchmark
类的实例是线程私有的。@BenchmarkMode(Mode.Throughput)
:表示测试模式为吞吐量,即每秒执行的次数。@OutputTimeUnit(TimeUnit.MILLISECONDS)
:表示测试结果的时间单位为毫秒。@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
:表示预热 5 次迭代,每次迭代 1 秒。@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
:表示测试 5 次迭代,每次迭代 1 秒。@Fork(1)
:表示 fork 1 个进程来运行测试,用于隔离测试环境。@Param({"10", "100", "1000"})
:表示length
字段有三个参数值:10、100 和 1000。JMH 会自动生成不同参数值的测试。@Setup(Level.Trial)
:表示setup
方法在每次测试 trial 前执行,用于初始化测试数据。@Benchmark
:标记testStringConcat
和testStringBuilder
方法为基准测试方法。Blackhole blackhole
:用于防止死代码消除。JMH 会确保blackhole.consume(result)
被实际执行,即使result
没有被使用。
要运行基准测试,我们可以使用 main
方法:
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(StringBenchmark.class.getSimpleName())
.forks(1)
.build();
new Runner(options).run();
}
运行后,JMH 会输出测试结果,如下所示:
Benchmark (length) Mode Cnt Score Error Units
StringBenchmark.testStringConcat 10 thrpt 5 19.382 ± 0.531 ops/ms
StringBenchmark.testStringConcat 100 thrpt 5 0.202 ± 0.005 ops/ms
StringBenchmark.testStringConcat 1000 thrpt 5 0.002 ± 0.001 ops/ms
StringBenchmark.testStringBuilder 10 thrpt 5 28.227 ± 2.007 ops/ms
StringBenchmark.testStringBuilder 100 thrpt 5 2.968 ± 0.277 ops/ms
StringBenchmark.testStringBuilder 1000 thrpt 5 0.310 ± 0.036 ops/ms
从结果可以看出,使用 StringBuilder
进行字符串拼接的性能明显优于使用 +
运算符。尤其是在字符串长度较大时,StringBuilder
的优势更加明显。
高级用法:状态、参数、模式
除了基本的基准测试,JMH 还提供了许多高级用法,可以帮助我们更准确、更全面地评估代码的性能。
1. 状态 (State)
@State
注解用于定义测试所需的状态。JMH 支持三种状态范围:
Scope.Thread
:每个线程拥有一个状态实例。Scope.Benchmark
:每个基准测试拥有一个状态实例。Scope.Group
:每个测试组拥有一个状态实例。
例如,我们可以使用 @State
来定义一个随机数生成器的状态:
@State(Scope.Thread)
public class RandomState {
public Random random = new Random();
}
@Benchmark
public void testRandom(RandomState state) {
state.random.nextInt();
}
2. 参数 (Param)
@Param
注解用于定义测试参数。JMH 会自动生成不同参数值的测试。
例如,我们可以使用 @Param
来定义一个数组大小的参数:
@State(Scope.Thread)
public class ArrayState {
@Param({"10", "100", "1000"})
public int size;
public int[] array;
@Setup(Level.Trial)
public void setup() {
array = new int[size];
}
}
@Benchmark
public void testArrayAccess(ArrayState state) {
for (int i = 0; i < state.size; i++) {
state.array[i] = i;
}
}
3. 模式 (Mode)
@BenchmarkMode
注解用于指定基准测试的模式。JMH 支持多种测试模式:
Mode.Throughput
:吞吐量,即每秒执行的次数。Mode.AverageTime
:平均执行时间,即每次执行的平均时间。Mode.SampleTime
:采样时间,即随机采样执行时间。Mode.SingleShotTime
:单次执行时间,即单次执行的时间。Mode.All
:所有模式。
例如,我们可以使用 @BenchmarkMode
来测试平均执行时间:
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class AverageTimeBenchmark {
@Benchmark
public int testAdd() {
return 1 + 1;
}
}
避免常见错误
在使用 JMH 进行基准测试时,需要注意一些常见的错误,以确保测试结果的准确性。
- 死代码消除: JVM 可能会优化掉没有实际作用的代码,导致测试结果失真。可以使用
Blackhole
来防止死代码消除。 - 不充分的预热: JVM 需要时间来预热,如果测试时间过短,会导致结果不准确。可以使用
@Warmup
注解来配置预热参数。 - 忽略 JIT 编译: JVM 的 JIT 编译器会将热点代码编译为机器码,从而提高性能。如果测试没有充分考虑 JIT 编译,会导致结果不准确。可以使用
@Fork
注解来隔离测试环境,并确保预热时间足够长。 - 统计偏差: 由于系统噪音和 JVM 的不确定性,单次测试结果可能存在偏差。可以使用
@Measurement
注解来配置测试迭代参数,并对结果进行统计分析。 - 不正确的测试环境: 测试环境与实际生产环境差异过大,导致测试结果不准确。尽量在与生产环境相同的硬件和软件配置下进行测试。
- 过度优化: 过度优化可能会导致代码可读性降低,维护成本增加。在进行优化时,需要权衡性能和可维护性。
案例分析:HashMap vs TreeMap
我们来分析一个更复杂的案例:比较 HashMap
和 TreeMap
的性能。HashMap
基于哈希表实现,提供快速的查找性能,但不保证元素的顺序。TreeMap
基于红黑树实现,可以保证元素的顺序,但查找性能略逊于 HashMap
。
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.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.TreeMap;
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 MapBenchmark {
@Param({"100", "1000", "10000"})
public int size;
private Map<Integer, Integer> hashMap;
private Map<Integer, Integer> treeMap;
private Random random;
@Setup(Level.Trial)
public void setup() {
hashMap = new HashMap<>();
treeMap = new TreeMap<>();
random = new Random();
for (int i = 0; i < size; i++) {
int key = random.nextInt(size * 10); // Ensure some key collisions
hashMap.put(key, i);
treeMap.put(key, i);
}
}
@Benchmark
public void testHashMapGet(Blackhole blackhole) {
int key = random.nextInt(size * 10);
blackhole.consume(hashMap.get(key));
}
@Benchmark
public void testTreeMapGet(Blackhole blackhole) {
int key = random.nextInt(size * 10);
blackhole.consume(treeMap.get(key));
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(MapBenchmark.class.getSimpleName())
.build();
new Runner(options).run();
}
}
在这个例子中,我们比较了 HashMap
和 TreeMap
的 get
方法的性能。我们使用 @Param
注解来定义 Map 的大小,并使用 @Setup
注解来初始化 Map。我们确保 key 的范围大于 map 的大小,以此制造一些 key 的冲突。
运行结果显示:
Benchmark (size) Mode Cnt Score Error Units
MapBenchmark.testHashMapGet 100 avgt 5 18.243 ± 2.682 ns/op
MapBenchmark.testHashMapGet 1000 avgt 5 21.519 ± 1.344 ns/op
MapBenchmark.testHashMapGet 10000 avgt 5 22.188 ± 2.535 ns/op
MapBenchmark.testTreeMapGet 100 avgt 5 28.442 ± 4.134 ns/op
MapBenchmark.testTreeMapGet 1000 avgt 5 32.790 ± 3.413 ns/op
MapBenchmark.testTreeMapGet 10000 avgt 5 35.344 ± 3.752 ns/op
从结果可以看出,HashMap
的 get
方法的性能优于 TreeMap
。随着 Map 大小的增加,HashMap
的优势更加明显。这符合我们之前的预期。
性能测试结论
今天我们深入探讨了如何使用 JMH 进行 Java 代码的性能基准测试。我们学习了 JMH 的基本概念、注解、配置和高级用法。通过案例分析,我们了解了如何使用 JMH 来比较不同算法和数据结构的性能。希望今天的分享能够帮助大家更好地理解和使用 JMH,从而编写出更高性能的 Java 代码。
JMH 是一款强大的 Java 性能测试工具。它帮助我们编写可靠的、准确的性能测试,从而优化代码,评估框架,并避免微优化。掌握 JMH 是每个 Java 工程师必备的技能之一。