1. 为什么手写System.currentTimeMillis()测出来的性能数据基本不可信很多同行第一次接触性能测试都是在业务代码里插两行long start System.nanoTime();和long end System.nanoTime();然后打印一个差值就宣称这个方法耗时 3 毫秒。我早期也这么干过直到有一次把一个明明很快的工具类方法测出了 20 毫秒的惊人耗时排查半天才发现是 JIT 还没预热、类加载还没完成测的根本不是方法的真实性能而是 JVM 冷启动的开销。这就是 JMHJava 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.version1.37/jmh.version /properties dependencies dependency groupIdorg.openjdk.jmh/groupId artifactIdjmh-core/artifactId version${jmh.version}/version /dependency dependency groupIdorg.openjdk.jmh/groupId artifactIdjmh-generator-annprocess/artifactId version${jmh.version}/version scopeprovided/scope /dependency /dependencies build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.5.1/version executions execution phasepackage/phase goalsgoalshade/goal/goals configuration finalNamebenchmarks/finalName transformers transformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClassorg.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 结果的基础Modeavgt表示平均耗时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.1BenchmarkMode与OutputTimeUnit先想清楚你要回答什么问题BenchmarkMode决定测量维度选错了整个测试就白做。常见模式对比如下模式含义适用场景单位示例AverageTime每次操作平均耗时关注单次延迟ns/op、us/opThroughput单位时间完成的操作数关注吞吐能力ops/s、ops/msSampleTime采样统计耗时分布关注延迟分布、P99ns/opSingleShotTime单次执行耗时冷启动、一次性任务ns/opAll以上全部全面评估混合我的经验是评估一个方法快不快用AverageTime评估一个服务能扛多少请求用Throughput排查长尾延迟用SampleTime。SingleShotTime比较特殊它通常配合Warmup(iterations 0)使用专门测冷启动性能比如类初始化、首次调用开销。OutputTimeUnit要和 Mode 匹配。测延迟用纳秒或微秒测吞吐用秒。如果单位选得离谱比如用秒来测纳秒级操作你会看到一堆0.000毫无意义。4.2Warmup与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.3Fork为什么必须开多个 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.4State与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.1Param一次跑遍所有候选值Param让同一个基准测试用不同的参数值各跑一遍非常适合做参数扫描。比如测试不同初始容量下HashMap的写入性能State(Scope.Thread) public class HashMapBenchmark { Param({16, 64, 256, 1024}) private int initialCapacity; private MapInteger, 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 BlockingQueueInteger 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/opA 的区间是 [95, 105]B 是 [99, 107]明显重叠这个 3 纳秒的差距在统计上不可信可能只是噪声。反过来如果 A 是 100±2B 是 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 调优工作流我通常按这个顺序推进明确问题要回答的是延迟、吞吐还是分布选对应的 Mode。快速试跑用最小轮数预热 1、测量 2、fork 1跑通确认没有报错、结果不是 0。正式测量加大轮数和 fork跑出稳定结果。对比分析看 Score 和 Error判断差异是否显著。参数扫描用Param找出最优配置。复现验证换一台机器或换个时间再跑一遍确认结论可复现。8.3 几个实用小技巧用-prof gc参数可以让 JMH 输出 GC 统计评估内存分配压力java -jar benchmarks.jar -prof gc。用-prof stack可以采样热点方法定位瓶颈。结果可以输出成 JSON 或 CSV方便后续用脚本分析-rf json -rff result.json。如果只想跑某个方法用-foe truefail on error配合方法名过滤避免跑全量。我个人在实际操作中的体会是JMH 最大的价值不是给你一个精确到小数点后三位的数字而是逼你把性能这件事想清楚你要测的到底是什么、在什么条件下测、结果能不能复现。想清楚这三个问题哪怕数字有波动结论也是可靠的。反过来如果连测什么都没想明白再精确的工具也救不了。