JMH 基准测试

261 阅读32分钟

JMH(Java Microbenchmark Harness)是一个用于基准测试的工具,专门用来评估Java代码的性能,特别是微观层面上的执行性能,比如在微秒或纳秒级别上。

性能测试包含了两种:压力测试(Stress Testing)和基准测试(Benchmarking)。前者是针对接口 API,模拟大量用户去访问接口然后生成接口级别的性能数据;而后者是针对代码,可以用来测试某一段代码的运行速度,例如一个排序算法。

想象一下,你在跑步比赛中,大家都会用同样的标准(基准)来计时,看看谁跑得最快。基准测试就是类似的概念,但它是用在计算机系统程序上。

举个简单的例子:你写了一个计算程序,用来找出一大堆数字中的最大值。

你可能会想知道,这个程序运行得有多快?消耗了多少内存?和其他类似的程序相比,哪一个更快、更节省资源?

基准测试就是帮你回答这些问题的。它通过运行你的程序(或者其中的一部分),然后测量和记录它的运行时间、内存使用等,最后你就能得到一份报告,告诉你程序的性能表现。

在基准测试中,就可以比较:不同算法的执行效率(比如排序算法的速度)、不同硬件配置对同一个程序的影响(比如在不同电脑上跑一个游戏,看看帧率变化)、你对程序做了修改,看看新版本的程序是否比旧版本快了...

更重要的是,JMH 作为 OpenJDK项目的一部分,是一个专门针对 Java 语言的基准测试工具,它能够非常精确地测量 Java 代码的性能,并且考虑了 Java 虚拟机(JVM)的一些特殊情况,比如即时编译(JIT),这样得到的测试结果更真实可靠。

下面就根据JMH官方提供的 Demo,看看该怎么使用 JMH。

基本使用

@Benchmark、Options 和 Runner

先看第一个 Demo

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

public class HelloWorld {
    @Benchmark
    public void wellHelloThere() {
        // this method was intentionally left blank.
    }
    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(HelloWorld.class.getSimpleName())
                .forks(1)
                .build();

        new Runner(opt).run();
    }
}

上述代码是 JMH(Java Microbenchmark Harness)提供的一个简单示例,用来展示如何编写和运行一个基本的基准测试。

@Benchmark注解标记了wellHelloThere方法,这是一个基准测试方法。这个注解是 JMH 框架的核心部分,所有要进行基准测试的代码都必须用 @Benchmark 注解标识。

OptionsBuilder 用于配置和构建基准测试的选项。它允许自定义基准测试的各种参数,例如要包含的测试类、执行次数、预热设置等。

include(String regex) :选择要运行的基准测试类或方法。支持正则表达式,可以用来选择多个匹配的类或方法,这里是当前类HelloWorld

forks(int count):每次 fork 会启动一个新的 JVM 实例来运行基准测试,这有助于隔离 JVM 的状态,以获得更稳定的基准测试结果。这里设置JMH启动的JVM进程数量为1(默认值是10)。

除此之外,常用的设置还有很多:

warmupIterations(int count):设置预热(Warmup)的迭代次数。预热可以让 JVM 充分优化代码,避免初始执行时的结果不准确。

measurementIterations(int count):设置测量(Measurement)的迭代次数。在预热之后,正式的测量将在此阶段进行。

threads(int count):设置用于基准测试的线程数量。可以用来模拟多线程环境下的性能。

output(String path) :将基准测试的结果输出到指定文件中。

timeUnit(TimeUnit unit):设置时间单位,用于表示基准测试结果的时间。可以选择 TimeUnit.MILLISECONDSTimeUnit.MICROSECONDS 等。

Runner 用于实际执行基准测试。它根据通过 OptionsBuilder 配置的选项来运行指定的基准测试,并输出测试结果。

当运行 Runner 后,JMH 会输出一系列结果数据,下面为运行上述代码生成的测试结果:

# JMH version: 1.37 //JMH 版本
# VM version: JDK 22.0.2, Java HotSpot(TM) 64-Bit Server VM, 22.0.2+9-70 // Java 版本
# VM invoker: D:\ProgrammingSoft\Java\jdk22\bin\java.exe # VM options: -javaagent:D:\ProgrammingSoft\IDEAJ\IntelliJ IDEA 2024.1.4\lib\idea_rt.jar=6254:D:\ProgrammingSoft\IDEAJ\IntelliJ IDEA 2024.1.4\bin -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 
# Blackhole mode: compiler (auto-detected, use -Djmh.blackhole.autoDetect=false to disable) 
# Warmup: 5 iterations, 10 s each // 进行了 5 次预热,每次 10 秒
# Measurement: 5 iterations, 10 s each // 也进行了 5 次正式测量,每次 10 秒
# Timeout: 10 min per iteration // 每次迭代的最大超时时间为 10 分钟
# Threads: 1 thread, will synchronize iterations // 使用了一个线程来执行代码

