☰
JMH 微基准测试实战:从手写计时不可信到 JVM 性能调优
2026/10/13 12:27:34 网站建设 项目流程

1. 为什么手写System.currentTimeMillis()测出来的性能数据基本不可信

很多同行第一次接触性能测试,都是在业务代码里插两行long start = System.nanoTime();和long end = System.nanoTime();,然后打印一个差值,就宣称"这个方法耗时 3 毫秒"。我早期也这么干过,直到有一次把一个明明很快的工具类方法测出了 20 毫秒的"惊人"耗时,排查半天才发现是 JIT 还没预热、类加载还没完成,测的根本不是方法的真实性能,而是 JVM 冷启动的开销。

这就是 JMH(Java Microbenchmark Harness)存在的意义。它不是又一个"计时工具",而是一套专门用来对抗 JVM 各种"欺骗行为"的基准测试框架。JVM 为了跑得快,会在背后做大量优化:即时编译(JIT)、死代码消除、常量折叠、循环展开、逃逸分析、栈上替换……这些优化对生产环境是好事,但对性能测试是灾难——你测的代码很可能被优化得面目全非,甚至整段被删掉,最后得到一个漂亮但完全虚假的数字。

JMH 由 OpenJDK 团队维护,是 Java 官方生态里做微基准测试的事实标准。它解决的问题很具体:让一段 Java 代码在受控、可复现、贴近真实运行状态的条件下被测量。适合谁来用?任何需要回答"这两个实现哪个更快""这个参数调到多少性能最优""这次重构到底有没有提升"这类问题的开发者。不管你是写基础库、调优中间件,还是单纯想搞清楚String.format和字符串拼接谁快,JMH 都是绕不开的工具。

这篇文章我会从"为什么手写计时不可信"讲起,把 JMH 的核心概念、注解体系、参数配置、结果解读、常见陷阱全部拆开讲透,最后给出一套可以直接抄作业的模板和调优经验。内容偏实战,假设你已经会写 Java、会用 Maven 或 Gradle,但对 JMH 还停留在"听说过"的阶段。

2. JMH 到底在对抗什么:JVM 那些让测量失真的优化行为

要理解 JMH 的每一个设计决策,得先搞清楚它要对付的敌人是谁。JVM 的运行时优化不是 bug,而是特性,但它们会让天真的测量方法彻底失效。下面这几类行为,是性能测试里最常见的"数据杀手"。

2.1 即时编译与预热:前一万次执行和第一万零一次不是同一段代码

JVM 一开始是解释执行字节码的,只有当某个方法被调用足够多次(达到编译阈值),才会被 JIT 编译成机器码。编译之后,执行速度可能有几十倍的差距。如果你在方法刚被调用几次时就计时,测到的是解释执行的速度,毫无参考价值。

更麻烦的是,JIT 还分 C1(客户端编译器,编译快、优化浅)和 C2(服务端编译器,编译慢、优化深)两个层级,方法可能先被 C1 编译,跑一阵子后被 C2 重新编译。这意味着同一段代码在不同阶段性能完全不同。JMH 的应对方式是预热(Warmup):先跑若干轮不计入统计,等 JIT 稳定后再开始正式测量。这个"稳定"不是拍脑袋,而是通过多轮迭代观察性能是否收敛。

2.2 死代码消除:你测的代码可能压根没执行

看这段代码:

@Benchmark public void test() { int sum = 0; for (int i = 0; i < 1000; i++) { sum += i; } }

JIT 一看,sum算完就没用了,整个循环都是死代码,直接删掉。你测出来"耗时接近 0",还以为是性能奇迹。JMH 的解法是要求你把结果返回出来,或者用Blackhole消费掉,让 JIT 无法证明这段计算是多余的。

2.3 常量折叠与循环优化:编译器比你想象的聪明

如果循环里的计算涉及常量,JIT 会在编译期直接算出结果,运行时循环就消失了。同理,如果循环体每次迭代做的是完全相同的事,JIT 可能把它优化成只做一次。这些优化让"循环 N 次取平均"这种朴素做法彻底失效。JMH 通过@OperationsPerInvocation、Blackhole以及把待测逻辑封装成独立方法等手段,尽量让每次迭代都有真实的、不可消除的工作量。

