《Java 并发编程·性能诊断与事故》2.1 JMH 的正确姿势

从 libraryd-conc 的库存扣减基准出发,给出 JMH 1.37 的完整 pom、基准类与两种跑法,用本机实测解释 @State 三种作用域、@Fork 隔离、@BenchmarkMode 与 ±(99.9%) 误差列的读法,并说明置信区间下界为 0 时为什么不能下结论。

承接的问题

1.3 把吞吐、时延与并发摆成了三角,但三角上的每一个点都要由一个具体数字填进去:「扣一次库存」在这台机器上到底要多少纳秒?这个数字没法靠读代码得到,也没法靠 System.nanoTime() 包一层 for 循环得到——后者测到的往往是 JIT 优化后的、被死代码消除过的、被操作系统调度污染的混合物。

JMH(Java Microbenchmark Harness)是 OpenJDK 官方维护的微基准框架,它做的事只有一件:把你写的「被测方法」放进一个受控的测量框架里,给出均值、误差区间和可复现的原始数据。这一节用一个完整的基准回答三个问题:怎么写、怎么跑、结果怎么读。至于基准本身可能错在哪里,2.2 专门讲。

构造场景:libraryd-conc 的库存扣减有三种写法——synchronized 块、AtomicInteger、VarHandle。业务上它们都能把 stock 减一,团队想知道 4 线程并发时哪个更快。这一节就测这件事。

一个能跑起来的基准

先给完整的工程。pom.xml 只有两个依赖和一个必须显式声明的编译器参数:

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.plume.conc</groupId>
  <artifactId>conc-lab-bench</artifactId>
  <version>1.0.0</version>

  <properties>
    <maven.compiler.release>25</maven.compiler.release>
    <jmh.version>1.37</jmh.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.openjdk.jmh</groupId>
      <artifactId>jmh-core</artifactId>
      <version>${jmh.version}</version>
    </dependency>
    <dependency>
      <groupId>org.openjdk.jmh</groupId>
      <artifactId>jmh-generator-annprocess</artifactId>
      <version>${jmh.version}</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
        <configuration>
          <release>25</release>
          <proc>full</proc>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <version>3.6.0</version>
        <executions>
          <execution>
            <phase>package</phase>
            <goals><goal>shade</goal></goals>
            <configuration>
              <finalName>benchmarks</finalName>
              <transformers>
                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                  <mainClass>org.openjdk.jmh.Main</mainClass>
                </transformer>
              </transformers>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

<proc>full</proc> 不是可选装饰。JDK 23 起 javac 默认不再隐式运行注解处理器,JMH 的 META-INF/BenchmarkList 就没人生成。本机实测:删掉这一行,mvn package 依然退出码 0,java -jar 才报:

Exception in thread "main" java.lang.RuntimeException: ERROR: Unable to find the resource: /META-INF/BenchmarkList
	at org.openjdk.jmh.runner.AbstractResourceReader.getReaders(AbstractResourceReader.java:98)
	at org.openjdk.jmh.runner.BenchmarkList.find(BenchmarkList.java:124)

「编译成功、运行时才发现没有基准」是 JDK 23 之后最容易踩的一脚。

基准类只测原语,不含库存校验与仓储调用,这样测出来的差额才能归因到同步方式本身:

package com.plume.conc.lab;

import org.openjdk.jmh.annotations.*;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 3, time = 1)
@Fork(1)
@Threads(4)
public class ReserveCounterBench {