# Benchmark mode: Throughput, ops/time // 吞吐量模式
# Benchmark: com.cango.jmh.HelloWorld.wellHelloThere // 基准测试方法
# Run progress: 0.00% complete, ETA 00:01:40 // 测试进度,当前测试刚开始,预计还需要 1 分钟 40 秒完成。
# Fork: 1 of 1 
# Warmup Iteration 1: 4390947059.857 ops/s 
# Warmup Iteration 2: 4380977153.733 ops/s 
# Warmup Iteration 3: 4386541103.152 ops/s 
# Warmup Iteration 4: 4327618977.315 ops/s 
# Warmup Iteration 5: 4339508415.539 ops/s 
Iteration 1: 4361376910.937 ops/s 
Iteration 2: 4375999730.340 ops/s 
Iteration 3: 4383174881.174 ops/s 
Iteration 4: 4387961807.339 ops/s 
Iteration 5: 4389681619.371 ops/s

Result "com.cango.jmh.HelloWorld.wellHelloThere":
  4379638989.832 +- (99.9%) 44298054.531 ops/s [Average]
  (min, avg, max) = (4361376910.937, 4379638989.832, 4389681619.371), stdev = 11504064.087
  CI (99.9%): [4335340935.301, 4423937044.363] (assumes normal distribution)

上述测试结果,可以分成三部分:

第一部分,是基准测试配置项,可以看出,JMH 自动检测到在当前情况下,可能存在编译器优化的问题,自动启用了 Blackhole 模式,确保基准测试代码得到正确的测量。(Blackhole 也会阻止 JVM 编译器将这些代码优化掉,进而确保基准测试的结果是准确和可信的。)

而且就算我们没有配置 warmupIterationsmeasurementIterations 还是默认预热五次,进行了五次测量,而且默认线程为 1,并对每次迭代进行同步处理。

其实这些配置,和基准测试使用的模式有关。

第二部分是基准测试使用的模式,这里使用的是吞吐量模式(Throughput Mode),主要用于测量每秒钟执行的操作数(operations per second,ops/s)。

吞吐量模式适合用于高负载场景的测试,比如服务端处理请求的能力,或者计算密集型任务的性能。

然后就是五次预热迭代时的吞吐量,五次正式测试的吞吐量,上述结果显示wellHelloThere 方法在单线程情况下的吞吐量非常高,达到每秒约 4.38 亿次操作。

虽然预热阶段的主要目的是让 JVM 热身,并优化代码执行,但它也会测量吞吐量。

最后一部分就是测试结论了:平均吞吐量 4379638989.832 ops/s误差范围 ± 44298054.531 ops/s最小、平均和最大吞吐量标准差置信区间(CI)

平均吞吐量大约是 4.38 亿次操作每秒,这表明测试的方法在单线程下性能非常高。这个值很高,毕竟测试的是一个空方法嘛。

第20个 Demo 演示了使用 JMH 提供的注解(annotations)来配置基准测试的默认行为,而不是在代码中指定 Options 参数。

@Warmup: 设置基准测试的预热阶段,等同于 OptionsBuilder#warmup(),如 @Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)

@Measurement: 设置基准测试的测量阶段,等同于 OptionsBuilder#measurement(),如 @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)

@Fork: 设置基准测试的 fork 次数,等同于 OptionsBuilder#forks(),如 @Fork(1)

@Threads: 设置基准测试的线程数,等同于 OptionsBuilder#threads(),如 @Threads(4)

⨳ ...

第 25 个 Demo 是使用 JMH API 和遗传算法(GA)来优化基准测试的 JVM 参数。通过不断迭代调整 JVM 参数,试图找到提高性能的最佳配置,比较复杂,但还是注解最好用。

@BenchmarkMode 基准测试模式

官方提供的第二个 Demo 是关于基准测试模式,模式很多,这里就节选一个看看模式配置方式:

@Benchmark
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
public void measureThroughput() throws InterruptedException {
    TimeUnit.MILLISECONDS.sleep(100);
}

@BenchmarkMode 告诉 JMH 如何测量和报告基准测试结果。JMH 支持多种基准测试模式,每种模式适用于不同类型的性能测量。

Mode.Throughput:吞吐量,关注每秒钟能够完成的操作次数。

Mode.AverageTime:平均时间,测量每次操作的平均时间。适用于测量每次操作的执行时间。

Mode.SampleTime:样本时间,测量每次操作的时间样本。适用于获取时间分布的样本数据。

Mode.SingleShotTime:单次执行时间,测量单次操作的时间,通常用于测试一次操作的性能。

Mode.All:同时测量所有模式(吞吐量、平均时间、样本时间、单次执行时间)。适用于全面分析性能。

@State 状态对象

官方提供的第三个 Demo 是关于基准测试中的状态对象的。

@State 用于定义基准测试中的状态对象。状态对象通常用于在多个基准测试方法之间共享数据,或者在不同的线程或迭代中维护不同的状态。

@State 注解的作用域由 Scope 枚举决定,常用的有以下几种:

Scope.Benchmark 在整个基准测试过程中,所有的线程共享同一个状态实例,用于测量在多个线程之间共享资源时的性能表现。

@State(Scope.Benchmark)
public static class BenchmarkState {
    volatile double x = Math.PI; // 共享变量
}