2.4 逃逸分析与栈上分配:对象分配的真实成本被隐藏

现代 JVM 的逃逸分析能把只在方法内使用的小对象直接分配在栈上,甚至完全消除分配。这意味着你测"创建对象"的开销时,如果对象没逃逸出方法,测到的可能是零分配成本。这在评估某些数据结构或缓存实现时会造成严重误判。JMH 的做法是让被测对象通过返回值或Blackhole"逃逸"出去,强制真实分配。

理解了这四类行为,你就能明白 JMH 那些看起来繁琐的注解和配置,每一条都是在堵一个具体的漏洞。下面进入实操。

3. 从零搭一个 JMH 工程:依赖、目录与第一个可运行基准

JMH 的接入方式有两种:独立工程和嵌入现有工程。我强烈建议独立工程,因为基准测试的依赖、编译配置和业务代码差异很大,混在一起容易互相干扰,而且基准测试通常不需要打包进生产产物。

3.1 Maven 依赖配置与版本选择

核心依赖只有两个:jmh-core提供运行时和注解,jmh-generator-annprocess是注解处理器,负责在编译期生成基准测试的驱动代码。

<properties> <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-shade-plugin</artifactId> <version>3.5.1</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>

版本上,1.37 是较新的稳定版,对 JDK 17 及以上支持良好。如果你还在 JDK 8,用 1.36 也没问题。maven-shade-plugin的作用是把所有依赖打成一个可执行 jar,这样跑基准测试时不用手动拼 classpath,非常省事。

注意:jmh-generator-annprocess的 scope 设为provided就够了,它只在编译期工作,不需要进入运行时。

3.2 第一个基准测试:字符串拼接的三种写法对比

光说不练没意义,直接上一个真实场景:比较+拼接、StringBuilder、String.format三种字符串拼接方式的性能。这个例子足够简单,又能体现 JMH 的完整用法。

import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; import java.util.concurrent.TimeUnit; @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @State(Scope.Thread) @Warmup(iterations = 3, time = 1) @Measurement(iterations = 5, time = 1) @Fork(2) public class StringConcatBenchmark { private int userId; private String userName; @Setup(Level.Trial) public void setup() { userId = 10086; userName = "benchmark-user"; } @Benchmark public String plusConcat() { return "user:" + userId + ",name:" + userName; } @Benchmark public String stringBuilder() { return new StringBuilder() .append("user:").append(userId) .append(",name:").append(userName) .toString(); } @Benchmark public String stringFormat() { return String.format("user:%d,name:%s", userId, userName); } }

跑起来的方式有两种。命令行直接跑:

mvn clean package java -jar target/benchmarks.jar StringConcatBenchmark

或者在 IDE 里写一个main方法:

public static void main(String[] args) throws Exception { org.openjdk.jmh.runner.options.Options opt = new org.openjdk.jmh.runner.options.OptionsBuilder() .include(StringConcatBenchmark.class.getSimpleName()) .build(); new org.openjdk.jmh.runner.Runner(opt).run(); }

3.3 读懂输出:那些数字和符号到底什么意思

跑完之后你会看到类似这样的输出:

Benchmark Mode Cnt Score Error Units StringConcatBenchmark.plusConcat avgt 10 38.521 ± 1.203 ns/op StringConcatBenchmark.stringBuilder avgt 10 42.117 ± 0.987 ns/op StringConcatBenchmark.stringFormat avgt 10 312.456 ± 8.221 ns/op

逐列解释一下,这几列是解读一切 JMH 结果的基础:

  • Mode:avgt表示平均耗时(AverageTime),还有thrpt(吞吐量)、sample(采样)、ss(单次执行)等模式。
  • Cnt:实际测量的迭代次数,这里是 10,来自 5 次测量迭代乘以 2 个 fork。
  • Score:核心指标,这里是每次操作的平均纳秒数,越小越好。
  • Error:误差范围,通常表示 99.9% 置信区间。这个值很关键,如果两个实现的 Score 差距小于各自的 Error,那这个差距在统计上不可信。
  • Units:单位,由@OutputTimeUnit决定。

