文档目录

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 的「多轮取平均」就不合适——你需要的是延迟分布,不是平均值。


七、本节小结

  1. JMH 不是「更好的计时器」,而是防止你自己骗自己的框架;前七节的每个坑它都有对应机制。
  2. 四个最常被忽略的参数:@Fork、@Setup 的 Level、@State 的 Scope、@OperationsPerInvocation。
  3. Kotlin 用 JMH 有两个坑:类和方法必须 open;必须加 kaptJmh(少了它会静默失败)。
  4. 读输出重点看四个字段:Score(含单位)、Error、Cnt、gc.alloc.rate。
  5. kotlinx-benchmark 更适合输出 JSON 接入 CI;JMH 更适合需要高级特性的场景。
  6. 带 IO 的操作不该用 JMH——那时你需要的是延迟分布,不是平均值。

八、自测

  1. 你在 Kotlin 里用 JMH,写了 class MyBench(没加 open),运行后报「No matching benchmarks found」。最可能的原因是什么?
  2. JMH 输出 Score = 16.234 ops/ms,你希望换算成「单次耗时」,应该是多少?如果有人把它读成 16.234 ops/s,误差是多少倍?
  3. 你要比较「Jackson 和 kotlinx.serialization 哪个快」。这个任务适合 JMH 吗?如果要测的是「序列化 + 写数据库」的组合,还适合吗?
  1. 两个可能:① 类或方法没有加 open——Kotlin 默认 final,JMH 无法生成子类来插桩;② 缺少 kaptJmh 依赖——Kotlin 源码没有走注解处理,JMH 生成器没有看到任何基准方法。这两个是 Kotlin + JMH 最常见的两个卡点,而且第二个不会报错,只会静默地找不到基准。
  2. 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 输出必须先看单位那一列。
  3. 比较序列化器本身:适合 JMH——纯计算、可重复、无外部依赖。但要注意:必须用真实结构的对象(字段多、有嵌套、有集合),而不是两个字段的 DTO;同时观察 gc.alloc.rate(序列化器的差异常常体现在分配量上,而非纯 CPU)。加上「写数据库」就不适合 JMH 了——这时被测操作包含 IO、有不确定性延迟,JMH 的多轮平均会掩盖真实的延迟分布。应该用第 3 章的组件基准(自己写采样循环,报告 P50/P99)或集成基准。