@Benchmark
public void measureShared(BenchmarkState state) {
    // All benchmark threads will call in this method.
    //
    // Since BenchmarkState is the Scope.Benchmark, all threads
    // will share the state instance, and we will end up measuring
    // shared case.
    state.x++;
}

Scope.Thread 每个线程都有自己的状态实例,用于测量每个线程独立操作时的性能表现。

@State(Scope.Thread)
public static class ThreadState {
    volatile double x = Math.PI;
}

@Benchmark
public void measureUnshared(ThreadState state) {
    // All benchmark threads will call in this method.
    //
    // However, since ThreadState is the Scope.Thread, each thread
    // will have it's own copy of the state, and this benchmark
    // will measure unshared case.
    state.x++;
}

在测试中,通过选择合适的 Scope 来隔离或共享状态,模拟真实的使用场景,比如当多个基准测试方法需要访问或修改相同的资源时,使用 State.Benchmark 注解来定义这些资源的状态。

这样定义成员变量的方式怎么别扭呢?第四个 Demo 说明@State 注解也可以注解到类上:

@State(Scope.Thread)
public class DefaultState {
    double x = Math.PI;

    @Benchmark
    public void measure() {
        x++;
    }
    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(DefaultState.class.getSimpleName())
                .forks(1)
                .build();

        new Runner(opt).run();
    }
}

@State(Scope.Thread) 声明在 DefaultState 类上,那每个线程都有自己的 DefaultState 实例,而不是共享一个实例。

注意第四个 Demo 的类名是 DefaultState ,默认状态,所以默认情况下,基准测试类的状态是线程范围的 (Scope.Thread),每个线程都会有该类的一个独立实例,这样的设计能够避免在多线程基准测试中出现数据竞争。

第 29 个 Demo ,展示了通过有向无环图(DAG)方式引用多个 @State,了解就行,JMH 也说了这是一个实验性功能,随时可能被删除。

@Setup、@TearDown 声明周期

第五个 Demo 是为了说明成员属性和方法属性作用域的,这上小学就会了,但这个例子提供了两个新的注解:


    @Setup
    public void prepare() {
        x = Math.PI;
    }

    @TearDown
    public void check() {
        assert x > Math.PI : "Nothing changed?";
    }

@Setup 标记的方法将在基准测试运行之前执行。它通常用于初始化资源、设置测试数据或执行任何需要在测试开始前完成的操作。

@TearDown 标记的方法将在基准测试完成后执行。它通常用于释放资源、清理数据或执行任何需要在测试结束后完成的操作。

这不就是生命周期注解吗,Junit 也有,而且还比 JMH 丰富,Junit 的 @BeforeAll 在所有测试方法执行之前运行可以类比 @Setup 注解,那 Junit 还有可以在每个测试方法执行之前运行的 @BeforeEach,JMH 就类似功能的注解吗?

其实也有,第六个Demo和第七个Demo,就说明@Setup@TearDown 注解的方法可以通过 Level 来指定它们的执行时机:

@Setup(Level.Trial) 默认级别,在整个基准测试运行的开始时执行。

@Setup(Level.Iteration) 在每次基准测试迭代的开始时执行。

@Setup(Level.Invocation) 在基准测试迭代(即多次调用的序列)之前运行。

@TearDown 也可以使用这三个级别,学习要回举一反三哦。

如果测试一个排序程序,@Setup 可以初始化原始数据,在每次调用前执行 @Setup 问题不大,如果@Setup 不在每次执行排序时执行,那么只有第一次排序是执行了排序,后面每次排序的都是相同顺序的数据,第 38 个 Demo 给出了一个解决方案,就是数据拷贝一份再进行排序:

    @State(Scope.Thread)
    public static class DataCopy {
        byte[] copy;

        @Setup(Level.Invocation)
        public void setup2(Data d) {
            copy = Arrays.copyOf(d.arr, d.arr.length);
        }
    }

@Param 参数测试

@ParamJUnit@ParameterizedTest 很像,都是需要验证多个输入组合时使用。

@Param({"1", "31", "65", "101", "103"})
public int arg;

@Param({"0", "1", "2", "4", "8", "16", "32"})
public int certainty;

@Benchmark
public boolean bench() {
    return BigInteger.valueOf(arg).isProbablePrime(certainty);
}

详见第 27 个 Demo

对抗 JIT 编译器优化

JVM 在使用基于分析的优化方面非常出色,比如JIT 编译器在运行时就会对代码进行优化,包括:

死代码消除:JIT 编译器会在编译过程中检测并移除那些不会影响程序最终输出的代码部分。

常量折叠:如果编译器确定某些计算的结果是恒定的,可能会将这些计算移除或替换为常量。

内联:将方法调用的代码直接插入到调用处,从而减少方法调用的开销。

循环优化:优化循环中的代码,例如将不变的计算移到循环外部。

但这对于基准测试来说是个坏消息,下面介绍的几个Demo就是说明这个问题,和怎么避免这个问题的。

死代码消除

第八个 Demo 是说死代码消除(Dead-Code Elimination,DCE) 问题,如果被消除的部分是我们要基准测试的代码,那就不完犊子了。