从这个结果能得出几个结论:+拼接和StringBuilder性能接近(现代 JVM 会把简单的+拼接自动优化成StringBuilder),而String.format慢了将近一个数量级,因为它内部涉及正则解析和格式化器创建。这个结论如果用手写计时,很可能因为预热不足而得出完全相反的结论。

4. 注解体系逐个拆解:每个参数背后都对应一个真实的测量陷阱

JMH 的注解不多,但每一个都直接影响结果的可信度。我把它们分成"控制测量方式"和"控制运行环境"两类来讲,重点说清楚每个参数该设多少、为什么这么设。

4.1@BenchmarkMode与@OutputTimeUnit:先想清楚你要回答什么问题

@BenchmarkMode决定测量维度,选错了整个测试就白做。常见模式对比如下:

模式含义适用场景单位示例
AverageTime每次操作平均耗时关注单次延迟ns/op、us/op
Throughput单位时间完成的操作数关注吞吐能力ops/s、ops/ms
SampleTime采样统计耗时分布关注延迟分布、P99ns/op
SingleShotTime单次执行耗时冷启动、一次性任务ns/op
All以上全部全面评估混合

我的经验是:评估一个方法快不快,用AverageTime;评估一个服务能扛多少请求,用Throughput;排查长尾延迟,用SampleTime。SingleShotTime比较特殊,它通常配合@Warmup(iterations = 0)使用,专门测冷启动性能,比如类初始化、首次调用开销。

@OutputTimeUnit要和 Mode 匹配。测延迟用纳秒或微秒,测吞吐用秒。如果单位选得离谱,比如用秒来测纳秒级操作,你会看到一堆0.000,毫无意义。

4.2@Warmup与@Measurement:预热多久才算够

这两个注解的参数结构一样:iterations(轮数)和time(每轮时长)。默认值是预热 5 轮、测量 5 轮,每轮 10 秒。这个默认值对大多数场景够用,但会跑很久。

我的调参经验是这样的:

  • 开发调试阶段:@Warmup(iterations = 2, time = 1)、@Measurement(iterations = 3, time = 1),快速看趋势。
  • 正式出报告:@Warmup(iterations = 5, time = 2)、@Measurement(iterations = 8, time = 2),保证 JIT 充分预热、统计稳定。
  • 简单方法:预热可以少一点,因为编译阈值很快达到。
  • 复杂方法或涉及大量类加载:预热要加长,否则测量阶段还在编译。

判断预热够不够有个土办法:看多轮测量结果是否稳定。如果第一轮测量明显比后面几轮慢,说明预热不足,得加轮数。

4.3@Fork:为什么必须开多个 JVM 进程

@Fork控制每个基准测试在多少个独立的 JVM 进程里运行。默认是 1,我建议至少设 2。原因有两个:一是单个 JVM 里的状态(比如 JIT 编译历史、堆布局)可能影响结果,多进程能平滑掉这种偏差;二是 fork 之间互相独立,能暴露结果的不稳定性。

@Fork还有几个进阶参数:jvmArgs可以给每个 fork 传 JVM 参数,比如@Fork(value = 2, jvmArgs = {"-Xmx2g", "-XX:+UseG1GC"}),用来测试不同 GC 或堆配置下的性能。jvmArgsAppend则是在默认参数基础上追加。这个功能在调优 GC 时特别有用。

注意:fork 数量越多,总运行时间越长。@Fork(2)意味着整个测试跑两遍,时间直接翻倍。别为了"更准"无脑设成 10。

4.4@State与@Setup/@TearDown:状态共享的三种作用域

@State定义基准测试的状态对象,它决定了状态在多个线程、多次迭代之间如何共享。三种作用域:

  • Scope.Thread:每个线程一份独立实例,最常用,避免线程竞争干扰测量。
  • Scope.Benchmark:所有线程共享一份,适合测共享资源的并发访问。
  • Scope.Group:每个线程组共享一份,用于复杂的多线程协作场景。

