如何用JMH基准测试框架比较代码性能?

wen java案例 62

本文目录导读:

如何用JMH基准测试框架比较代码性能?

  1. 目录导读
  2. 为什么需要JMH?——性能测试的陷阱与真相
  3. JMH核心概念速览
  4. 实战:从零搭建JMH项目
  5. 常见性能比较场景与代码示例
  6. JMH结果解读与常见误区
  7. QA:开发者最关心的5个问题

JMH基准测试框架实战指南:如何科学比较Java代码性能


目录导读

  1. 为什么需要JMH?——性能测试的陷阱与真相
  2. JMH核心概念速览
  3. 实战:从零搭建JMH项目
  4. 常见性能比较场景与代码示例
  5. JMH结果解读与常见误区
  6. QA:开发者最关心的5个问题

为什么需要JMH?——性能测试的陷阱与真相

痛点场景
你写了两段代码:ArrayList遍历 vs LinkedList遍历,在循环里加100万个元素后测试时间——结果发现一次快、一次慢,甚至不同环境结果完全相反,这是为什么?

关键真相

  • JVM预热机制:热点代码会被JIT编译,第一次运行和第十次运行速度天差地别。
  • 死代码消除:如果计算结果未被使用,JVM可能直接优化掉整个循环。
  • 系统噪声:GC暂停、线程调度、CPU降频都会干扰测试结果。

JMH(Java Microbenchmark Harness)正是为解决这些问题而生,它由OpenJDK官方开发,通过精确控制预热次数、GC行为、运行温等,给出统计学上有效的性能对比

一句话总结:不用JMH测性能,就像不用温度计测体温——全凭感觉。


JMH核心概念速览

概念 作用 常见配置
@Benchmark 标记要测试的方法 无参数
@BenchmarkMode 测试维度(吞吐量/平均时间/采样时间等) Mode.Throughput
@Warmup 预热次数与时间 iterations = 5, time = 1
@Measurement 正式测量次数与时间 iterations = 10, time = 1
@Fork 独立进程运行次数(避免GC交互污染) value = 2
@State 定义共享或线程私有状态对象 Scope.Thread

核心原则

  • 每个@Benchmark方法应只测单一操作
  • 使用Blackhole对象消费计算结果,防止死代码消除。
  • 必须配置合理的预热(至少3-5轮)与迭代次数。

实战:从零搭建JMH项目

1 Maven依赖(推荐)

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.37</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.37</version>
    <scope>provided</scope>
</dependency>

2 第一个测试类

@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 2)
@Fork(1)
@State(Scope.Thread)
public class StringConcatBenchmark {
    private String a = "Hello";
    private String b = "World";
    private int count = 1000;
    @Benchmark
    public String plusConcat() {
        String s = "";
        for (int i = 0; i < count; i++) {
            s += a + b;
        }
        return s;
    }
    @Benchmark
    public String builderConcat() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < count; i++) {
            sb.append(a).append(b);
        }
        return sb.toString();
    }
    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}

3 运行与输出

执行后控制台会输出类似:

Benchmark                               Mode  Cnt     Score     Error   Units
StringConcatBenchmark.plusConcat       thrpt    5  1245.123 ± 34.567  ops/ms
StringConcatBenchmark.builderConcat    thrpt    5  9876.543 ± 89.012  ops/ms

解读

  • Score:每秒操作次数(越高越好)。
  • Error:置信区间(越小越稳定)。
  • plusConcat 性能远低于 builderConcat——这正是JMH能清晰揭示的性能差距。

常见性能比较场景与代码示例

场景1:ArrayList vs LinkedList 插入性能

@Benchmark
public void arrayListAdd(Blackhole bh) {
    List<Integer> list = new ArrayList<>();
    for (int i = 0; i < 10000; i++) list.add(i);
    bh.consume(list);
}
@Benchmark
public void linkedListAdd(Blackhole bh) {
    List<Integer> list = new LinkedList<>();
    for (int i = 0; i < 10000; i++) list.add(i);
    bh.consume(list);
}

ArrayList尾部插入快于LinkedList(基于数组复制更快)。

场景2:同步锁 vs 原子类 vs 乐观锁

@State(Scope.Benchmark)
public class CounterBenchmark {
    private int value = 0;
    private final AtomicInteger atomic = new AtomicInteger(0);
    @Benchmark
    public synchronized int syncIncrement() {
        return value++;
    }
    @Benchmark
    public int atomicIncrement() {
        return atomic.incrementAndGet();
    }
}

典型结果atomicIncrementsyncIncrement 快2-5倍(无竞争环境下)。


JMH结果解读与常见误区

1 误区:数值越大/越小就代表代码更好?

不是,需要结合误差范围业务场景

  • 吞吐量(ops/ms)高 → 适合高并发场景。
  • 平均时间(us/op)低 → 适合低延迟场景。

    throughputaverageTime的反向关系并不绝对,需要看具体实现。

2 如何判断测试结果是否可信?

  • 观察Error值:若误差超过10%,说明测试不稳定(需增加预热或迭代次数)。
  • 检查Fork次数:至少@Fork(2),避免单次JVM的GC毛刺影响。

3 一定需要Blackhole吗?

// 错误写法:JVM可能会优化掉整个循环
@Benchmark
public void badTest() {
    Math.pow(2, 10);
}
// 正确写法
@Benchmark
public void goodTest(Blackhole bh) {
    bh.consume(Math.pow(2, 10));
}

Blackhole会“欺骗”JVM:这个结果我还在用,别优化掉。


QA:开发者最关心的5个问题

Q1:JMH需要独立工程吗?

:不一定,建议新建一个Maven模块,仅用于性能测试,推荐使用mvn package后直接运行JAR包,避免IDE的类加载干扰。

Q2:为什么我的测试结果每次都不一样?

:可能原因:

  • 预热不足(增加-wi参数到5以上)。
  • 机器有功耗限制(如笔记本电池模式)。
  • 测试代码包含IO操作(JMH主要适合纯CPU/内存操作)。

Q3:要不要在测试中调用System.gc()

:不要,JMH默认会记录GC暂停并影响结果,可以在@Measurement之前通过@OperationsPerInvocation调整。

Q4:JMH结果能直接用于线上估算吗?

:可以作为相对比较的依据,但不能直接得到QPS,线上环境有网络IO、Redis缓存、线程池调度等复杂因素,JMH只能反映纯代码层面的“微性能”。

Q5:如果我和其他人的测试结果不同怎么办?

:这是正常的,限制条件:

  • 测试时CPU型号/内存频率不同。
  • JDK版本不同(比如JDK8 vs JDK17的字符串优化不同)。
  • 需要同版本、同机器下对比,才有意义。

JMH并非魔法工具,但它提供了一种科学、可重复、统计可信的代码性能对比方法。
下一次当你犹豫“用HashMap还是TreeMap”、“synchronized还是Lock”时,写一个JMH测试,让数据说话,而不是凭感觉猜测。
性能优化之路,从一份可靠的报告开始。

抱歉,评论功能暂时关闭!