@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class DeadCode {
    private double x = Math.PI;
    private double compute(double d) {
        for (int c = 0; c < 10; c++) {
            d = d * d / Math.PI;
        }
        return d;
    }
    @Benchmark
    public void measureWrong() {
        // This is wrong: result is not used and the entire computation is optimized away.
        compute(x);
    }

上述 measureWrong 方法,虽然调用了 compute(x),但是因为没有使用计算结果,编译器可能会优化掉整个计算。

怎么避免死代码消除呢?很简单,返回计算结果就可以啦。

    @Benchmark
    public double measureRight() {
        // This is correct: the result is being used.
        return compute(x);
    }

如果需要测试两个计算,总不能返回一个计算结果呢,另一个会被消除掉的:

    @Benchmark
    public double measureWrong() {
        compute(x1);
        return compute(x2);
    }

JMH在第九个 Demo 中给出了解决方案:

⨳ 可以将两次结果合并成一个,并且返回:

    @Benchmark
    public double measureRight_1() {
        return compute(x1) + compute(x2);
    }

⨳ 也可以采用采用了 JMH 提供的 Blackhole 对象

    @Benchmark
    public void measureRight_2(Blackhole bh) {
        bh.consume(compute(x1));
        bh.consume(compute(x2));
    }

Blackhole 除了可以防止死代码消除,最主要的功能是消耗 CPU 周期。如第 21 个 Demo 给出的案例:

  • 消耗 32 个 CPU 周期。
@Benchmark
public void consume_0032() {
    Blackhole.consumeCPU(32);
}
  • 消耗 1024 个 CPU 周期。
@Benchmark
public void consume_1024() {
    Blackhole.consumeCPU(1024);
}

消耗的周期数越多,方法的执行时间越长,这在需要模拟某些 CPU 密集型操作时很好用。

Blackhole 还可以在辅助方法(如@Setup 和 @TearDown 方法)中使用,详见第 28 个 Demo

常量折叠

常量折叠 (constant-folding)包括两部分:

识别常量表达式: 编译器在编译源代码时,会识别出那些仅由常量值组成的表达式。这些表达式的结果可以在编译阶段直接计算出来。

计算并替换: 编译器会计算这些常量表达式的结果,并将结果替换原来的表达式。这意味着在运行时,这些表达式不再需要计算,因为结果已经在编译时得到了处理。

这也是第10个Demo给出的案例:

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class ConstantFold {
    private double x = Math.PI;
    private final double wrongX = Math.PI;
    private double compute(double d) {
        for (int c = 0; c < 10; c++) {
            d = d * d / Math.PI;
        }
        return d;
    }
    
    @Benchmark
    public double measureWrong_1() {
        // This is wrong: the source is predictable, and computation is foldable.
        return compute(Math.PI);
    }

    @Benchmark
    public double measureWrong_2() {
        // This is wrong: the source is predictable, and computation is foldable.
        return compute(wrongX);
    }

    @Benchmark
    public double measureRight() {
        // This is correct: the source is not predictable.
        return compute(x);
    }
}

measureWrong_1()measureWrong_2() :因为输入值是可预测的常量,所以这些计算可能会被 JVM 优化掉。

measureRight() :使用非 final 实例字段 x 作为输入值,防止了常量折叠,确保基准测试的结果更准确。

循环优化问题 @OperationsPerInvocation

常见的循环优化(Loop Optimization)包括循环展开、循环移位、循环并行化等。

循环展开(Loop Unrolling) : 将一个循环的多个迭代合并到同一块代码中,减少循环控制开销。

循环移位(Loop Fusion) : 将多个循环合并为一个循环,以减少对数据的访问次数和循环控制开销。

循环分裂(Loop Splitting):循环分裂是将一个复杂的循环分成多个简单的循环,以便编译器能够更容易地优化每个小循环。

⨳ ...

第11个 Demo 说,很多人会在基准测试方法中进行循环,这样看似和JML使用迭代调用单一计算差不多,其实不然,这可能导致优化器合并循环迭代,从而使基准测试结果失真。

比如说想测量两个整数相加的性能,错误的方式是在准测试方法中进行循环:

    int x = 1;
    int y = 2;
    
    private int reps(int reps) {
        int s = 0;
        for (int i = 0; i < reps; i++) {
            s += (x + y);
        }
        return s;
    }
    
    @Benchmark
    @OperationsPerInvocation(10)
    public int measureWrong_10() {
        return reps(10);
    }
    

正确的做法是:

    @Benchmark
    public int measureRight() {
        return (x + y);
    }

第11个 Demo 还引入了一个新的注解 @OperationsPerInvocation,当一个基准测试方法中包含循环或批处理逻辑时,每次方法调用实际上会执行多次操作。@OperationsPerInvocation 可以明确告诉 JMH,这个方法调用包含多少次操作。JMH 将使用这个信息来准确计算每次操作的时间。

但即使给在内部进行循环的方法加上@OperationsPerInvocation,但还是由于 JVM 优化器可能会合并循环迭代,导致结果失真。

循环问题不得不仔细,第 34 个 Demo 就给出了怎么安全的测试一个循环,比如使用 Blackhole

@Benchmark
public void measureRight_1(Blackhole bh) {
    for (int x : xs) {
        bh.consume(work(x));
    }
}

@Fork 分叉进程

第12个 Demo 是说:如果不同的基准测试在同一 JVM 中运行,JIT 编译器可能会将这些测试的代码优化在一起。一个测试的优化可能会影响其他测试的代码。

可以使用 @Fork 注解指定基准测试在执行时要分叉多少个独立的 JVM 进程,从而避免JVM 优化对基准测试结果的干扰。

@Fork(0) :表示基准测试将在当前 JVM 进程中运行,不会启动新的进程。

    @Benchmark
    @Fork(0)
    public int measure_1_c1() {
        return measure(c1);
    }

@Fork(1) :表示每个测试将启动一个新的 JVM 进程。

    @Benchmark
    @Fork(1)
    public int measure_4_forked_c1() {
        return measure(c1);
    }

运行方差

运行方差(run-to-run variance)指的是每次运行程序时,结果在不同运行之间的波动情况。这种方差是由于诸多因素引起的,比如JVM 优化、垃圾回收(GC)、I/O 操作...软硬件的不同,都会导致单次性能测试结果的不可预测性。

为了得到更稳定的性能数据,就可以对同一测试进行多次独立运行,收集多组数据,计算平均值和标准差。JMH 的 Forking 机制就是为此设计的,通过在不同 JVM 实例中多次运行同一基准测试,聚合多次运行的结果,从而更好地捕捉运行方差。

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class RunToRun {

  @State(Scope.Thread)
    public static class SleepyState {
        public long sleepTime;

        @Setup
        public void setup() {
            sleepTime = (long) (Math.random() * 1000);
        }
    }
    @Fork(1)
    public void baseline(SleepyState s) throws InterruptedException {
        TimeUnit.MILLISECONDS.sleep(s.sleepTime);
    }

    @Benchmark
    @Fork(5)
    public void fork_1(SleepyState s) throws InterruptedException {
        TimeUnit.MILLISECONDS.sleep(s.sleepTime);
    }

    @Benchmark
    @Fork(20)
    public void fork_2(SleepyState s) throws InterruptedException {
        TimeUnit.MILLISECONDS.sleep(s.sleepTime);
    }
}

第十三个Demo通过 SleepyState 状态对象引入了一个随机的 sleepTime(睡眠时间)。这个时间是随机生成的,每次运行可能不同,模拟了在现实场景中一些工作负载可能因环境或资源不同而导致执行时间的变化。

基准测试方法 baselinefork_1fork_2 分别使用了不同的 Fork 次数,来比较不同次数的 JVM Fork 对结果的影响:

@Fork(1):只启动一个 JVM 来执行测试。

@Fork(5):启动 5 个不同的 JVM 进行测试。

@Fork(20):启动 20 个 JVM 来测试。

HotSpot 编译器控制 @CompilerControl

第16个 Demo 引入了一个新的注解 @CompilerControl ,它用于控制 JIT 编译器如何处理基准测试中的方法,它允许强制编译器执行或跳过某些优化。

CompilerControl.Mode.INLINE:强制编译器将被标记的方法进行内联优化。这意味着方法调用会在编译时被替换为方法体的内容,从而消除方法调用的开销。

CompilerControl.Mode.DONT_INLINE: 禁止编译器对被标记的方法进行内联优化。这可以帮助测试方法在不进行内联的情况下的性能。

CompilerControl.Mode.EXCLUDE:禁止编译器编译被标记的方法。这意味着该方法不会被编译成字节码,因此在测试中不会执行这个方法。这可以用于测试代码中其他部分的性能而不受该方法的影响。

CompilerControl.Mode.FULL:让编译器进行默认的优化,这通常是大多数情况下的标准行为。

多线程测试

非对称测试 @Group、 @GroupThreads

通常情况下,基准测试是对称的,即每个线程都运行相同的代码,但在某些情况下,尤其是模拟生产环境中的多线程交互时,不同线程可能执行不同的操作。例如,在生产者-消费者模式中,生产者线程负责生成数据,消费者线程负责处理数据,这就需要不同的线程执行不同的操作。这种场景可以通过非对称测试来实现。

非对称测试(Asymmetric Testing)在基准测试中指的是不同线程执行不同的任务,而不是所有线程执行相同的任务。

在 JMH ,非对称测试可以通过 @Group@GroupThreads 注解实现,它们允许定义多个线程组,每个线程组中的线程可以执行不同的基准方法。

@Group:用于定义多线程基准测试的线程组。通过使用 @Group,你可以将多个基准方法绑定在一起,让每个线程组中的不同线程执行不同的操作。

@GroupThreads :用于指定每个执行组中应该有多少个线程来执行被 @Group 注解的方法。

@State(Scope.Group)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class Asymmetric {
    private AtomicInteger counter;

    @Setup
    public void up() {
        counter = new AtomicInteger();
    }

    @Benchmark
    @Group("g")
    @GroupThreads(3)
    public int inc() {
        return counter.incrementAndGet();
    }

    @Benchmark
    @Group("g")
    @GroupThreads(1)
    public int get() {
        return counter.get();
    }
}

注意第十四个 Demo@State 不再是所有的线程共享同一个状态实例的Scope.Benchmark,也不是每个线程都有自己的状态实例的Scope.Thread,而是在执行组内共享状态的 Scope.Group

如上代码,使用 @Group("g"),将 inc()get() 方法绑定到名为 "g" 的执行组中。线程在这个组内可以执行这两个方法中的一个。

@GroupThreads(3)定义了执行组 "g" 中有 3 个线程同时执行 inc() 方法,@GroupThreads(1)定义了执行组 "g" 中有 1 个线程执行 get() 方法,整个线程组共五个线程。

线程同步 syncIterations

在多线程基准测试中,线程的启动和停止会显著影响性能。自然的做法是让所有线程在某个屏障上等待,然后一起开始。然而,这种做法可能导致线程启动时间的不一致,从而影响结果的准确性。

JMH 通过引入假迭代(bogus iterations),逐步增加执行线程,然后原子地开始和结束测量,来处理这个问题。这种方式能够确保线程同步,从而提供更一致的测试结果。

@State(Scope.Thread)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class SyncIterations {
    private double src;
    @Benchmark
    public double test() {
        double s = src;
        for (int i = 0; i < 1000; i++) {
            s = Math.sin(s);
        }
        return s;
    }
}

第 17 个 Demo 提供的测试方法很常规,@State(Scope.Thread) 说明每个线程拥有独立的 SyncIterations 实例,src是被基准测试的方法中的输入数据,test()方法进行了一系列的 Math.sin() 计算,目的是测试性能。

特殊的是这个 Demo 对 Options 选项的构建:


public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder()
            .include(JMHSample_17_SyncIterations.class.getSimpleName())
            .warmupTime(TimeValue.seconds(1))
            .measurementTime(TimeValue.seconds(1))
            .threads(Runtime.getRuntime().availableProcessors()*16)
            .forks(1)
            .syncIterations(true) // try to switch to "false"
            .build();

    new Runner(opt).run();
}