@Setup和@TearDown配合Level使用,控制初始化时机:

  • Level.Trial:整个基准测试只执行一次,适合加载大文件、建连接池。
  • Level.Iteration:每轮迭代执行一次,适合重置状态。
  • Level.Invocation:每次调用都执行,慎用,会严重拖慢测试。

这里有个大坑:如果@Setup里做了耗时操作,而它又是Level.Invocation,那测出来的时间大部分是 setup 的开销。我见过有人把数据库连接放在Level.Invocation,结果测出来每次操作几百毫秒,其实是建连接的时间。

4.5Blackhole:让 JIT 无法偷懒的关键工具

Blackhole是 JMH 提供的"黑洞",用来消费计算结果,防止死代码消除。它的用法有两种:

@Benchmark public void withBlackhole(Blackhole bh) { int result = computeSomething(); bh.consume(result); }

或者直接返回结果:

@Benchmark public int withReturn() { return computeSomething(); }

两种方式都行,但有个细节要注意:Blackhole.consume本身有开销,如果被测操作极快(几纳秒),这个开销可能占比不小。JMH 内部对consume做了优化,但极端情况下还是建议用返回值方式。另外,如果要消费多个值,用bh.consume(a); bh.consume(b);分开写,别自己拼成一个对象再消费,那样会引入额外分配。

5. 参数化与多线程基准:把"哪个配置最优"变成可量化的问题

单点测试只能回答"A 和 B 谁快",但真实调优往往要回答"参数取多少最优""并发多少吞吐最高"。这就需要参数化和多线程支持。

5.1@Param:一次跑遍所有候选值

@Param让同一个基准测试用不同的参数值各跑一遍,非常适合做参数扫描。比如测试不同初始容量下HashMap的写入性能:

@State(Scope.Thread) public class HashMapBenchmark { @Param({"16", "64", "256", "1024"}) private int initialCapacity; private Map<Integer, Integer> map; @Setup(Level.Iteration) public void setup() { map = new HashMap<>(initialCapacity); } @Benchmark public void put() { for (int i = 0; i < 1000; i++) { map.put(i, i); } } }

跑完会得到四组结果,直接对比就能看出容量对性能的影响。@Param的值是字符串数组,JMH 会自动转换成字段类型。如果字段是枚举或自定义类型,需要提供转换方法。

有个经验:@Param的取值不要太多,否则运行时间成倍增长。四个值乘以默认的 10 轮迭代,就是 40 轮,每轮 10 秒,接近 7 分钟。调试时先把轮数降下来。

5.2 多线程基准:@Threads与并发状态的正确姿势

@Threads指定并发线程数,配合Scope.Benchmark的状态,可以测并发场景。比如测一个并发计数器的吞吐:

@BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.SECONDS) @State(Scope.Benchmark) @Threads(8) public class CounterBenchmark { private AtomicLong atomicCounter; @Setup(Level.Trial) public void setup() { atomicCounter = new AtomicLong(); } @Benchmark public long atomicIncrement() { return atomicCounter.incrementAndGet(); } }

这里@State(Scope.Benchmark)是关键——所有线程共享同一个计数器,才能测出真实的并发竞争。如果写成Scope.Thread,每个线程各有一个计数器,测的就是无竞争的本地操作,完全不是你要的结论。

多线程测试还有个陷阱:线程数不等于真实并发度。JMH 的@Threads是固定线程数,但真实系统里线程可能被阻塞、被调度走。如果要模拟更真实的并发,得结合@Group或者用Mode.Throughput观察吞吐随线程数的变化曲线。

5.3 用@Group模拟生产者-消费者这类协作场景

@Group允许把多个基准方法绑定成一组,组内线程按比例分配。典型场景是生产者-消费者:

@State(Scope.Group) public class ProducerConsumerBenchmark { private BlockingQueue<Integer> queue; @Setup(Level.Trial) public void setup() { queue = new ArrayBlockingQueue<>(1024); } @Benchmark @Group("pc") @GroupThreads(2) public void produce() throws InterruptedException { queue.put(1); } @Benchmark @Group("pc") @GroupThreads(2) public void consume() throws InterruptedException { queue.take(); } }

@Group("pc")把两个方法绑成一组,@GroupThreads(2)表示各分配 2 个线程。这样就能测出在 2 生产 2 消费的配置下,队列的整体吞吐。这个功能在评估并发数据结构、线程池配置时非常实用。

6. 结果解读与统计陷阱:别被一个漂亮的数字骗了

拿到结果只是开始,能不能正确解读才是分水岭。我见过太多人盯着 Score 列做决策,完全忽略 Error 和运行环境,最后得出错误结论。

6.1 Error 不是"误差",是置信区间

JMH 输出的Error列,默认是 99.9% 置信区间的半宽。它的含义是:如果重复这个实验,有 99.9% 的概率真实均值落在Score ± Error范围内。这个值越大,说明结果越不稳定。

判断两个实现是否有显著差异,不能只看 Score 谁小,要看区间是否重叠。比如:

A: 100.0 ± 5.0 ns/op B: 103.0 ± 4.0 ns/op

A 的区间是 [95, 105],B 是 [99, 107],明显重叠,这个 3 纳秒的差距在统计上不可信,可能只是噪声。反过来,如果 A 是 100±2,B 是 110±2,区间不重叠,那差距就是真实的。

6.2 什么时候该怀疑结果:几个危险信号

出现下面这些情况,先别急着下结论,回头检查测试设计:

  • Error 比 Score 还大:说明测量极不稳定,可能是预热不足、有外部干扰、或者被测操作本身波动大。
  • 不同 fork 之间结果差异巨大:可能是 JIT 编译路径不同,或者有随机性(比如涉及哈希)。
  • Score 是 0 或接近 0:几乎可以肯定是死代码消除,检查有没有正确消费结果。
  • 某个参数值结果异常好:可能是 JIT 针对该值做了特殊优化,或者触发了常量折叠。

6.3 环境噪声:那些你没意识到的影响因素

JMH 再强大,也架不住运行环境拖后腿。几个必须注意的点:

  • 关闭 CPU 频率调节:CPU 降频会让结果忽高忽低。Linux 下可以设置 performance 模式。
  • 避免其他进程干扰:跑基准测试时别开浏览器、别编译代码、别下载文件。
  • 固定 CPU 亲和性:用taskset把测试绑定到固定核心,减少调度抖动。
  • 注意 NUMA:多路服务器上,跨 NUMA 节点的内存访问延迟差异很大。
  • 别在虚拟机里跑关键测试:虚拟化层的调度和资源争抢会引入不可控噪声。

这些不是 JMH 能帮你解决的,但直接决定结果可信度。我的习惯是:正式测试前先跑一遍空基准(什么都不做的方法),看看基线噪声有多大,心里有个数。

7. 实战避坑清单:我踩过的那些坑和对应的解法

理论讲完了,最后分享一些只有真正跑过几十轮基准测试才会遇到的坑。这些在官方文档里要么没写,要么一笔带过,但每一个都能让你的测试结果彻底失真。

7.1 循环写在基准方法里还是外面

这是个高频争议点。看两种写法:

// 写法一:循环在方法内 @Benchmark public int loopInside() { int sum = 0; for (int i = 0; i < 1000; i++) { sum += compute(i); } return sum; } // 写法二:循环在方法外(JMH 自动多次调用) @Benchmark public int loopOutside() { return compute(42); }

写法一的问题是 JIT 可能对循环做优化,而且单次操作的开销被平均掉了,掩盖了单次调用的真实成本。写法二让 JMH 的迭代机制来处理重复,每次调用都是独立的,更贴近真实使用。我的建议是:除非你要测的就是"批量处理 N 个元素"的整体性能,否则把循环交给 JMH。如果确实要测批量,用@OperationsPerInvocation(1000)告诉 JMH 每次调用包含 1000 次操作,这样 Score 会自动除以 1000,得到单次操作的成本。

