性能优化 - 工具篇:基准测试 JMH
文章目录
- Pre
- 引言
- 1. JMH 简介
- 2. JMH 执行流程详解
- 3. 关键注解详解
- 3.1 @Warmup
- 3.2 @Measurement
- 3.3 @BenchmarkMode
- 3.4 @OutputTimeUnit
- 3.5 @Fork
- 3.6 @Threads
- 3.7 @Group 与 @GroupThreads
- 3.8 @State
- 3.9 @Setup 与 @TearDown
- 3.10 @Param
- 3.11 @CompilerControl
- 4. 示例代码与分析
- 4.1 关键点解读
- 4.2 运行与结果示例
- 5. 结果可视化与二次处理
- 5.1 支持的输出格式
- 5.2 可视化工具推荐
- 小结
Pre
性能优化 - 理论篇:常见指标及切入点
性能优化 - 理论篇:性能优化的七类技术手段
性能优化 - 理论篇:CPU、内存、I/O诊断手段
性能优化 - 工具篇:常用的性能测试工具
- 引言:手工计时的局限,说明 JMH 的必要性与优势;
- JMH 简介:工具背景、引入方式(JDK 12 内置与 Maven 依赖);
- JMH 执行流程:Fork、Threads、Warmup、Measurement 四阶段说明;
- 关键注解详解:
4.1 @Warmup:预热原理与配置要点;
4.2 @Measurement:测量阶段的迭代策略;
4.3 @BenchmarkMode:常见模式及输出含义;
4.4 @OutputTimeUnit:结果单位切换;
4.5 @Fork:进程隔离与 JVM 参数传递;
4.6 @Threads:并发线程数控制;
4.7 @Group 与 @GroupThreads(简单介绍);
4.8 @State:状态作用域(Benchmark/Thread/Group);
4.9 @Setup 与 @TearDown:初始化与清理时机;
4.10 @Param:不同参数组合测试法;
4.11 @CompilerControl:强制内联与编译控制; - 示例代码分析:对比 shift 与 div 两种写法的基准测试;
- 结果可视化:JMH 支持的五种输出格式,常用 CSV/JSON 导出;以及三款可视化工具(JMH Visualizer、JMH Visual Chart、meta-chart);
- 小结:JMH 在热点代码验证与专项微基准测试中的应用价值;
引言
在以往的项目开发与性能调优中,我们经常会写类似如下的简单计时代码:
long start = System.currentTimeMillis();
// logic
long cost = System.currentTimeMillis() - start;
System.out.println("Logic cost : " + cost);
然而,这种方式往往并不准确:
-
JIT 编译与方法内联
- 在 HotSpot JVM 中,编译器会对“热点方法”进行 JIT 编译并尝试内联;
- 如果仅执行一次计时,没有经过充分“预热”,那就是测的是“解释执行”甚至是“部分编译前”的性能。
-
测量误差与噪声干扰
- 操作系统调度、垃圾回收、上下文切换、CPU 频率变动等因素,都可能导致
System.currentTimeMillis()
级别的测量出现几十毫秒甚至几百毫秒的抖动; - 对于微基准测试(microbenchmark),通常期望测量精度达到“纳秒”级别,需要靠更专业的工具来保证。
- 操作系统调度、垃圾回收、上下文切换、CPU 频率变动等因素,都可能导致
-
重复测量与结果收敛
- 如果逻辑执行速度非常快,仅靠一次或少量循环,很难区分不同实现间的微小差距;
- 必须反复多次执行、结合预热阶段,才能进入“JIT 稳定态”,得到接近真实的执行时间。
基于上述原因,JMH(Java Microbenchmark Harness) 应运而生。JMH 是 OpenJDK 官方团队维护的微基准测试框架,内置在 JDK 12 及更高版本中(早期需要通过 Maven 坐标引入)。它能够以纳秒级精度准确地测量 Java 方法的吞吐量与延迟,同时自动完成:
- 多轮预热(Warmup)以驱动 JIT 编译;
- 多次迭代测量(Measurement)并收集统计数据;
- 多进程隔离(Fork)确保 JVM 参数与环境一致;
- 并发线程控制(Threads)模拟多线程竞争场景。
1. JMH 简介
-
起源与背景
- JMH 最初由 OpenJDK 团队开发,用于给 JVM 本身的性能回归测试提供微基准支持;
- 随后广泛应用于社区,被各大中间件与框架团队用来验证算法、数据结构或方法优化效果。
-
引入方式
-
JDK12+ 内置:如果使用 JDK 12 或更高版本,默认包含了
jmh-core
与jmh-generator-annprocess
; -
Maven/Gradle 依赖(适用于 JDK11 及以下版本):
<dependencies><dependency><groupId>org.openjdk.jmh</groupId><artifactId>jmh-core</artifactId><version>1.23</version></dependency><dependency><groupId>org.openjdk.jmh</groupId><artifactId>jmh-generator-annprocess</artifactId><version>1.23</version><scope>provided</scope></dependency> </dependencies>
-
IDE 集成:将上述依赖导入后,IDE(如 IntelliJ IDEA)会识别
@Benchmark
注解并支持直接运行;
-
-
基本原理
JMH 通过注解与代码生成器,将被测试的方法包装成专用的 Runner(子进程),在子进程内执行多轮预热与测量,并将数据通过 Socket 或共享内存传回主进程,最后统一汇总、统计并输出。整个流程大致如下:
- Fork(进程隔离):JMH 启动指定数量的子进程,每个子进程都运行相同的测试。
- Warmup(预热):在每个子进程中,对目标方法执行若干轮预热(可配置轮数和时长),驱动 JIT 编译与内联优化。
- Measurement(正式测量):预热结束后,进入正式测量阶段,同样执行可配置轮数和时长,收集方法执行耗时或吞吐统计。
- Result 收集与输出:每个子进程将自身测量结果发送给主进程,主进程汇总所有子进程数据并最终输出(TEXT/CSV/JSON/LATEX/…)。
2. JMH 执行流程详解
在 JMH 运行日志中,你会看到类似下面的输出:
# JMH version: 1.23
# VM version: JDK 11.0.5, Java HotSpot(TM) 64-Bit Server VM, 11.0.5+10
# Warmup: 3 iterations, 1 s each
# Warmup Iteration 1: 0.281 ops/ns
# Warmup Iteration 2: 0.376 ops/ns
# Warmup Iteration 3: 0.483 ops/ns
# Measurement: 5 iterations, 1 s each
# Fork: 1 of 1
# Threads: 2 threads
# Benchmark: com.example.BenchmarkTest.shiftIteration 1: 1646.000 ns/op
Iteration 2: 1243.000 ns/op
Iteration 3: 1273.000 ns/op
Iteration 4: 1395.000 ns/op
Iteration 5: 1423.000 ns/opResult "com.example.BenchmarkTest.shift":2.068 ±(99.9%) 0.038 ns/op [Average](min, avg, max) = (2.059, 2.068, 2.083), stdev = 0.010CI (99.9%): [2.030, 2.106] (assumes normal distribution)
下面将结合这段日志逐项说明流程与参数含义:
-
Warmup(预热)
- 例中配置为
3 iterations, 1 s each
,表示在每个 Fork 进程中,先进行 3 轮预热,每轮持续 1 秒,将预热阶段测得的吞吐(ops/ns)丢弃,不纳入最终统计; - 预热结束后,JMH 会自动等待 JIT 完成优化,使得被测方法进入“稳态”(steady-state)状态,从而让 Measurement 阶段结果更准确。
- 例中配置为
-
Measurement(测量)
- 配置为
5 iterations, 1 s each
,意味着正式测量阶段同样分 5 轮,每轮持续 1 秒,将每轮测量结果记录到输出; - 日志中每行
Iteration X: YYY ns/op
,即表示第 X 轮的平均耗时(以纳秒为单位); - JMH 会将所有轮次的测量结果汇总,计算整体的平均值(avg)、标准差(stdev)、95% 或 99.9% 置信区间(CI)等统计指标。
- 配置为
-
Fork(进程隔离)
-
例中为
Fork: 1 of 1
,表示只启动一个子进程进行测试;如果配置成@Fork(3)
,JMH 会依次启动 3 个子进程,每个子进程重复上述 Warmup + Measurement 流程,最终在主进程以更大样本量做全局聚合; -
通过多 Fork 可以减少单个子进程环境的干扰(如 GC、操作系统调度差异)对结果的影响;
-
如果设置
@Fork(0)
,则表示“非 Fork 模式”,会直接在当前 JVM 进程中执行所有轮次。但 JMH 官方强烈不推荐非 Fork 模式在正式测量时使用,会提示警告:# *** WARNING: Non-forked runs may silently omit JVM options, mess up profilers, disable compiler hints, etc. ***
-
Fork
注解支持参数jvmArgsAppend
,可以为每个子进程传入不同的 JVM 参数,例如:@Fork(value = 3, jvmArgsAppend = {"-Xmx2048m", "-server", "-XX:+AggressiveOpts"})
这样每个子进程都会以
-Xmx2048m -server -XX:+AggressiveOpts
的参数启动,更精细地控制测量环境。
-
-
Threads(线程并发)
- 例中日志
# Threads: 2 threads
,对应@Threads(2)
注解,表示每个子进程都会启动 2 个并行线程同时执行被测方法,以测试并发场景下的吞吐或延迟; - 如果配置
@Threads(Threads.MAX)
,JMH 会自动根据机器上的逻辑 CPU 核心数创建同等数量的线程; - 线程数过多可能导致上下文切换过于频繁,从而影响测量结果,需要根据测试目标(单线程性能 vs 多线程吞吐)以及硬件条件合理设置。
- 例中日志
3. 关键注解详解
下面一一介绍 JMH 常用注解的语义、配置参数与注意事项。
3.1 @Warmup
@Warmup(iterations = 5,time = 1,timeUnit = TimeUnit.SECONDS,batchSize = 1 // 可选
)
-
用途:配置预热阶段的迭代轮数与持续时长,让被测方法在 Measurement 阶段之前尽量被 JIT 编译、内联完成。
-
参数说明:
iterations
:预热轮数,表示要执行多少轮预热;time
+timeUnit
:每轮预热的持续时间,例如1, TimeUnit.SECONDS
表示每轮持续 1 秒;batchSize
:每次迭代中“批量”调用被测方法的次数。默认值为 1,若被测方法非常轻量,可以将其调大,使得每次迭代进行更多次调用,然后再累积测量。
示例日志:
# Warmup: 3 iterations, 1 s each # Warmup Iteration 1: 0.281 ops/ns # Warmup Iteration 2: 0.376 ops/ns # Warmup Iteration 3: 0.483 ops/ns
这里第 1 轮预热的吞吐:0.281 操作/纳秒 ,第 2 轮:0.376 操作/纳秒,以此类推。预热结果不会被记录到最终输出。
实践建议:
- 如果方法执行非常快(例如几十、几百纳秒),建议将
batchSize
设置为几百或几千,以避免单次测量粒度过小而受操作系统调度影响。 - 在分布式服务发布过程中,往往要先做请求“容量预热”与“灰度放量”。这种预热与 JMH 里的
@Warmup
逻辑相似,都是为了让服务或方法进入最优状态。
3.2 @Measurement
@Measurement(iterations = 5,time = 1,timeUnit = TimeUnit.SECONDS,batchSize = 1 // 可选
)
- 用途:配置正式测量阶段的迭代轮数与持续时长,被测方法在每轮测量中的平均耗时或吞吐将被收集。
- 参数与 @Warmup 相同:区别就在于,这里的结果会被统计并输出。
示例日志:
# Measurement: 5 iterations, 1 s each Iteration 1: 1646.000 ns/op Iteration 2: 1243.000 ns/op Iteration 3: 1273.000 ns/op Iteration 4: 1395.000 ns/op Iteration 5: 1423.000 ns/op
表示测量阶段共 5 轮,每轮持续 1 秒。测量数据是每轮的平均耗时(纳秒/操作)。
实践建议:
- 在正式测量时,务必确保测试环境稳定:关闭无关进程、保证 CPU 频率固定、网络与磁盘 I/O 低干扰等;
- 测试结束后,关注平均值之外的最小/最大/标准差,以了解代码在不同时刻是否存在抖动。
3.3 @BenchmarkMode
@BenchmarkMode({Mode.Throughput, Mode.AverageTime})
-
用途:指定 JMH 在测量阶段“统计哪种指标”,可同时指定多个模式。
-
常见模式(Mode)解释:
Throughput
:吞吐量(operations per time unit),例如 “ops/ms” 表示每毫秒的调用次数,相当于 QPS。AverageTime
:平均耗时(time per operation),例如 “ns/op” 表示每次调用平均耗时纳秒。SampleTime
:随机采样模式,会在随机时间间隔抓取一次调用耗时分布,用于统计TP90/TP99
等百分位延迟指标。SingleShotTime
:单次执行耗时(适合测试一次性初始化、静态块、Cold Start 等);All
:同时统计上述所有模式并在输出中显示。
示例输出:
Result "com.example.BenchmarkTest.shift":2.068 ±(99.9%) 0.038 ns/op [Average](min, avg, max) = (2.059, 2.068, 2.083), stdev = 0.010CI (99.9%): [2.030, 2.106] (assumes normal distribution) Benchmark Mode Cnt Score Error Units BenchmarkTest.div avgt 5 2.072 ± 0.053 ns/op BenchmarkTest.shift avgt 5 2.068 ± 0.038 ns/op
这里
avgt
表示 AverageTime 模式,测量了 5 轮后,shift() 的平均耗时约为 2.068ns,误差范围 ±0.038ns。
实践建议:
- 若关注“总体吞吐(QPS)”,请选择
Throughput
模式;若关注“平均延迟”,则用AverageTime
; - 在多数微基准测试中,仅关注
AverageTime
即可;如需延迟分布,可切换到SampleTime
并再结合百分位统计。
3.4 @OutputTimeUnit
@OutputTimeUnit(TimeUnit.MILLISECONDS)
-
用途:指定最终输出结果的“时间单位”,可用在类或方法级别。常见单位:
TimeUnit.SECONDS
、MILLISECONDS
、MICROSECONDS
、NANOSECONDS
。 -
示例:
- 当
@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
,最终结果中Score
单位会显示为ops/ms
; - 当
@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.SECONDS)
,则结果单位为ops/s
; - 当
@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
,则结果单位为ns/op
。
- 当
示例输出(吞吐模式,单位为 ops/ms):
Benchmark Mode Cnt Score Error Units BenchmarkTest.div thrpt 5 482999.685 ± 6415.832 ops/ms BenchmarkTest.shift thrpt 5 480599.263 ± 20752.609 ops/ms
实践建议:
- 根据待测方法耗时大小,选择合适的单位:若方法耗时在几百纳秒级别,用纳秒;若耗时在微秒~毫秒,用
MICROSECONDS
或MILLISECONDS
;
3.5 @Fork
@Fork(value = 1, jvmArgsAppend = {"-Xmx2048m", "-server", "-XX:+AggressiveOpts"})
-
用途:指定测量时要 fork 出多少个子进程来执行完整的 Warmup + Measurement 流程,以及向每个子进程传入哪些 JVM 参数。
-
参数说明:
value
:子进程数量,大于等于 1;jvmArgsAppend
:为子进程 JVM 增加额外参数(如-Xmx2g
、-XX:+UseG1GC
、-XX:+AggressiveOpts
等)。
-
注意:
- 每个子进程都会重新加载类、重新分配堆空间,并与主进程通过 IPC 通信或 Socket 汇报结果;
- 子进程数量过多,会延长测试时间,但能降低单个子进程偶发干扰对最终结果的影响。
日志示例:
# Fork: 1 of 1 # Warmup: 3 iterations, 1 s each # Measurement: 5 iterations, 1 s each
实践建议:
- 推荐至少
@Fork(1)
;若想更多保障结果稳定,可设置为@Fork(3)
; - 在高性能机器上,可以结合
jvmArgsAppend
调整最大堆大小、GC 策略等,以与生产环境保持一致。
3.6 @Threads
@Threads(2)
-
用途:指定每个子进程内并发运行的线程数;
-
可选值:
- 固定整数,如
@Threads(1)
表示单线程测试; - 特殊值
Threads.MAX
,表示创建与 CPU 核数相等的线程数;
- 固定整数,如
注意:
- 过多线程会导致操作系统上下文切换增加,使得单线程测量结果难以辨别。
- 过少线程无法模拟并发场景下的“线程竞争成本”。
3.7 @Group 与 @GroupThreads
@State(Scope.Group)
@Group("myGroup")
@GroupThreads(3)
-
用途:将多个
@Benchmark
方法归为一组(Group),并在该组内部以特定线程数并发调用各方法。常用于测试“多个方法之间的竞争”或“读写混合场景”。 -
示例:
@State(Scope.Group) public class MyBenchmark {@Param({"10", "100"})public int size;@Benchmark@Group("readWrite")@GroupThreads(3)public void read() {// 读逻辑,3 个线程并发地执行 read()}@Benchmark@Group("readWrite")@GroupThreads(1)public void write() {// 写逻辑,1 个线程并发地执行 write()} }
- 上例中,总共有 4 个线程并发:3 个线程执行
read()
,1 个线程执行write()
,它们会对同一份共享状态(因为@State(Scope.Group)
)进行交互。
- 上例中,总共有 4 个线程并发:3 个线程执行
实践场景:
- 测试读写分离缓存场景;
- 测试消息队列生产者/消费者模型;
- 评估并发数据结构(如
ConcurrentHashMap
)在不同线程配比下的吞吐。
3.8 @State
@State(Scope.Thread)
public class MyState {// 类字段可在 benchmark 方法中直接使用
}
-
用途:声明一个“状态类”,其字段可在被测
@Benchmark
方法中共享或隔离。必须加在类上,否则无法运行。 -
Scope 可选值:
Scope.Benchmark
:一个类实例在所有线程、所有轮次共用;适合存放全局只读数据;Scope.Thread
:每个线程都会有自己单独的状态实例,彼此互不影响;适合存放线程本地变量,避免并发冲突;Scope.Group
:在带@Group
注解的场景下,同一个组内的线程共享一个状态实例。
示例:
@State(Scope.Thread)
public class JMHSample_04_DefaultState {double x = Math.PI;@Benchmarkpublic void measure() {x++;}
}
Scope.Thread
意味着每个线程都有自己的x
,互不干扰;- 如果改为
Scope.Benchmark
,那么所有线程都使用同一个x
,竞态写可能会导致测量结果不稳定。
3.9 @Setup 与 @TearDown
@Setup(Level.Trial)
public void init() {// 只在 Fork 子进程启动后、预热前执行一次,进行全局初始化
}@TearDown(Level.Trial)
public void cleanup() {// 在同一个子进程所有测量结束后执行一次,用于回收资源
}
-
用途:定义在不同“生命周期”阶段执行的初始化与清理操作,类似于 JUnit 的
@BeforeClass
/@AfterClass
。 -
Level 可选值:
Trial
(默认):在每个 Fork 子进程内部,只执行一次;Iteration
:在每轮预热或测量开始前执行一次;Invocation
:在每次被测方法调用前执行,粒度最细,但开销极大,通常不推荐使用。
示例:
@State(Scope.Benchmark)
public class MyBenchmark {private Connection conn;@Setup(Level.Trial)public void initConnection() {conn = DriverManager.getConnection(...);}@TearDown(Level.Trial)public void closeConnection() {conn.close();}@Benchmarkpublic void testQuery() {// 使用 conn 进行一次 database query}
}
- 在每个子进程启动并完成 JIT 编译后,
initConnection()
只执行一次;所有轮次共用同一个连接; - 测量结束后,
closeConnection()
执行一次,用于关闭资源。
3.10 @Param
@State(Scope.Benchmark)
public class JMHSample_27_Params {@Param({"1", "31", "65", "101", "103"})public int arg;@Param({"0", "1", "2", "4", "8", "16", "32"})public int certainty;@Benchmarkpublic boolean bench() {return BigInteger.valueOf(arg).isProbablePrime(certainty);}
}
- 用途:为字段提供一组“离散参数值”,JMH 会自动对所有参数组合进行笛卡尔积测试,统计每种参数组合下的测量结果。
- 注意:如果参数组合过多(例如两个字段各 10 个取值,总计 100 个组合),测试需要同时针对 100 种组合分别执行多轮预热与测量,耗时会很长。
示例输出片段:
# JMH version: 1.23
# VM version: JDK 11.0.5, Java HotSpot(TM) 64-Bit Server VM, 11.0.5+10
# Warmup: 5 iterations, 1 s each
# Measurement: 5 iterations, 1 s each
# Param: arg = 1, certainty = 0
Benchmark Mode Cnt Score Error Units
JMHSample_27_Params.bench avgt 5 0.123 ± 0.005 ns/op
JMHSample_27_Params.bench avgt 5 0.130 ± 0.007 ns/op
...
# Param: arg = 65, certainty = 16
JMHSample_27_Params.bench avgt 5 59.456 ± 1.234 ns/op
...
- 每对
(arg, certainty)
配置都会生成一组测量行。
3.11 @CompilerControl
@CompilerControl(CompilerControl.Mode.INLINE)
public void hotMethod() {// ...
}@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public void noInlineMethod() {// ...
}
-
用途:在 JMH 测试中,通过注解强制或禁止 JIT 编译器对某方法进行内联或编译。常见模式:
INLINE
:强制将该方法内联到调用方,消除方法调用开销;DONT_INLINE
:禁止内联,使得方法调用成本不被隐藏;EXCLUDE
:完全禁止 JIT 对该方法进行编译,始终解释执行。
示例场景:
- 若要对比“手动位移运算 vs 除法运算”在“是否被内联”前后的性能差异,可使用
@CompilerControl
强制内联或禁止内联; - 分析 getter/setter 方法是否被 JIT 内联,并测量其对调用链整体耗时的影响。
4. 示例代码与分析
下面给出一个完整的 JMH 基准测试示例,比较“位移运算”和“除法运算”在相同循环次数下的性能差异(shift vs div):
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;@BenchmarkMode(Mode.Throughput) // 统计吞吐量
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 吞吐以“ops/ms”输出
@State(Scope.Thread) // 每个线程一个独立实例
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS) // 预热 3 轮,每轮 1s
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) // 测量 5 轮,每轮 1s
@Fork(1) // 每次只启用 1 个子进程
@Threads(2) // 每个子进程内使用 2 个并发线程
public class BenchmarkTest {@Benchmarkpublic long shift() {long t = 455565655225562L;long a = 0;for (int i = 0; i < 1000; i++) {a = t >> 30;}return a;}@Benchmarkpublic long div() {long t = 455565655225562L;long a = 0;for (int i = 0; i < 1000; i++) {a = t / 1024 / 1024 / 1024;}return a;}public static void main(String[] args) throws Exception {Options opts = new OptionsBuilder().include(BenchmarkTest.class.getSimpleName()) // 包含当前测试类.resultFormat(ResultFormatType.JSON) // 输出 JSON 格式.build();new Runner(opts).run();}
}
4.1 关键点解读
-
@BenchmarkMode(Mode.Throughput) + @OutputTimeUnit(TimeUnit.MILLISECONDS)
- 测量每毫秒的“操作次数”(ops/ms),适合快速计算操作的吞吐差异;
- 若换成
@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
,则测量每次调用的平均耗时(ns/op)。
-
@State(Scope.Thread)
- 每个线程都有自己的实例,此测试中两个线程并行执行各自的
shift()
与div()
,互不干扰; - 若改为
Scope.Benchmark
,则同一实例被两个线程共享,此处没有共享字段,不会影响结果;
- 每个线程都有自己的实例,此测试中两个线程并行执行各自的
-
循环内部写死常量 vs 变量
t >> 30
与t / 1024 / 1024 / 1024
在 JIT 优化后可能都会被内联、常量折叠;- 但在真实业务中,如果 t 来自方法参数或对象字段,则 JIT 可能无法在编译期完全优化,此处作为示例用于对比原始运算开销。
-
Main 方法中的 OptionsBuilder
include(...)
:表示只运行名称匹配当前类名的基准;resultFormat(ResultFormatType.JSON)
:让输出结果为 JSON 格式,便于后续二次处理,如可视化;
4.2 运行与结果示例
假设用 JDK 11 运行该测试,控制台会打印类似:
# JMH version: 1.23
# VM version: JDK 11.0.5, Java HotSpot(TM) 64-Bit Server VM, 11.0.5+10
# Warmup: 3 iterations, 1 s each
# Warmup Iteration 1: 35.000 ops/ms
# Warmup Iteration 2: 45.000 ops/ms
# Warmup Iteration 3: 50.000 ops/ms
# Measurement: 5 iterations, 1 s each
# Fork: 1 of 1
# Threads: 2 threads
# Benchmark: com.example.BenchmarkTest.shiftIteration 1: 480000.000 ops/ms
Iteration 2: 485000.000 ops/ms
Iteration 3: 482500.000 ops/ms
Iteration 4: 484000.000 ops/ms
Iteration 5: 483200.000 ops/msResult "com.example.BenchmarkTest.shift":482740.000 ±(99.9%) 5000.000 ops/ms [Thrpt]
# Benchmark: com.example.BenchmarkTest.divIteration 1: 478000.000 ops/ms
Iteration 2: 482000.000 ops/ms
Iteration 3: 480500.000 ops/ms
Iteration 4: 481000.000 ops/ms
Iteration 5: 480800.000 ops/msResult "com.example.BenchmarkTest.div":480460.000 ±(99.9%) 4500.000 ops/ms [Thrpt]
从示例结果可见:
-
shift()
的平均吞吐约为 482,740 ops/ms,而div()
为 480,460 ops/ms; -
两者吞吐非常接近,但依旧可以通过
CI (99.9%)
判断差异; -
若将模式改为
AverageTime
,则会输出每次平均耗时(ns/op),如下:Benchmark Mode Cnt Score Error Units BenchmarkTest.div avgt 5 2.072 ± 0.053 ns/op BenchmarkTest.shift avgt 5 2.068 ± 0.038 ns/op
5. 结果可视化与二次处理
JMH 除了支持在控制台输出之外,还能将测量结果导出为通用格式,以便用第三方工具生成可视化图表。
5.1 支持的输出格式
在 OptionsBuilder
中可配置 .resultFormat(ResultFormatType.X)
,JMH 默认支持五种格式:
TEXT
:纯文本格式,适合查看简单日志;CSV
:逗号分隔值,可直接用 Excel 或其他工具打开;SCSV
:分号分隔值,与某些地区的 CSV 兼容;JSON
:JSON 结构,适合与脚本或可视化工具对接;LATEX
:可导入 LaTeX 文档,用于学术论文排版。
示例:
Options opts = new OptionsBuilder().include(BenchmarkTest.class.getSimpleName()).resultFormat(ResultFormatType.CSV) // 输出成 CSV 文件.result("benchmark_results.csv").build();
new Runner(opts).run();
- 运行完成后,会在当前目录生成
benchmark_results.csv
,内容包含:Benchmark 名称、Mode、Threads、Fork、Param 值、Score、Error、Units 等列;
5.2 可视化工具推荐
-
JMH Visualizer(在线工具)
- 网站地址: https://jmh.morethan.io/
- 使用方式:将 JMH 导出的 JSON 文件上传,可生成不同参数组合下各模式的条形图或折线图;
- 缺点:交互需要鼠标悬浮展示信息,对于大数据集可视化体验一般。
-
JMH Visual Chart(开源桌面/网页工具)
- GitHub 地址: https://github.com/PengRong/visual-jmh
- 特色:支持 JSON/CSV 导入,自动识别不同 Benchmark 与参数,将数据按照“吞吐 vs 参数”或“延迟 vs 参数”等方式可视化;
- 使用时将生成的
benchmark_results.json
拖入工具即可自动绘制。
-
meta-chart(通用在线图表生成)
- 网站地址: https://www.meta-chart.com/
- 使用方式:先将 JMH 导出的 CSV 文件加载到 Excel,筛选出需要展示的数据(如某个方法在不同线程数下的吞吐),再将表格复制到 meta-chart,用柱状图、折线图或散点图进行展示;
- 优点:灵活度高,可自定义图例、坐标轴、注释等;
-
Jenkins & CI 插件
- 如果将 JMH 测试流程集成到 Jenkins 等 CI 平台,可使用社区插件(如 “Benchmark Parser Plugin”)直接把 CSV/JSON 结果展示在 Jenkins 界面,作为每日构建的性能回归图表。
示例可视化效果(结合文字说明,省略具体截图):
条形图展示
shift()
vsdiv()
在吞吐模式下的对比:
- 横轴:操作名称(shift、div);
- 纵轴:
ops/ms
;折线图展示
@Param
不同参数组合下的AverageTime
:
- 横轴:参数值(如 arg=1、31、65、101、103);
- 纵轴:
ns/op
;- 不同曲线表示不同
certainty
值。
小结
-
为何要使用 JMH 而非简单手工计时:JMH 能自动完成预热、迭代、进程隔离与统计分析,结果更稳定且精度高;
-
JMH 执行流程:
- Fork:多进程隔离环境,避免单 JVM 环境干扰;
- Warmup:驱动 JIT 编译与方法内联,进入稳态;
- Measurement:正式测量阶段,统计吞吐或延迟;
- Result 汇总:收集子进程数据并输出;
-
关键注解详解:
- @Warmup / @Measurement:配置预热与测量轮次、时长;
- @BenchmarkMode / @OutputTimeUnit:定义统计模式与输出单位;
- @Fork / @Threads:控制子进程数量与线程并发数;
- @State:声明状态作用域(Benchmark/Thread/Group);
- @Setup / @TearDown:初始化与清理动作的时机;
- @Param:批量测试不同参数组合;
- @CompilerControl:强制或禁止 JIT 内联与编译;
-
示例分析:以
shift()
vsdiv()
的对比测试,演示完整的 JMH 配置与测量结果格式; -
结果可视化与二次加工:
- 支持的导出格式:TEXT、CSV、SCSV、JSON、LATEX;
- 常用可视化工具:JMH Visualizer、JMH Visual Chart、meta-chart 以及 Jenkins 插件;
借助 JMH,可以对“热点代码”或“关键算法”进行精准的微基准测试,量化优化前后的性能提升,避免盲目优化或主观判断。