warmupTime: 热身时间设置为 1 秒,用于让 JVM 在实际测量前进行初始化。

measurementTime: 测量时间设置为 1 秒,用于实际的性能测量。

threads: 设置线程数为 CPU 核心数的 16 倍,这样可以更明显地看到同步迭代的效果。

forks: 设置为 1,表示每个测试运行一次。

syncIterations(true) : 启用同步迭代,这将确保在所有线程开始和结束时都保持同步,也就是等所有线程预热完成,然后所有线程一起进入测量阶段,等所有线程执行完测试后,再一起进入关闭。

状态对象 Control

Control 对象的主要目的是帮助控制测试的执行流。例如,当测试即将结束或当测量阶段停止时,Control 对象会提供一个状态标志,让线程能够及时地退出循环或停止执行。

第18个Demo就是展示Control 对象的用法:

@State(Scope.Group)
public class JMHSample_18_Control {

    public final AtomicBoolean flag = new AtomicBoolean();

    @Benchmark
    @Group("pingpong")
    public void ping(Control cnt) {
        while (!cnt.stopMeasurement && !flag.compareAndSet(false, true)) {
            // this body is intentionally left blank
        }
    }

    @Benchmark
    @Group("pingpong")
    public void pong(Control cnt) {
        while (!cnt.stopMeasurement && !flag.compareAndSet(true, false)) {
            // this body is intentionally left blank
        }
    }

在基准测试期间,JMH 会在测量阶段结束时将 stopMeasurement 标志设置为 true,让线程能够及时停止工作。这防止了线程在测量结束后继续执行,确保测量数据的准确性。

如果测试代码出现死锁,陷入阻塞怎么办,第 27 个 Demo ,讲了可以设置超时时间:

public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder()
            .include(JMHSample_30_Interrupts.class.getSimpleName())
            .threads(2)
            .forks(5)
            .timeout(TimeValue.seconds(10))
            .build();