    static final VarHandle COUNT;
    static {
        try {
            COUNT = MethodHandles.lookup().findVarHandle(Mutable.class, "count", int.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    public static final class Mutable {
        int count;
        final AtomicInteger atomic = new AtomicInteger();
    }

    @State(Scope.Benchmark)
    public static class Shared {
        public final Mutable mutable = new Mutable();

        @Setup(Level.Iteration)
        public void reset() {
            mutable.count = 0;
            mutable.atomic.set(0);
        }
    }

    @Benchmark
    public int synchronizedDecrement(Shared s) {
        synchronized (s.mutable) {
            return --s.mutable.count;
        }
    }

    @Benchmark
    public int atomicDecrement(Shared s) {
        return s.mutable.atomic.decrementAndGet();
    }

    @Benchmark
    public int varHandleDecrement(Shared s) {
        return (int) COUNT.getAndAdd(s.mutable, -1) - 1;
    }
}

两种跑法都能得到同一份结果。第一种走 shade 打出的可执行 jar:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
mvn -q -DskipTests package
java -jar target/benchmarks.jar -wi 2 -i 3 -w 1s -r 1s -f 1

第二种不打包,直接用 classpath 启动 org.openjdk.jmh.Main,适合 CI 里不想跑 shade 的场合(本机已验证可列出全部基准):

CP="target/classes:$HOME/.m2/repository/org/openjdk/jmh/jmh-core/1.37/jmh-core-1.37.jar"
CP="$CP:$HOME/.m2/repository/net/sf/jopt-simple/jopt-simple/4.6/jopt-simple-4.6.jar"
CP="$CP:$HOME/.m2/repository/org/apache/commons/commons-math3/3.6.1/commons-math3-3.6.1.jar"
java -cp "$CP" org.openjdk.jmh.Main -wi 2 -i 3 -w 1s -r 1s -f 1

@State 的三种作用域

@State 决定了状态对象「一个 fork 里创建几份、几个线程共享一份」。三种作用域的区别不是风格问题,它直接决定你测的是「无竞争」还是「有竞争」:

作用域实例数线程可见性典型用途
Scope.Benchmark每个 fork 一份所有线程共享共享计数器、连接池、缓存
Scope.Thread每个线程一份线程私有每线程缓冲区、随机数、局部累加
Scope.Group每个 group 一份组内线程共享生产者/消费者配对、伪共享实验

同一段自增代码,放进 Scope.Benchmark 是 4 个线程抢一个 AtomicLong,放进 Scope.Thread 是各抢各的。本机实测(4 线程,load average 105–132):

Benchmark                              Mode  Cnt           Score           Error  Units
StateScopeBench.perThreadIncrement    thrpt    3   541355379.269 ±  53299991.137  ops/s
StateScopeBench.sharedIncrement       thrpt    3    33823999.270 ± 234667506.446  ops/s

每线程一份是 5.41 亿 ops/s(约 1.8 ns 一次),共享一份是 3382 万,差 16 倍。这个倍数不是「AtomicLong 慢」,而是「同一个缓存行被 4 个核轮流改写」。如果基准里本该共享的状态写成了 Scope.Thread,你会测出一个漂亮但毫无意义的数字——这是 2.2 里「伪共享未隔离」的另一面。

Scope.Group 用来强制几个方法同时运行,@GroupThreads 指定每个方法分几个线程。2.2 的伪共享实验就靠它把两个线程绑到同一个 group 上。

@Fork 与 @BenchmarkMode

@Fork(1) 的意思是「每个基准方法单开一个 JVM 进程跑」。不 fork 会怎样?本机把 -f 1 换成 -f 0,同一份代码、同一组参数:

# -f 0(不隔离,全部基准在 JMH 启动器进程里顺序跑)
ReserveCounterBench.atomicDecrement        thrpt    3   49105355.597 ± 162557270.260  ops/s
ReserveCounterBench.synchronizedDecrement  thrpt    3   19616881.866 ±  69762815.711  ops/s
ReserveCounterBench.varHandleDecrement     thrpt    3   49132552.141 ±  55314509.497  ops/s

对照 -f 1 的 2648 万 / 733 万 / 2466 万,三种写法分别虚高 1.9、2.7、2.0 倍。原因很朴素:不 fork 时,前一个基准的 JIT 编译结果、堆状态、类加载都留在同一个 JVM 里,后面的基准一上来就在「热」的代码缓存上跑。跨基准的数字一旦共享 JVM 就不可比,-f 0 只适合调试命令行参数,不适合出结论。

@BenchmarkMode 决定「一次迭代产出什么统计量」:

模式产出什么时候用
Throughput每秒多少次操作比吞吐、比原语快慢
AverageTime每次操作多少纳秒讲「一次调用多久」
SampleTime每次操作的采样分布看尾延迟、看抖动
SingleShotTime只跑一次的总耗时冷启动、类加载
All以上全部探索阶段

Throughput 只有一个均值,看不见尾部。把同一个基准改成 -bm sample -tu ns,本机输出(244,371 个样本,4 线程争锁):

ReserveCounterBench.synchronizedDecrement          sample  244371      3080.141 ± 815.511  ns/op
ReserveCounterBench.synchronizedDecrement:p0.50    sample               250.000            ns/op
ReserveCounterBench.synchronizedDecrement:p0.90    sample              1208.000            ns/op
ReserveCounterBench.synchronizedDecrement:p0.99    sample              3124.000            ns/op
ReserveCounterBench.synchronizedDecrement:p0.999   sample            130256.384            ns/op
ReserveCounterBench.synchronizedDecrement:p0.9999  sample           6371852.288            ns/op
ReserveCounterBench.synchronizedDecrement:p1.00    sample          13926400.000            ns/op

均值 3080 ns 是中位数 250 ns 的 12 倍,差额主要来自 P99 以上的长尾;最大值 13.9 ms 更是中位数的 5.5 万倍。只报 Throughput 的基准,会把这条尾巴完全藏起来——这也是 1.3 强调分位数的原因。

误差列怎么读

JMH 的 ±(99.9%) 不是「估计值可能偏这么多」,它的定义是:

error = t(0.999, n-1) × stdev / sqrt(n)

n = 3 时 t 分位数约 31.6,所以哪怕样本很稳,误差项也会被放大到标准差的 18 倍。用本机 Blackhole 基准的真实数字核对:stdev = 6480.418,31.6 × 6480.418 / sqrt(3) ≈ 118,226,与输出的 ±118226.941 一致。

于是读法有三条:

  1. 先看误差项和均值的比例。本机 ReserveCounter 的完整结果(load average 105–132,-wi 2 -i 3 -w 1s -r 1s -f 1):
Benchmark                                   Mode  Cnt           Score            Error  Units
BlackholeBench.sumBlackholed               thrpt    3      742461.401 ±     118226.941  ops/s
BlackholeBench.sumDropped                  thrpt    3  1311185160.469 ± 1493707963.588  ops/s
ReserveCounterBench.atomicDecrement        thrpt    3    26479812.839 ±   31407350.217  ops/s
ReserveCounterBench.synchronizedDecrement  thrpt    3     7330331.392 ±   43258540.788  ops/s
ReserveCounterBench.varHandleDecrement     thrpt    3    24664301.731 ±   10620923.611  ops/s
StateScopeBench.perThreadIncrement         thrpt    3   541355379.269 ±    53299991.137  ops/s
StateScopeBench.sharedIncrement            thrpt    3    33823999.270 ±  234667506.446  ops/s
  1. 置信区间重叠就别说谁快。atomicDecrement 的区间是 [≈0, 5789 万],synchronizedDecrement 是 [≈0, 5059 万]——两条区间大面积重叠,只能得出「都在千万量级」。varHandle 的区间 [1404 万, 3529 万] 虽然不含 0,但 atomic 的下界是 0,两者仍然重叠,同样排不出先后。

  2. 区间下界为 0 时,方向不可信。synchronizedDecrement 的三次测量是 481 万 → 765 万 → 952 万,单调上升,说明两次预热迭代根本没让它进入稳态;均值 733 万只能当下界看,误差项 4325 万比均值本身大 6 倍。

本机 load average 常年 70–170,这些区间宽是真实环境的如实反映,不是 JMH 的缺陷。要在这样的机器上分出胜负,唯一诚实的说法是「分不出」,或者换一台隔离的机器重跑。

Blackhole 的正确用法

JMH 用 Blackhole 防止被测代码的结果被优化掉:方法有返回值时,JMH 会把返回值消费掉;方法返回 void 时,你必须自己消费。两种写法:

@Benchmark
public int withReturn(Shared s) {
    return s.mutable.atomic.incrementAndGet();
}

@Benchmark
public void withBlackhole(Shared s, Blackhole bh) {
    int v = s.mutable.atomic.incrementAndGet();
    bh.consume(v);
}

Blackhole 以方法参数的形式注入,同一个 @Benchmark 方法里可以有多个被消费的值。它内部有编译器可见的消费语义,JIT 不会把被 consume 的值当作死代码删掉。

反过来,如果一个「计算型」基准忘了消费结果,你会得到一个好看到不真实的数字。本机同一个求和循环:带 bh.consume(sum) 是 742,461 ops/s,不消费是 1,311,185,160 ops/s,差 1766 倍——后者整个循环被删掉了。完整对照与判定方法放在 2.2。

在 libraryd-conc 里落地

基准要能被复核,报告里必须同时记录五件事,缺一件数字就不可引用:

项本机示例为什么必须
机器与 JDKApple M1 Pro 10 核 32 GB,Temurin 25.0.5+8换机器排序可能翻转
负载uptime,如 load averages: 105.07 117.61 117.14负载决定误差项宽度
JMH 参数-wi 2 -i 3 -w 1s -r 1s -f 1迭代数决定 t 分位数
完整输出均值 + 误差 + 每次迭代原始值只看均值会漏掉趋势
结论强度「分不出」也是结论防止把噪声写进设计文档

conc-lab 里给基准加一个启动脚本,把 uptime 和 java -version 一起写进结果文件:

#!/bin/sh
set -eu
out="bench-$(date +%Y%m%d-%H%M%S).txt"
{
  date
  uptime
  java -version 2>&1
  java -jar conc-lab-bench/target/benchmarks.jar -wi 3 -i 5 -w 1s -r 1s -f 1
} | tee "$out"

参数上给一个经验口径:正式报告用 -wi 3 -i 5,探索阶段用 -wi 1 -i 2。前者在 10 核机器上单个基准约 8 秒,50 个基准约 7 分钟,可以对每个 PR 跑;后者只用来确认基准本身跑得通。

常见错误与边界

错误后果正确做法
漏写 <proc>full</proc>编译通过、运行时报 Unable to find /META-INF/BenchmarkListJDK 23+ 显式声明
用 -f 0 出结论数字虚高约 2 倍,基准间互相污染至少 -f 1
共享状态写成 Scope.Thread测出无竞争的理想值按业务是否共享来选作用域
返回 void 且不消费结果整个计算被死代码消除返回值或 bh.consume
只看均值不看误差把 3 倍以内的差异当结论区间重叠就说分不出
在 CI 共享 runner 上跑微基准误差项宽到没有信息量固定机器、记录 load
把 SampleTime 的 p99 当业务 P99微基准没有网络与排队系统级压测另做(2.3)

什么时候不要用 JMH:当你真正想知道的是「这套服务在 200 QPS 下的 P99 是多少」时。JMH 的调用栈里没有网络、没有连接池、没有队列、没有 GC 与业务对象的生命周期,它只能回答「这段代码本身多快」。把 JMH 的数字直接换算成容量,是 2.3 要拆的错误。

小结

JMH 的价值不在于它算得快,而在于它把「不可比」变成「可比」:@State 定义了共享边界,@Fork 切断了基准之间的污染,@BenchmarkMode 决定了你看均值还是看分布,±(99.9%) 则诚实地告诉你「这次测量有多不确定」。本机这组数字里,只有「每线程状态比共享状态快 16 倍」这一条方向明确;三种扣减写法之间的差异,被宽到 0 的置信区间吞掉了。承认这一点,比编一个 3.2 倍的加速比有用得多。

下一节把这套框架拆开:基准写得对,不代表测的是你以为的那件事。

阅读导航:上一节 1.3 吞吐、时延与并发的三角 | 下一节 2.2 微基准的陷阱

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收