2.8 JMH 与 kotlinx-benchmark:标准化的基准工具
上一节:2.7 协程的真实开销模型 | 下一节:2.9 Lab 2 配套代码:02-jvm-performance-basics/08-jmh-and-kotlinx-benchmark
一句话结论
前面七节讲的每一个陷阱和坑,JMH 都替你处理好了。 它不是一个「更好用的计时器」,而是一套防止你自己骗自己的框架。代价是它的配置比你想象的啰嗦——但每一条配置都在防一个具体的错误。
一、为什么必须用框架
回顾前七节,自己写基准要同时防住这些:
| 风险 | 自己写要做什么 | JMH 的做法 |
|---|---|---|
| 死代码消除 | 手动消费结果 | Blackhole.consume() |
| 常量折叠 | 运行时生成输入 | @State + @Setup |
| 未消费结果 | 同上 | 同上 |
| 预热不足 | 手动预热并判断稳态 | @Warmup + @Iterations |
| 上一轮的 profile 污染下一轮 | 手动开新 JVM | @Fork(N) |
| 计时精度 | 手动 nanoTime |
@BenchmarkMode + 框架自己的计时循环 |
| 统计 | 手动算均值/分布 | 自动算 Score ± Error |
| 分配测量 | 手动监控 | -prof gc,直接给 gc.alloc.rate |
一句话:自己写,就是把这些坑重新踩一遍。
二、JMH 注解速查
@State(Scope.Benchmark) // 状态的作用域:Benchmark(共享)/ Thread(每线程)/ Group
@BenchmarkMode(Mode.Throughput) // 模式:Throughput / AverageTime / SampleTime / SingleShotTime
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3, time = 1) // 预热 3 轮,每轮 1 秒
@Measurement(iterations = 5, time = 1) // 测量 5 轮,每轮 1 秒
@Fork(2) // 跑在 2 个独立 JVM 里
@Threads(1) // 线程数
open class MyBenchmark {
private lateinit var data: IntArray
@Setup(Level.Trial) // 每个 fork 前执行一次
fun setup() {
val rnd = Random(42)
data = IntArray(4096) { rnd.nextInt() } // 运行时输入,防常量折叠
}
@TearDown(Level.Trial)
fun tearDown() { /* 清理 */ }
@Benchmark
fun myMethod(bh: Blackhole) { // Blackhole 消费结果
var sum = 0L
for (v in data) sum += v
bh.consume(sum)
}
@Benchmark
@OperationsPerInvocation(1000) // 一次调用做了 1000 次操作,自动摊薄
fun bulkOperation(bh: Blackhole) { /* ... */ }
@Benchmark
@CompilerControl(CompilerControl.Mode.DONT_INLINE) // 诊断:禁止内联某方法
fun diagnostic(bh: Blackhole) { /* ... */ }
}
四个最常被忽略的参数:
| 参数 | 为什么重要 |
|---|---|
@Fork(2) 以上 |
单次 fork 的结果可能是运气;多 fork 能看出跨 JVM 的稳定性 |
@Setup 的 Level |
Trial = 每个 fork 一次;Invocation = 每次调用都执行(会把 setup 开销算进结果,通常不是你想要的) |
@State 的 Scope |
写错会让 @Threads(N) 的结果完全不同 |
@OperationsPerInvocation |
如果一次调用处理 N 个元素,不加这个会让「每次操作」的成本被高估 N 倍 |
三、Kotlin 用 JMH 的两个坑
坑一:类和方法必须 open
JMH 通过生成子类来插桩。Kotlin 的类默认是 final,方法也是 final——加了注解也不会被识别。
// ❌ JMH 会报「找不到 benchmark 方法」
class MyBenchmark { @Benchmark fun test() {} }
// ✅
open class MyBenchmark { @Benchmark fun test() {} }
坑二:需要 kapt
JMH 依赖注解处理器生成代码,而 Kotlin 源码不走 Java 的 APT。必须加 kapt:
plugins {
kotlin("jvm") version "2.0.20"
kotlin("kapt") version "2.0.20"
id("me.champeau.jmh") version "0.7.2"
}
dependencies {
jmh("org.openjdk.jmh:jmh-core:1.37")
jmh("org.openjdk.jmh:jmh-generator-annprocess:1.37")
kaptJmh("org.openjdk.jmh:jmh-generator-annprocess:1.37") // ← 关键
}
少了
kaptJmh这一行,JMH 会静默地「找不到基准」而不报错——这是最容易卡住的一步。
四、怎么读 JMH 的输出
Benchmark Mode Cnt Score Error Units
SumBenchmark.sum thrpt 2 16.234 ops/ms
SumBenchmark.sum:gc.alloc.rate 2 0.001 MB/sec
四个关键字段:
| 字段 | 含义 | 判读 |
|---|---|---|
Score |
数值本身 | 注意单位:ops/ms 还是 ops/s?搞反会差 1000 倍 |
Error |
误差(置信区间半宽) | 相对 Score 超过 5% → 数据不稳定,先查环境 |
Cnt |
有效轮数 | 如果远小于设定的 iteration 数,说明有轮次失败 |
gc.alloc.rate |
每次操作的分配速率 | 最容易被忽略、信息量最大:能发现隐藏分配 |
一个实用技巧:即使你只关心速度,也请加上 -prof gc:
./gradlew jmh -PjmhProfiler=gc
因为「分配速率」经常直接解释了「为什么这个实现慢」(GC 压力)。
五、kotlinx-benchmark:更 Kotlin 友好的选择
什么时候用它:
| 场景 | 推荐 |
|---|---|
| 需要把结果输出成 JSON、接入 CI 回归 | kotlinx-benchmark(第 8 章会用到) |
需要复杂的 JMH 特性(自定义 profiler、@CompilerControl、多 JVM 参数对比) |
直接用 JMH |
| 只是快速比较两个实现 | 都可以,kotlinx-benchmark 更省事 |
配置:
plugins {
kotlin("jvm") version "2.0.20"
id("org.jetbrains.kotlinx.benchmark") version "0.4.11"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-benchmark-runtime:0.4.11")
}
benchmark {
targets { register("main") }
configurations {
register("main") {
warmups = 3
iterations = 5
iterationTime = 1
iterationTimeUnit = "s"
mode = "thrpt" // thrpt = 吞吐;avgt = 平均耗时
}
}
}
写法(注意:不需要 open):
@State(Scope.Benchmark)
class SumBenchmark {
private lateinit var data: IntArray
@Setup
fun setup() { val rnd = Random(42); data = IntArray(4096) { rnd.nextInt() } }
@Benchmark
fun sum(bh: Blackhole) { // Blackhole 需要 0.4.9+
var s = 0L
for (v in data) s += v
bh.consume(s)
}
}
结果默认输出到 build/reports/benchmarks/,格式是结构化 JSON——这是它比 JMH 更适合做 CI 门禁的原因。
六、什么时候「不该」用 JMH
JMH 假设被测操作是纯的、可重复的、无外部依赖的。所以:
| 场景 | 不该用 JMH | 该用什么 |
|---|---|---|
| 带数据库查询的 Repository | ❌ JMH 的迭代模型与 IO 不匹配 | 自己写采样循环,报告百分位(第 3 章) |
| HTTP 接口 | ❌ | 压测工具(k6) |
| 需要观察 GC 与并发的交互 | ⚠️ 部分可以 | 组件基准 / 集成基准 |
| 纯算法、序列化、锁的开销 | ✅ 正合适 | JMH |
判断标准:如果这个操作有外部状态或不确定性延迟(网络、磁盘、数据库),JMH 的「多轮取平均」就不合适——你需要的是延迟分布,不是平均值。
七、本节小结
- JMH 不是「更好的计时器」,而是防止你自己骗自己的框架;前七节的每个坑它都有对应机制。
- 四个最常被忽略的参数:
@Fork、@Setup的Level、@State的Scope、@OperationsPerInvocation。 - Kotlin 用 JMH 有两个坑:类和方法必须
open;必须加kaptJmh(少了它会静默失败)。 - 读输出重点看四个字段:
Score(含单位)、Error、Cnt、gc.alloc.rate。 - kotlinx-benchmark 更适合输出 JSON 接入 CI;JMH 更适合需要高级特性的场景。
- 带 IO 的操作不该用 JMH——那时你需要的是延迟分布,不是平均值。
八、自测
- 你在 Kotlin 里用 JMH,写了
class MyBench(没加open),运行后报「No matching benchmarks found」。最可能的原因是什么? - JMH 输出
Score = 16.234 ops/ms,你希望换算成「单次耗时」,应该是多少?如果有人把它读成16.234 ops/s,误差是多少倍? - 你要比较「
Jackson和kotlinx.serialization哪个快」。这个任务适合 JMH 吗?如果要测的是「序列化 + 写数据库」的组合,还适合吗?
- 两个可能:① 类或方法没有加
open——Kotlin 默认 final,JMH 无法生成子类来插桩;② 缺少kaptJmh依赖——Kotlin 源码没有走注解处理,JMH 生成器没有看到任何基准方法。这两个是 Kotlin + JMH 最常见的两个卡点,而且第二个不会报错,只会静默地找不到基准。 16.234 ops/ms= 16234 ops/s,单次耗时 =1 / 16234 秒 ≈ 61.6 微秒(或1/16.234 ms ≈ 0.0616 ms)。如果误读成16.234 ops/s,单次耗时会被算成1/16.234 ≈ 61.6 毫秒——误差 1000 倍。这也是为什么读 JMH 输出必须先看单位那一列。- 比较序列化器本身:适合 JMH——纯计算、可重复、无外部依赖。但要注意:必须用真实结构的对象(字段多、有嵌套、有集合),而不是两个字段的 DTO;同时观察
gc.alloc.rate(序列化器的差异常常体现在分配量上,而非纯 CPU)。加上「写数据库」就不适合 JMH 了——这时被测操作包含 IO、有不确定性延迟,JMH 的多轮平均会掩盖真实的延迟分布。应该用第 3 章的组件基准(自己写采样循环,报告 P50/P99)或集成基准。