    new Runner(opt).run();
}

JMH 会在达到默认或用户指定的超时时间后开始发出中断,强制中断基准测试线程。

伪共享

现代 CPU 使用缓存来减少对主存的访问延迟,而缓存通常以缓存行(Cache Line)为单位存储数据。一个缓存行的大小通常是 64 字节左右。如果两个线程同时访问位于同一缓存行中的两个不同的变量,即使它们访问的是不同的变量,缓存一致性协议仍会导致频繁的缓存刷新和同步,从而降低性能,这就是伪共享。

第22个 Demo 就讲了怎么可以避免伪共享。

如下两个共享变量,看似是两个实际上因为内存地址相邻,会一起缓存,一起刷新:

@State(Scope.Group)
public static class StateBaseline {
    int readOnly;
    int writeOnly;
}

JMH这个案例展示了四种不同的方式来缓解伪共享问题:

填充 :通过在 readOnlywriteOnly 字段之间添加虚拟字段来分隔它们,从而减少它们位于同一缓存行的可能性。虽然填充可以有效减少伪共享,但 JVM 可能会重新排列字段顺序,因此效果有限。

@State(Scope.Group)
public static class StatePadded {
    int readOnly;
    int p01, p02, p03, p04, p05, p06, p07, p08;
    int p11, p12, p13, p14, p15, p16, p17, p18;
    int writeOnly;
    int q01, q02, q03, q04, q05, q06, q07, q08;
    int q11, q12, q13, q14, q15, q16, q17, q18;
}

