Java 并发编程性能测试——JMH 基准测试工具的正确使用方式
Java 并发编程性能测试——JMH 基准测试工具的正确使用方式
适读人群:Java后端开发,想对代码做性能量化分析的工程师 | 阅读时长:约18分钟 | 核心价值:掌握JMH的正确使用姿势,能对并发代码做出可靠的性能基准测试
那次差点发到生产的"性能优化"
2021年,我把一个高频调用的工具方法从synchronized改成了ReentrantLock,在本地用System.currentTimeMillis()测了一下,改完之后快了30%,信心满满地准备合并代码。
代码Review时,一个老同事问我:你这个测试是单线程的还是多线程的?有没有预热?测试循环有多少次?
我楞了一下,回答:单线程,没有预热,循环了10000次。
他说:那你这个结果没啥参考价值。JVM的JIT编译器会在运行期做优化,前几千次循环的性能和稳定状态下的性能差异很大。而且单线程测试锁的性能,本来就不公平。
那次之后,我认真学习了JMH,才知道做正确的性能测试有多复杂。
为什么 System.currentTimeMillis 测性能不可靠
Java的JVM是一个复杂的运行时,影响性能测试的因素有:
JIT编译(Just-In-Time):热点代码会被即时编译成机器码,性能远高于解释执行。如果没有预热,你测的可能是解释执行的性能。
死码消除(Dead Code Elimination):如果测试代码的计算结果没有被使用,JIT可能直接把它优化掉,导致测到的是"什么都不做"的性能。
常量折叠:如果循环体的计算是常量,JIT会在编译期求出结果,测试成了空循环。
GC暂停:测试过程中的GC会带来延迟峰值,影响统计结果。
CPU缓存效应:数据在CPU缓存里的状态会影响内存访问速度。
JMH(Java Microbenchmark Harness)是OpenJDK官方提供的微基准测试框架,专门处理上述所有问题。
JMH 快速上手
添加依赖
<!-- Maven -->
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.37</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.37</version>
<scope>provided</scope>
</dependency>
<!-- 用于打包成可执行jar -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<configuration>
<finalName>benchmarks</finalName>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jmh.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>JMH 核心注解
@BenchmarkMode(Mode.Throughput) // 测试模式
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 时间单位
@State(Scope.Thread) // 状态对象的作用域
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) // 预热
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS) // 正式测量
@Fork(2) // JVM分叉次数
@Threads(4) // 并发线程数BenchmarkMode 测试模式:
Throughput:吞吐量(ops/time),越大越好AverageTime:平均时间(time/op),越小越好SampleTime:采样时间分布(支持百分位数P99等)SingleShotTime:冷启动时间(不预热,测第一次执行)All:所有模式都跑
State Scope:
Thread:每个线程有独立实例,避免线程间干扰Benchmark:所有线程共享同一个实例(测竞争场景)Group:线程组内共享
完整基准测试代码:并发计数器性能对比
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.*;
import java.util.concurrent.locks.*;
/**
* 并发计数器性能基准测试
* 对比:synchronized、ReentrantLock、AtomicLong、LongAdder
* 测试场景:高并发写(increment),偶尔读(get)
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 5, time = 1) // 预热5次,每次1秒
@Measurement(iterations = 10, time = 1) // 测10次,每次1秒
@Fork(2) // 2个独立JVM进程
@Threads(16) // 16个并发线程
@State(Scope.Benchmark) // 所有线程共享state(测竞争)
public class CounterBenchmark {
// 方案1:synchronized
private long syncCounter = 0;
// 方案2:ReentrantLock(非公平)
private long lockCounter = 0;
private final ReentrantLock lock = new ReentrantLock();
// 方案3:AtomicLong
private final AtomicLong atomicCounter = new AtomicLong(0);
// 方案4:LongAdder
private final LongAdder longAdder = new LongAdder();
@Setup(Level.Iteration) // 每次iteration开始前重置
public void reset() {
syncCounter = 0;
lockCounter = 0;
atomicCounter.set(0);
longAdder.reset();
}
@Benchmark
public void incrementSync() {
synchronized (this) {
syncCounter++;
}
}
@Benchmark
public void incrementReentrantLock() {
lock.lock();
try {
lockCounter++;
} finally {
lock.unlock();
}
}
@Benchmark
public void incrementAtomic() {
atomicCounter.incrementAndGet();
}
@Benchmark
public void incrementLongAdder() {
longAdder.increment();
}
// 读写混合测试:9次写1次读
@Benchmark
@Group("readWrite")
@GroupThreads(9) // 9个线程做写
public void write() {
atomicCounter.incrementAndGet();
}
@Benchmark
@Group("readWrite")
@GroupThreads(1) // 1个线程做读
public long read() {
return atomicCounter.get();
}
}避免 JMH 中的常见陷阱
/**
* 演示 JMH 中的常见错误和正确做法
*/
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3)
@Measurement(iterations = 5)
@Fork(1)
@State(Scope.Thread)
public class JmhPitfallsDemo {
private double x = Math.PI;
// ===== 陷阱1:死码消除(Dead Code Elimination) =====
@Benchmark
public void deadCode_WRONG() {
// JIT可能发现这个计算结果没人用,直接优化掉
// 实际上什么都没测到!
double result = Math.sqrt(x);
}
@Benchmark
public double deadCode_CORRECT_returnValue() {
// 方法1:返回结果,JMH会消费它
return Math.sqrt(x);
}
@Benchmark
public void deadCode_CORRECT_blackhole(Blackhole bh) {
// 方法2:通过Blackhole消费,专门用于吸收无用结果
bh.consume(Math.sqrt(x));
}
// ===== 陷阱2:常量折叠(Constant Folding) =====
// 错误:JIT可能在编译期就计算出 2*2=4,测试变成空操作
@Benchmark
public int constantFolding_WRONG() {
return 2 * 2; // 常量折叠,直接返回4
}
// 正确:使用State中的变量,JIT无法预知
@State(Scope.Thread)
public static class MyState {
public int a = 2;
public int b = 2;
}
@Benchmark
public int constantFolding_CORRECT(MyState state) {
return state.a * state.b;
}
// ===== 陷阱3:过短的预热时间 =====
// JIT有多个优化级别(解释执行→C1编译→C2编译)
// 预热不足时,测到的是中间态的性能,不是峰值性能
// 建议:至少预热5次,每次1秒以上
// ===== 陷阱4:Fork=0(在当前JVM中运行) =====
// @Fork(0) 会在当前JVM中运行,受当前JVM状态影响
// 生产的基准测试应该 @Fork(2) 或 @Fork(3),在干净的JVM中运行
}并发场景的特殊考量
/**
* 并发基准测试的最佳实践
* 场景:测试不同锁实现在不同竞争程度下的性能
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5, time = 2)
@Measurement(iterations = 10, time = 2)
@Fork(3) // 3个独立JVM,取平均
public class LockComparisonBenchmark {
// ===== 测试1:低竞争场景(线程数 = CPU核数)=====
@Benchmark
@Threads(4) // 4核机器,4个线程
@Group("lowContention_sync")
@State(Scope.Group)
public static class LowContentionSync {
private long counter = 0;
@Benchmark
@GroupThreads(4)
public synchronized void increment() {
counter++;
}
}
// ===== 测试2:高竞争场景(线程数 >> CPU核数)=====
@Benchmark
@Threads(32) // 远超CPU核数,高竞争
@Group("highContention_longAdder")
@State(Scope.Group)
public static class HighContentionLongAdder {
private final LongAdder adder = new LongAdder();
@Benchmark
@GroupThreads(32)
public void increment() {
adder.increment();
}
}
// ===== 模拟真实业务:锁内有一定耗时的操作 =====
@State(Scope.Benchmark)
public static class WithWorkloadState {
private long result = 0;
private final ReentrantLock lock = new ReentrantLock();
}
@Benchmark
@Threads(8)
public void withWorkload(WithWorkloadState state, Blackhole bh) {
state.lock.lock();
try {
// 模拟锁内有一些工作量(比如读取共享状态做计算)
long value = state.result;
// Blackhole.consumeCPU 模拟CPU工作量(tokens个CPU周期)
Blackhole.consumeCPU(10);
state.result = value + 1;
} finally {
state.lock.unlock();
}
}
}如何运行和解读 JMH 结果
# 打包后运行
java -jar benchmarks.jar
# 只运行指定的benchmark
java -jar benchmarks.jar CounterBenchmark
# 快速运行(减少预热和测量轮次,适合开发调试)
java -jar benchmarks.jar -wi 3 -i 5 -f 1
# 生成JSON报告(可用JMH Visualizer网站可视化)
java -jar benchmarks.jar -rf json -rff results.json
# 查看P99等百分位数(需要SampleTime模式)
java -jar benchmarks.jar -bm SampleTime -tu us典型的JMH输出解读:
Benchmark Mode Cnt Score Error Units
CounterBenchmark.incrementSync thrpt 20 458.234 ± 12.456 ops/ms
CounterBenchmark.incrementAtomic thrpt 20 1823.567 ± 45.123 ops/ms
CounterBenchmark.incrementLongAdder thrpt 20 5234.891 ± 102.345 ops/ms
# Score:平均吞吐量
# Error:95%置信区间,误差越小说明结果越稳定
# ±误差 / Score = 相对误差,最好低于5%三个踩坑实录
坑一:在 IDE 里直接运行 @Benchmark 方法,结果完全不准
现象: 在IDEA里右键运行benchmark方法,结果和用java -jar benchmarks.jar运行差了5倍以上。
原因: JMH需要通过注解处理器生成辅助代码,在IDE中直接运行main()方法时,这些辅助代码的字节码可能没有被正确生成,JMH退化成了普通的循环,失去了死码消除防护、预热等机制。
解法: 必须通过mvn package打包后运行java -jar benchmarks.jar,或者使用JMH提供的程序化API:
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(CounterBenchmark.class.getSimpleName())
.warmupIterations(5)
.measurementIterations(10)
.forks(2)
.build();
new Runner(opt).run();
}坑二:@State(Scope.Thread) 测并发竞争,结果是单线程性能
现象: 测synchronized锁在多线程下的性能,但结果和单线程一样快,没有竞争的开销。
原因: 用了@State(Scope.Thread),每个线程有自己独立的State实例,各自加各自的锁,根本没有竞争。
解法: 测竞争场景必须用@State(Scope.Benchmark),让所有线程共享同一个State实例:
@State(Scope.Benchmark) // 所有测试线程共享
public class SharedState {
public long counter = 0;
// 所有线程竞争这一个实例的锁
}坑三:忽略 Error 值,得出不可靠的结论
现象: 两个方案的Score相差3%,就得出"方案A比方案B快3%"的结论,并据此做了决策。
原因: Score的±Error(置信区间)如果超过了3%,那两者的差异其实在误差范围内,无法区分哪个更快。
解法: 只有当Score的差异显著大于Error时,才能得出有意义的结论。如果Error过大,需要增加测量次数(-i 20)或分叉次数(-f 5)来缩小误差。
JMH 最佳实践清单
- 总是打包后运行,不在IDE中直接执行
- 预热至少5次,每次1秒以上
- 测量至少10次,每次1秒以上
- Fork至少2次
- 每个@Benchmark方法只测一件事
- 用Blackhole消费计算结果,防止死码消除
- 竞争测试用
Scope.Benchmark,隔离测试用Scope.Thread - 关注Error值,只有差异远大于误差才有意义
- 在接近生产的环境(相同JVM参数、相同CPU)中运行
小结
JMH是Java并发性能测试的标准工具,正确使用它能给出可靠的量化数据。用System.currentTimeMillis做的"性能测试"不但不可靠,还可能引导你做出错误的优化决策。
投入时间学会JMH,是每一个认真做性能优化的Java工程师应该做的事情。