7.2 常量折叠:为什么你的"计算"耗时为零

@Benchmark public int constantFolding() { return 2 + 3 * 4; // JIT 直接算出 14,运行时零开销 }

这种代码测出来必然是 0。要避免常量折叠,得让输入来自"不可预测"的地方,比如从@State字段读取,或者用Blackhole消费中间结果。JMH 甚至提供了@CompilerControl注解,可以强制禁止某些优化,但一般不建议用,因为那测的就不是真实运行状态了。

7.3 自动装箱与缓存:小整数陷阱

@Benchmark public Integer boxing() { return Integer.valueOf(100); // 命中 IntegerCache,几乎零开销 }

Integer.valueOf对 -128 到 127 的值有缓存,测出来快得离谱。要测真实的装箱开销,得用超出缓存范围的值,比如Integer.valueOf(10000)。类似的还有Long、Short、Byte的缓存,以及字符串常量池。这些细节不搞清楚,测出来的数字毫无意义。

7.4 伪共享:多线程测试里的隐形杀手

多线程基准里,如果多个线程频繁写同一缓存行里的不同变量,会发生伪共享(False Sharing),性能急剧下降。这个问题在单线程测试里完全看不到。JMH 提供了@State的 padding 机制,但更简单的做法是用@Contended注解(需要开启-XX:-RestrictContended)或者手动填充。如果你测多线程计数器发现吞吐上不去,先怀疑伪共享。

7.5 别用 JMH 测 IO 和网络

JMH 是为 CPU 密集型微基准设计的。测数据库查询、HTTP 调用、文件读写这类 IO 操作,JMH 的迭代模型并不合适——IO 延迟波动大、受外部系统影响、预热概念也不适用。这类场景应该用压测工具(如专门的负载测试框架),而不是 JMH。硬要用 JMH 测 IO,你会得到一堆 Error 比 Score 还大的结果。

8. 一套可以直接抄的基准测试模板与调优工作流

讲了这么多,最后给一套我日常用的模板和工作流,你可以直接拿去改。

8.1 通用模板

@BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @State(Scope.Thread) @Warmup(iterations = 5, time = 2) @Measurement(iterations = 8, time = 2) @Fork(value = 2, jvmArgs = {"-Xms2g", "-Xmx2g"}) public class MyBenchmark { @Param({"100", "1000", "10000"}) private int size; private DataStructure target; @Setup(Level.Trial) public void setupTrial() { // 加载大资源、建连接池 } @Setup(Level.Iteration) public void setupIteration() { // 重置状态,保证每轮起点一致 target = new DataStructure(size); } @Benchmark public long measure() { return target.operate(); } }

8.2 调优工作流

我通常按这个顺序推进:

  1. 明确问题:要回答的是延迟、吞吐还是分布?选对应的 Mode。
  2. 快速试跑:用最小轮数(预热 1、测量 2、fork 1)跑通,确认没有报错、结果不是 0。
  3. 正式测量:加大轮数和 fork,跑出稳定结果。
  4. 对比分析:看 Score 和 Error,判断差异是否显著。
  5. 参数扫描:用@Param找出最优配置。
  6. 复现验证:换一台机器或换个时间再跑一遍,确认结论可复现。

8.3 几个实用小技巧

  • 用-prof gc参数可以让 JMH 输出 GC 统计,评估内存分配压力:java -jar benchmarks.jar -prof gc。
  • 用-prof stack可以采样热点方法,定位瓶颈。
  • 结果可以输出成 JSON 或 CSV,方便后续用脚本分析:-rf json -rff result.json。
  • 如果只想跑某个方法,用-foe true(fail on error)配合方法名过滤,避免跑全量。

我个人在实际操作中的体会是,JMH 最大的价值不是给你一个精确到小数点后三位的数字,而是逼你把"性能"这件事想清楚:你要测的到底是什么、在什么条件下测、结果能不能复现。想清楚这三个问题,哪怕数字有波动,结论也是可靠的。反过来,如果连测什么都没想明白,再精确的工具也救不了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询