类层次技巧 :通过继承层次结构将字段分隔到不同的类层次中。这样可以确保字段位于不同的缓存行,缓解伪共享。

public static class StateHierarchy_1 {
    int readOnly;
}

public static class StateHierarchy_2 extends StateHierarchy_1 {
    // padding bytes
}

public static class StateHierarchy_3 extends StateHierarchy_2 {
    int writeOnly;
}

数组技巧 : 使用数组将数据稀疏化,使读取和写入操作在不同的数组索引上进行,避免共享同一缓存行。

@State(Scope.Group)
public static class StateArray {
    int[] arr = new int[128];
}

@Contended :使用 @Contended 注解(从 JDK 8 开始引入),显式告诉 JVM 将这些字段放在不同的缓存行中。需要启用 JVM 选项 -XX:-RestrictContended 才能使用这个注解。

public static class StateContended {
    int readOnly;

    @sun.misc.Contended
    int writeOnly;
}

总之,要么避免两个不同的共享变量在一个缓存行,要么禁用缓存。

小技巧

还有几个案例,下面就简单说一下。

继承

第 24 个 Demo,展示了使用继承来创建基准测试。

通过在抽象超类中定义基准测试方法,并在子类中实现具体的测试逻辑,你可以重用代码并清晰地组织不同的基准测试。

需要注意的是 在父类中定义了Benchmark,子类也会继承并合成自己的 Benchmark,这是JMH在编译期完成的,如果不是由JMH来编译,就无法享受这种继承。

批处理 batchSize

第 26 个 Demo 讲了对于小操作,单次测量可能不够可靠。通过批处理大小参数,我们可以定义每次测量时的 @Benchmark 调用次数,而不需要手动循环。

@Benchmark
@Warmup(iterations = 5, batchSize = 5000)
@Measurement(iterations = 5, batchSize = 5000)
@BenchmarkMode(Mode.SingleShotTime)
public List<String> measureRight() {
    list.add(list.size() / 2, "something");
    return list;
}

计数器 @AuxCounters

@AuxCounters 用于在基准测试中定义额外的计数器,这些计数器可以帮助你在基准测试中跟踪特定的操作或事件。使用这个注解可以生成额外的性能指标,如操作的吞吐量或者事件的发生率。

@AuxCounters 提供了两种计数器类型:

AuxCounters.Type.OPERATIONS:用于计数操作的次数。JMH 将会计算和记录这些操作的吞吐量(每秒操作次数)。

AuxCounters.Type.EVENTS:用于计数事件的次数。JMH 将记录这些事件发生的次数,通常用于评估某些特定事件的发生频率。

使用案例详见第 23 个 Demo

基础参数

第 31 个 Demo 演示了如何通过 JMH 基础设施对象查询当前基准测试的运行模式参数,可以在benchmark方法或前后处理的方法中注入下面三个基础设置对象:

BenchmarkParams:benchmark全局配置;

IterationParams:当前迭代的配置参数;

ThreadParams:当前线程的相关细节;

预热方式 WarmupMode

第 32 个 Demo 演示了三种预热方法:

WarmupMode.INDI:默认模式,JVM 为每个基准测试分别运行预热迭代,确保每个基准测试有自己独立的 JVM 状态。

WarmupMode.BULK:所有基准测试一起预热。

WarmupMode.BATCH:类似于 INDI 模式的变种,但通过某种批次处理的方式运行测试。

SecurityManager

第 33 个 Demo 演示了如何在启用和禁用 SecurityManager 的情况下测量 System.getProperty 的性能。

@State(Scope.Benchmark)
public static class SecurityManagerInstalled {
    @Setup
    public void setup() throws IOException, NoSuchAlgorithmException, URISyntaxException {
        URI policyFile = JMHSample_33_SecurityManager.class.getResource("/jmh-security.policy").toURI();
        Policy.setPolicy(Policy.getInstance("JavaPolicy", new URIParameter(policyFile)));
        System.setSecurityManager(new SecurityManager());
    }

    @TearDown
    public void tearDown() {
        System.setSecurityManager(null);
    }
}

Profilers

第 35 个 [Demo](java -jar target/benchmarks.jar MyBenchmark -prof gc ) 讲了JMH 提供了一些内置的分析器 (profiler),可以在运行基准测试时使用它们来深入了解基准的性能行为。尽管这些分析器无法完全替代成熟的外部分析工具,但它们对于快速分析和调试基准测试行为非常有用。

可以通过 -lprof 选项列出可用的 profiler,然后通过 -prof <profiler-name>:help 查看帮助。

java -jar target/benchmarks.jar -lprof

常用的 Profiler 有:

-prof stack 堆栈采样分析器,采集方法调用的堆栈信息,帮助分析 CPU 的使用情况,定位性能瓶颈。

-prof gc 垃圾回收分析器,帮助查看垃圾回收的活动频率、时间和产生的垃圾量,适用于内存敏感的基准测试。

-prof cl 类加载器分析器,分析类加载和卸载的行为,适用于检查类加载对性能的影响。

-prof comp 编译器分析器,记录即时编译器 (JIT) 的活动,适用于了解哪些方法被编译,JIT 编译对性能的影响。

-prof perf Linux 平台下的 perf 分析器,使用 Linux 的 perf 工具来采集性能计数器的统计信息。

-prof perfnorm 规范化的 perf 分析器,标准化并使 perf 输出更容易解读。

-prof perfasm Linux 平台下的 perf 汇编分析器,展示热点方法的汇编代码和性能计数器信息。

-prof xperfasm Windows 平台下的 xperf 汇编分析器,类似于 perfasm,但用于 Windows 系统。

-prof dtraceasm MacOS 平台下的 dtrace 汇编分析器,用于分析汇编级别的性能。

比如使用 gc profiler 运行基准测试,并显示垃圾回收信息:

public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder()
        .include(JMHSample_35_Profilers.Maps.class.getSimpleName())
//      .addProfiler(StackProfiler.class)
        .addProfiler(GCProfiler.class)
        .build();

    new Runner(opt).run();
}

CPU 特性

其实讲到 Profilers,整个 JMH 的使用就讲完了,但还有几个案例用于说明 CPU 特性对性能的影响。

分支预测

第 36 个 Demo 演示了展示分支预测如何影响程序的性能。

分支预测是一种现代 CPU 优化技术,旨在提高处理器的执行效率。它通过预测程序中的分支(如 if-elseswitch 语句)将执行哪个路径,从而减少等待判断结果的时间。

因为 CPU 是并行执行指令的,如果它必须等到条件判断完成后才能决定下一步执行哪条指令,那么执行效率会大大降低。当 CPU 遇到分支语句时,它会基于历史执行记录或模式来预测下一条指令执行的路径。如果预测正确,程序继续顺利执行;如果预测错误,则需要回滚并重新执行分支后的代码,这会造成性能损失。

在 第 36 个 Demo 中有两组数据,一个是经过排序的数组,一个是未排序的数组。由于排序后的数据有序,CPU 很容易预测 if (v > 0) 的结果,导致分支预测正确率高,从而提升性能。而在未排序的数据中,分支预测器很难判断 v > 0 的结果,分支预测错误率高,影响了整体性能。

缓存访问的差异

第 17 个 Demo 展示了行优先(row-first)和列优先(col-first)访问模式对性能的影响。

基准测下先搞了一个 4096x4096 的二维数组,并用随机整数填充。注意,Java 中的二维数组实际上是一个一维数组的数组,这会对访问模式产生影响。

@Setup
public void setup() {
    matrix = new int[COUNT][COUNT];
    Random random = new Random(1234);
    for (int i = 0; i < COUNT; i++) {
        for (int j = 0; j < COUNT; j++) {
            matrix[i][j] = random.nextInt();
        }
    }
}

在列优先访问中,代码首先遍历列,然后遍历行。这种访问模式在内存中是不连续的,因为 Java 的二维数组实际上是一个数组的数组。每次访问时,内存会跳转到不同的行,这会导致缓存未命中,从而降低性能。

@Benchmark
@OperationsPerInvocation(MATRIX_SIZE)
public void colFirst(Blackhole bh) {
    for (int c = 0; c < COUNT; c++) {
        for (int r = 0; r < COUNT; r++) {
            bh.consume(matrix[r][c]);
        }
    }
}

行优先访问则是先遍历行,然后遍历列。这种模式更符合内存的实际存储顺序,访问时内存地址是连续的,有助于提高缓存的命中率,从而提升性能。

@Benchmark
@OperationsPerInvocation(MATRIX_SIZE)
public void rowFirst(Blackhole bh) {
    for (int r = 0; r < COUNT; r++) {
        for (int c = 0; c < COUNT; c++) {
            bh.consume(matrix[r][c]);
        }
    }
}

在进行性能基准测试时,特别是涉及到大规模数据访问的测试,选择正确的访问模式至关重要。行优先访问通常能够更好地利用 CPU 缓存,从而提高性能。

更进一步,第 39 个 Demo 展示了在使用基本类型数组(int[])和包装类型集合(List<Integer>)时内存访问模式的不同。

基本类型数组(int[] :将 int 值存储在连续的内存中,内存访问效率高,缓存未命中较少。

ArrayList 的 Integer 包装类型(List<Integer> :存储的是指向 Integer 对象的引用,而不是实际的整数值。这导致了大量的缓存未命中,因为 Integer 对象通常分布在堆的不同位置。

乱序的 ArrayList(shuffledIntList :对 List<Integer> 进行随机打乱,进一步增加缓存未命中的可能性,使得内存访问更加不连续。

总结

JMH 一共给出了 39 个测试案例,都讲完了。

总而言之,Junit是测试程序对不对,JMH 是测试程序快不快,还有一个压力测试用于测试程序稳不稳。