Java性能测试框架—JMH详解

前言

JMH(Java Microbenchmark Harness)是一个面向 Java 语言或者其他 Java 虚拟机语言的性能基准测试框架。它针对的是纳秒级别、微秒级别、毫秒级别,以及秒级别的性能测试。

快速上手

虽然JMH是随着Java9一起发布的,但是它并没有与任何具体的Java版本绑定,而且JDK中没有支持JMH的工具,所以JMH的安装是非常简单的,我们只需要在Maven项目中引入下面的依赖,就可以轻松愉快地写代码了。

xml 复制代码
 <dependency>
     <groupId>org.openjdk.jmh</groupId>
     <artifactId>jmh-generator-annprocess</artifactId>
     <version>1.36</version>
 </dependency>
  <dependency>
 <groupId>org.openjdk.jmh</groupId>
     <artifactId>jmh-core</artifactId>
     <version>1.36</version>
 </dependency>

当然,我们也可以使用命令行的方式创建项目:

ini 复制代码
 $ mvn archetype:generate \
   -DinteractiveMode=false \
   -DarchetypeGroupId=org.openjdk.jmh \
   -DarchetypeArtifactId=jmh-java-benchmark-archetype \
   -DgroupId=org.sample \
   -DartifactId=test \
   -Dversion=1.0

一个完整的例子如下所示:

java 复制代码
 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() {
         // 这里书写你需要测试的代码
     }
     
     //运行JMH代码的一种方式
     public static void main(String[] args) throws RunnerException {
         Options opt = new OptionsBuilder()
                 .include(HelloWorld.class.getSimpleName())
                 .forks(1)
                 .build();
 ​
         new Runner(opt).run();
     }
 }

在这个例子中,你既可以通过运行main方法 的方式执行代码,也可以打包为jar 文件,通过命令行的方式执行。

shell 复制代码
 $ cd test/
 $ mvn clean verify
 $ java -jar target/benchmarks.jar

本文笔者在IDEA中使用main方法的方式执行代码。

首先,JHM会打印本机环境的配置信息,以及本次测试的整体信息:

shell 复制代码
 # JMH version: 1.36
 # VM version: JDK 11.0.12, Java HotSpot(TM) 64-Bit Server VM, 11.0.12+8-LTS-237
 # VM invoker: C:\Program Files\Java\jdk-11.0.12\bin\java.exe
 # VM options: -javaagent:C:\Users\chao\AppData\Local\JetBrains\Toolbox\apps\IDEA-U\ch-1\232.8660.185\lib\idea_rt.jar=60334:C:\Users\chao\AppData\Local\JetBrains\Toolbox\apps\IDEA-U\ch-1\232.8660.185\bin -Dfile.encoding=UTF-8
 # Blackhole mode: full + dont-inline hint (auto-detected, use -Djmh.blackhole.autoDetect=false to disable)
 # Warmup: 5 iterations, 10 s each
 # Measurement: 5 iterations, 10 s each
 # Timeout: 10 min per iteration
 # Threads: 1 thread, will synchronize iterations
 # Benchmark mode: Throughput, ops/time
 # Benchmark: com.szc.benchmark.HelloWorldBenchmark.measure

然后,以此打印每个测试的过程信息:

bash 复制代码
 # Run progress: 0.00% complete, ETA 00:08:20
 # Fork: 1 of 5
 # Warmup Iteration   1: 2635805003.372 ops/s
 # Warmup Iteration   2: 2616635602.687 ops/s
 # Warmup Iteration   3: 2618041383.077 ops/s
 # Warmup Iteration   4: 1970496590.693 ops/s
 # Warmup Iteration   5: 1976220421.389 ops/s
 Iteration   1: 1972121906.389 ops/s
 Iteration   2: 1951305842.294 ops/s
 Iteration   3: 1963541171.486 ops/s
 Iteration   4: 1965423944.882 ops/s
 Iteration   5: 1972175221.548 ops/s

最终统计信息如下:

ini 复制代码
 Result "com.szc.benchmark.HelloWorldBenchmark.measure":
   1969124308.515 ±(99.9%) 8905169.278 ops/s [Average]
   (min, avg, max) = (1951305842.294, 1969124308.515, 2001098858.414), stdev = 11888146.208
   CI (99.9%): [1960219139.237, 1978029477.794] (assumes normal distribution)
   
 # Run complete. Total time: 00:08:24
 ​
 Benchmark                     Mode  Cnt           Score         Error  Units
 HelloWorldBenchmark.measure  thrpt   25  1969124308.515 ± 8905169.278  ops/s

我们可以看到,总共有 5 个派生(fork)进程,即 5 次测试。每次测试都在一个独立的JVM中进行。在每个JVM中:JMH默认首先执行了5次预热迭代,每次 10s,然后执行了5次测量迭代,每次 10s,最终报告只统计测量迭代的结果。

其中预热迭代能够让编译器充分优化代码,这里我们后续会展开讲解。

一些解释:

  • min,avg, max,stdev分别代表最小值,平均值,最大值和标准差。
  • CI:在99.9%的置信区间下,我们可以预计数据的平均值在[1960219139.237, 1978029477.794]之间。
  • Mode:thrpt代表着测量单位是吞吐量
  • Cnt代表测量迭代的次数(一共5个派生进程,每个派生进程中测量迭代 5 次)
  • ScoreErrorsUnits 分别代表得分,误差,单位。

可以看出,JMH可以帮助我们进行必要的统计分析,让我们直到特定结果的偏差是否在可以接受的范围内。

JMH基本注解

BenchmarkMode和OutputTimeUnit

less 复制代码
 //JMHSample_02_BenchmarkModes
 @Benchmark
 @BenchmarkMode(Mode.Throughput) //指定基准测试的模式(吞吐量)
 @OutputTimeUnit(TimeUnit.SECONDS) //指定测量单位(秒)
 public void measureThroughput() throws InterruptedException {
     TimeUnit.MILLISECONDS.sleep(100);
 }

其中:

  • @OutputTimeUnit 可以指定输出的时间单位,可以传入 java.util.concurrent.TimeUnit 中的时间单位,最小可以到纳秒级别;
  • @BenchmarkMode 指明了基准测试的模式
    • Mode.Throughput:吞吐量,单位时间内执行的次数
    • Mode.AverageTime:平均时间,一次执行需要的单位时间,其实是吞吐量的倒数
    • Mode.SampleTime:基于采样的执行时间,采样频率由JMH自动控制,同时结果中也会统计出p50、p90、p99.9之类的时间(pxx即xx%分位数)
    • Mode.SingleShotTime:单次执行时间,只执行一次,可用于冷启动的测试。

这些模式可以自由组合,甚至可以使用全部。

  • @BenchmarkMode({Mode.Throughput,Mode.AverageTime}):自由组合
  • @BenchmarkMode(Mode.All):使用全部

Scope范围

java 复制代码
 //JMHSample_03_States
 //所有测试线程共享一个实例,用于测试有状态实例在多线程共享下的性能
 @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++;
 }
 //每个测试线程分配一个实例,相当于ThreadLocal变量
 @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++;
 }
 //每个线程组共享一个实例
 @State(Scope.Group)
 public static class GroupState {
     //your code
 }
 ​
 public static void main(String[] args) throws RunnerException {
     Options opt = new OptionsBuilder()
         .include(JMHSample_03_States.class.getSimpleName())
         .threads(4) //设置测试运行时使用的线程数为4,模拟多线程的情景
         .forks(1)
         .build();
 ​
     new Runner(opt).run();
 }

另外,注意到,JMH可以像Spring一样自动注入这些变量,十分方便。

JMH允许你指定测试运行时所使用的线程数。通过.threads()方法,你可以设置并发执行测试的线程数量。

我们分别在单线程和多线程的条件下运行上面的Benchmark,可以看到,线程共享与否,对于Score的影响是比较大的(约10倍)。

threads=4的情况

threads=1的情况

对于注解@State,为了方便,我们可以在类上标注,从而将基准实例本身标记为@State。 然后,我们可以像任何其他 Java 程序一样引用它自己的字段。

通过类注解,假如我们需要将类中所有的字段都标记为@State(Scope.Thread),只需要标记一次就可以了。

java 复制代码
 /*
  * Fortunately, in many cases you just need a single state object.
  * In that case, we can mark the benchmark instance itself to be
  * the @State. Then, we can reference its own fields as any
  * Java program does.
  */
 @State(Scope.Thread)
 public class JMHSample_04_DefaultState {
     double x = Math.PI;
     @Benchmark
     public void measure() {
         x++;
     }
 }

Scope分组

使用@State(Scope.Group)设置作用域为组,可以模拟多线程情景:

java 复制代码
 @State(Scope.Group)
 @BenchmarkMode(Mode.AverageTime)
 @OutputTimeUnit(TimeUnit.NANOSECONDS)
 public class JMHSample_15_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();
     }
 }

@Group 定义了一个线程组, @GroupThreads 可以分配线程给测试用例,可以测试线程执行不均衡的情况,比如三个线程写,一个线程读,这里用 @State(Scope.Group) 定义了counter 作用域是这个线程组。运行结果也是分组展示:

Setup和TearDown

typescript 复制代码
@State(Scope.Thread)
public class JMHSample_05_StateFixtures {
    double x;
    @Setup //基准测试前的初始化动作
    public void prepare() {
        x = Math.PI;
    }
    @TearDown //基准测试结束后的收尾工作
    public void check() {
        assert x > Math.PI : "Nothing changed?";
    }
}

在注解SetupTearDown修饰的方法中,我们可以做一些准备和收尾工作。当然,它们也有等级区分。Level参数表明粒度,粒度从粗到细分别是:

  • Level.Invocation:每次方法调用级别
  • Level.Iteration:执行测量(预热)迭代级别
  • Level.Trial:Benchmark级别

示例代码:

csharp 复制代码
 @Setup(Level.Invocation)
 public void setup() {
     System.out.println("setup......");
 }

CompilerControl

JMH提供了可以控制是否使用内联的注解 @CompilerControl ,它的参数有如下可选:

  • CompilerControl.Mode.DONT_INLINE:不使用内联
  • CompilerControl.Mode.INLINE:强制使用内联
  • CompilerControl.Mode.EXCLUDE:不编译

示例代码:

less 复制代码
 ​
 public void target_blank() {
     // this method was intentionally left blank
 }
 ​
 @CompilerControl(CompilerControl.Mode.DONT_INLINE)
 public void target_dontInline() {
     // this method was intentionally left blank
 }
 ​
 @CompilerControl(CompilerControl.Mode.INLINE)
 public void target_inline() {
     // this method was intentionally left blank
 }
 ​
 @CompilerControl(CompilerControl.Mode.EXCLUDE)
 public void target_exclude() {
     // this method was intentionally left blank
 }
 ​
 @Benchmark
 public void baseline() {
     // this method was intentionally left blank
 }
 ​
 @Benchmark
 public void blank() {
     target_blank();
 }
 ​
 @Benchmark
 public void dontinline() {
     target_dontInline();
 }
 ​
 @Benchmark
 public void inline() {
     target_inline();
 }
 ​
 @Benchmark
 public void exclude() {
     target_exclude();
 }

运行结果:

bash 复制代码
 Benchmark                                Mode  Cnt  Score   Error  Units
 JMHSample_16_CompilerControl.baseline    avgt    3  0.435 ± 1.228  ns/op
 JMHSample_16_CompilerControl.blank       avgt    3  0.426 ± 1.342  ns/op
 JMHSample_16_CompilerControl.dontinline  avgt    3  0.426 ± 1.339  ns/op
 JMHSample_16_CompilerControl.exclude     avgt    3  0.388 ± 0.108  ns/op
 JMHSample_16_CompilerControl.inline      avgt    3  0.425 ± 1.351  ns/op

从执行结果可以看到不编译执行最慢,其他方法不明显。

syncIterations

在我们使用JMH的时候, 我们可能会使用多线程对一个Benchmark进行测试,然后分析在多线程环境下Benchmark的执行速度。那么这时就会有一个问题, 线程的创建是有先后顺序的。如果我们一个测试用例, 要创建很多线程, 然后再执行benchmark的方法,就会把很多时间浪费在了初始化线程上。

JMH提供了一个参数.syncIterations(true)用来控制线程的初始化。

  • true(默认):等待所有线程都初始化好了, 再一起执行benchmark。
  • false:这些线程, 谁先初始化谁就先执行。
scss 复制代码
 public static void main(String[] args) throws RunnerException {
     Options opt = new OptionsBuilder()
         .include(JMHSample_17_SyncIterations.class.getSimpleName())
         .threads(Runtime.getRuntime().availableProcessors()*16)
         .forks(1)
         .syncIterations(true) // try to switch to "false"
         .build();
 ​
     new Runner(opt).run();
 }

Control

在多线程场景下,容易发生活锁,有效使用Control对象的.stopMeasurement方法,可以有效规避。cnt.stopMeasurement是JMH框架在测量过程中判断是否停止测量的标志。

less 复制代码
 @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
     }
 }
 public static void main(String[] args) throws RunnerException {
     Options opt = new OptionsBuilder()
         .include(JMHSample_18_Control.class.getSimpleName())
         .threads(2)
         .forks(1)
         .build();
 ​
     new Runner(opt).run();
 }

Warmup和Measurement

可以通过注解很方便的添加执行的参数

less 复制代码
 @Benchmark
 @Warmup(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)
 @Measurement(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)
 public double measure() {
     return Math.log(x1);
 }

AuxCounters

@AuxCounters是一个辅助计数器,可以统计 @State 修饰的对象中的 public 属性被执行的情况,它的参数有两个:

  • AuxCounters.Type.EVENTS: 统计发生的次数。
  • AuxCounters.Type.OPERATIONS:按我们指定的格式统计,如按吞吐量统计。
java 复制代码
 @State(Scope.Thread)
 @AuxCounters(AuxCounters.Type.OPERATIONS)
 public static class OpCounters {
     // These fields would be counted as metrics
     public int case1;
     public int case2;
 ​
     // This accessor will also produce a metric
     public int total() {
         return case1 + case2;
     }
 }
 ​
 @State(Scope.Thread)
 @AuxCounters(AuxCounters.Type.EVENTS)
 public static class EventCounters {
     // This field would be counted as metric
     public int wows;
 }
 ​
 @Benchmark
 public void splitBranch(OpCounters counters) {
     if (Math.random() < 0.1) {
         counters.case1++;
     } else {
         counters.case2++;
     }
 }
 ​
 @Benchmark
 public void runSETI(EventCounters counters) {
     float random = (float) Math.random();
     float wowSignal = (float) Math.PI / 4;
     if (random == wowSignal) {
         // WOW, that's unusual.
         counters.wows++;
     }
 }

运行结果:

bash 复制代码
Benchmark                                    Mode  Cnt         Score         Error  Units
JMHSample_23_AuxCounters.runSETI            thrpt    3  55869036.957 ± 6513518.130  ops/s
JMHSample_23_AuxCounters.runSETI:wows       thrpt    3         6.000                    #
JMHSample_23_AuxCounters.splitBranch        thrpt    3  54936208.691 ± 3440716.525  ops/s
JMHSample_23_AuxCounters.splitBranch:case1  thrpt    3   5490316.306 ±  388208.981  ops/s
JMHSample_23_AuxCounters.splitBranch:case2  thrpt    3  49445892.385 ± 3053612.103  ops/s
JMHSample_23_AuxCounters.splitBranch:total  thrpt    3  54936208.691 ± 3440716.525  ops/s

Benchmark继承

JMH在父类中定义了Benchmark,子类也会继承并合成自己的Benchmark,这是JMH在编译期完成的。

java 复制代码
 @BenchmarkMode(Mode.AverageTime)
 @Fork(1)
 @State(Scope.Thread)
 @OutputTimeUnit(TimeUnit.NANOSECONDS)
 public static abstract class AbstractBenchmark {
     int x;
 ​
     @Setup
     public void setup() {
         x = 42;
     }
 ​
     @Benchmark
     @Warmup(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)
     @Measurement(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)
     public double bench() {
         return doWork() * doWork();
     }
 ​
     protected abstract double doWork();
 }
 ​
 public static class BenchmarkLog extends AbstractBenchmark {
     @Override
     protected double doWork() {
         return Math.log(x);
     }
 }

Params

@Param 允许使用一份基准测试代码运行多组数据。

kotlin 复制代码
 @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);
 }

BulkWarmup

三种预热方式:

  • WarmupMode.INDI:每个Benchmark单独预热
  • WarmupMode.BULK:在每个Benchmark执行前都预热所有的Benchmark
  • WarmupMode.BULK_INDI:在每个Benchmark执行前都预热所有的Benchmark,且需要再预热本次执行的Benchmark

Profilers

JMH内置了一些性能剖析工具,可以帮助我们查看基准测试消耗在什么地方,具体的剖析方式内置的有如下几种:

  • ClassloaderProfiler:类加载剖析
  • CompilerProfiler:JIT编译剖析
  • GCProfiler:GC剖析
  • StackProfiler:栈剖析
  • PausesProfiler:停顿剖析
  • HotspotThreadProfiler:Hotspot线程剖析
  • HotspotRuntimeProfiler:Hotspot运行时剖析
  • HotspotMemoryProfiler:Hotspot内存剖析
  • HotspotCompilationProfiler:Hotspot编译剖析
  • HotspotClassloadingProfiler:Hotspot 类加载剖析
scss 复制代码
 public static void main(String[] args) throws RunnerException {
     Options opt = new OptionsBuilder()
         .include(Maps.class.getSimpleName())
         .addProfiler(StackProfiler.class)
         //                    .addProfiler(GCProfiler.class)
         .build();
     new Runner(opt).run();
 }

陷阱

死码消除(DCE)和Blackhole

编译器原理中,死码消除 (Dead code elimination)是一种编译优化技术,它的用途是移除对程序执行结果没有任何影响的代码。移除这类的代码有两种优点,不但可以减少程序的大小,还可以避免程序在执行中进行不相关的运算行为,减少它执行的时间。不会被执行到的代码(unreachable code )以及只会影响到无关程序执行结果的变量(Dead Variables ),都是死码(Dead code)的范畴。

编译器自动消除死码,很有可能会对我们的测试产生影响,比如下面的例子。

typescript 复制代码
 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 baseline() {
     // 空方法
 }
 ​
 @Benchmark
 public void measureWrong() {
     // 错误的例子,会被JIT优化成空方法
     compute(x);
 }
 ​
 @Benchmark
 public double measureRight() {
     // 正确的例子,把结果返回,让JVM认为计算不能省略
     return compute(x);
 }

在这种情况下,JMH向我们提供了一个类Blackhole。我们将计算出的变量传递给Blackhole中的consume方法,consume方法可以防止即时编译器将所传入的值给优化掉。

typescript 复制代码
 private double compute() {
     //your code
 }
 @Benchmark
 public void measure(Blackhole bh) {
     bh.consume(compute());
 }

需要注意的是,它并不会阻止对传入值的计算的优化 。在下面这段代码中,将3+4的值传入Blackhole.consume方法中。即时编译器仍旧会进行常量折叠,而Blackhole将阻止即时编译器把所得到的常量值 7 给优化消除掉。

typescript 复制代码
@Benchmark
public void testMethod(Blackhole bh) {
	bh.consume(3+4);
}

除了防止死代码消除的consume之外,Blackhole类还提供了一个静态方法consumeCPU,来消耗 CPU 时间。该方法将接收一个 long 类型的参数,这个参数与所消耗的 CPU 时间呈线性相关。

typescript 复制代码
@Benchmark
public void consume() {
    Blackhole.consumeCPU(1000);
}

使用fork隔离多个测试方法

在同一个 JVM 中运行的测评代码有时候会互相影响,所以我们需要 fork 出一个新的 Java 虚拟机,来运行性能基准测试。

  • 获得一个相对干净的虚拟机环境
  • 避免 JVM 优化带来的不确定性
less 复制代码
@Benchmark
@Fork(n)
public int measure() {
    //your code
}

我们来看一个很有意思的示例:

java 复制代码
 public class JMHSample_13_RunToRun {
 ​
     @State(Scope.Thread)
     public static class SleepyState {
         public long sleepTime;
 ​
         @Setup
         public void setup() {
             sleepTime = (long) (Math.random() * 1000);
         }
     }
 ​
     @Benchmark
     @Fork(1)
     public void baseline(SleepyState s) throws InterruptedException {
         TimeUnit.MILLISECONDS.sleep(s.sleepTime);
     }
 ​
     public static void main(String[] args) throws RunnerException {
         Options opt = new OptionsBuilder()
                 .include(JMHSample_13_RunToRun.class.getSimpleName())
                 .threads(1)
                 .build();
 ​
         new Runner(opt).run();
     }
 }

可以看到,我们使用@State(Scope.Thread)标记了一个类SleepyState,在这个类内部的setup方法中,我们得到了一个随机数sleepTime(1-1000中随机),然后,在每个 benchmark 中,我们所做的事情就是睡眠sleepTime这么长时间 。因为我们在benchmark函数中,只做了睡眠这一件事情,所以得分s/ops应该与sleepTime相差无几。

不难想到,如果我们运行很多次测试(1-1000内随机),那么平均值应该接近 500,运行次数越多,距离500越近。

但是,直接运行这段代码,你会发现,sleepTime永远是一个固定的数值。原因在于,我们只有一个JVM,JVM中只有一个线程,而sleepTime在线程创建之后就被确定了(根据Scope.Thread)。

有两种解决方法:

  • 修改fork()大小,即派生出的新的JVM实例数量
  • 修改.threads()大小,即单个JVM中用于执行benchmark的线程数。

修改fork(50),或者修改.threads(50),得到 Score 为:

bash 复制代码
Benchmark                       Mode  Cnt    Score     Error  Units
JMHSample_13_RunToRun.baseline  avgt   50  514.249 ± 138.246  ms/op
JMHSample_13_RunToRun.baseline  avgt       484.893          ms/op

常量折叠与常量传播

常量折叠Constant folding )以及常量传播constant propagation)都是编译器优化技术,他们被使用在现代的编译器中。高级的常量传播形式,或称之为稀疏有条件的常量传播,可以更精确地传播常量及无缝的移除无用的代码。

常量折叠 是一个在编译时期简化常量的一个过程,常量在表示式中仅仅代表一个简单的数值,就像是整数 2,若是一个变量从未被修改也可作为常量,或者直接将一个变量被明确地被标注为常量,例如下面的描述:

ini 复制代码
i = 320 * 200 * 32;

多数的现代编译器不会真的产生两个乘法的指令再将结果存储下来,取而代之的,他们会识别出语句的结构,并在编译时期将数值计算出来(在这个例子,结果为2,048,000)。

常量传播是一个替代表示式中已知常量的过程,也是在编译时期进行,包含前述所定义,内置函数也适用于常量,以下列描述为例:

ini 复制代码
int x = 14;
int y = 7 - x / 2;
return y * (28 / x + 2);

传播x变量将会变成:

ini 复制代码
int x = 14;
int y = 7 - 14 / 2;
return y * (28 / 14 + 2);

持续传播,则会变成:(还可以再进一步的消除无用代码x及y来进行优化)

ini 复制代码
int x = 14;
int y = 0;
return 0;

常量折叠与常量传播很可能对我们的真实意图产生影响,所以我们要避免在Benchmark中存在这类行为。下面是一个例子:

java 复制代码
 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 baseline() {
     return Math.PI;
 }
 ​
 @Benchmark
 public double measureWrong_1() {
     //结果是已知的,可以提前计算,所以计算过程被折叠了
     return compute(Math.PI);
 }
 ​
 @Benchmark
 public double measureWrong_2() {
     //wrongX是不可变的,所以结果同样已知
     return compute(wrongX);
 }
 ​
 @Benchmark
 public double measureRight() {
     //这样是正确的。
     return compute(x);
 }

运行后,会发现measureWrong_1measureWrong_2measureRight得分相同,这也再次印证了该过程存在常量折叠。

bash 复制代码
 Benchmark                                 Mode  Cnt   Score   Error  Units
 JMHSample_10_ConstantFold.baseline        avgt    3   2.221 ± 5.661  ns/op
 JMHSample_10_ConstantFold.measureRight    avgt    3  13.597 ± 1.710  ns/op
 JMHSample_10_ConstantFold.measureWrong_1  avgt    3   2.202 ± 5.143  ns/op
 JMHSample_10_ConstantFold.measureWrong_2  avgt    3   2.191 ± 5.428  ns/op

永远不要在测试中写循环

测试一个耗时较短的方法,我们经常会这样写,通过循环放大,再求均值。

ini 复制代码
public class BadMicrobenchmark {
    public static void main(String[] args) {
        long startTime = System.nanoTime();
        for (int i = 0; i < 10_000_000; i++) {
            reps();
        }
        long endTime = System.nanoTime();
        System.out.println("score :" + (endTime - startTime));
    }
}

但是这样是不可取的,因为循环被执行时存在很多复杂的优化。

(1)循环展开(Loop unwinding),是一种牺牲程序的大小来加快程序执行速度的优化方法。可以由程序员完成,也可由编译器自动优化完成。循环展开最常用来降低循环开销,为具有多个功能单元的处理器提供指令级并行。也有利于指令流水线的调度。

举例:

css 复制代码
 for (i = 1; i <= 60; i++) 
    a[i] = a[i] * b + c;

可以如此循环展开:

css 复制代码
 for (i = 1; i <= 58; i+=3)
 {
   a[i] = a[i] * b + c;
   a[i+1] = a[i+1] * b + c;
   a[i+2] = a[i+2] * b + c;
 }

这被称为展开了两次。

(2)OSR(On-Stack Replacement)对循环的优化

这里不详细展开,读者可以自行搜索。总之,循环代码的结果是不可预测的。

我们来看一个JMH官方的示例:

java 复制代码
 ​
 int x = 1;
 int y = 2;
 ​
 @Benchmark
 public int measureRight() {
     return (x + y);
 }
 ​
 private int reps(int reps) {
     int s = 0;
     for (int i = 0; i < reps; i++) {
         s += (x + y);
     }
     return s;
 }
 ​
 @Benchmark
 //该注解意思是这一个benchmark一共执行了多少次measure,最后会除以次数,得到最终的score
 @OperationsPerInvocation(1) 
 public int measureWrong_1() {
     return reps(1);
 }
 ​
 @Benchmark
 @OperationsPerInvocation(10)
 public int measureWrong_10() {
     return reps(10);
 }
 ​
 @Benchmark
 @OperationsPerInvocation(100)
 public int measureWrong_100() {
     return reps(100);
 }
 ​
 @Benchmark
 @OperationsPerInvocation(1_000)
 public int measureWrong_1000() {
     return reps(1_000);
 }
 ​
 @Benchmark
 @OperationsPerInvocation(10_000)
 public int measureWrong_10000() {
     return reps(10_000);
 }
 ​
 @Benchmark
 @OperationsPerInvocation(100_000)
 public int measureWrong_100000() {
     return reps(100_000);
 }

运行结果如下,可以看到,运行结果并没有什么规律可言。

bash 复制代码
 Benchmark                               Mode  Cnt  Score   Error  Units
 JMHSample_11_Loops.measureRight         avgt    3  2.052 ± 0.238  ns/op
 JMHSample_11_Loops.measureWrong_1       avgt    3  2.058 ± 0.065  ns/op
 JMHSample_11_Loops.measureWrong_10      avgt    3  0.231 ± 0.045  ns/op
 JMHSample_11_Loops.measureWrong_100     avgt    3  0.026 ± 0.011  ns/op
 JMHSample_11_Loops.measureWrong_1000    avgt    3  0.022 ± 0.002  ns/op
 JMHSample_11_Loops.measureWrong_10000   avgt    3  0.018 ± 0.007  ns/op
 JMHSample_11_Loops.measureWrong_100000  avgt    3  0.017 ± 0.002  ns/op

但是,在一些场景下,比如遍历数据集合中的所有元素,我们很难不使用循环,因此,应该设计一个安全循环的方式。接下来看一个正确的做法:

java 复制代码
 static final int BASE = 42;
 static int work(int x) {
     return BASE + x;
 }
 ​
 @Param({"1", "10", "100", "1000"})
 int size;
 ​
 int[] xs;
 ​
 @Setup
 public void setup() {
     xs = new int[size];
     for (int c = 0; c < size; c++) {
         xs[c] = c;
     }
 }
 ​
 @Benchmark
 public int measureWrong_1() {
     int acc = 0;
     for (int x : xs) {
         acc = work(x);
     }
     return acc;
 }
 ​
 @Benchmark
 public int measureWrong_2() {
     int acc = 0;
     for (int x : xs) {
         acc += work(x);
     }
     return acc;
 }
 @Benchmark
 public void measureRight_1(Blackhole bh) {
     for (int x : xs) {
         bh.consume(work(x));
     }
 }
 ​
 @Benchmark
 public void measureRight_2() {
     for (int x : xs) {
         sink(work(x));
     }
 }
 ​
 //告诉编译器不要内联
 @CompilerControl(CompilerControl.Mode.DONT_INLINE)
 public static void sink(int v) {
     // 匹配签名以避免自动装箱非常重要
     // The method intentionally does nothing.
 }

伪共享(False Shareing)

在计算机科学中,伪共享False sharing)是一种会导致性能下降的使用模式,它可能出现在具有分布式缓存且维持缓存一致性快取一致性)的系统中。当系统参与者尝试定期访问未被另一方更改的数据,但欲访问的数据与正被更改的另一数据共享同一缓存块时,缓存协议可能会强制第一个参与者重新加载整个缓存块,尽管在逻辑上这么做是不必要的。 缓存系统不知道此块内的活动,所以强制第一个参与者承担资源的真共享访问(true shared access)所需的缓存系统开销。

java 复制代码
 @BenchmarkMode(Mode.Throughput)
 @OutputTimeUnit(TimeUnit.MICROSECONDS)
 @Warmup(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS)
 @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
 @Fork(5)
 public class JMHSample_22_FalseSharing {
 ​
     @State(Scope.Group)
     public static class StateBaseline {
         int readOnly;
         int writeOnly;
     }
 ​
     @Benchmark
     @Group("baseline")
     public int reader(StateBaseline s) {
         return s.readOnly;
     }
 ​
     @Benchmark
     @Group("baseline")
     public void writer(StateBaseline s) {
         s.writeOnly++;
     }
 ​
     /*
      * APPROACH 1: PADDING
      *
      * We can try to alleviate some of the effects with padding.
      * This is not versatile because JVMs can freely rearrange the
      * field order, even of the same type.
      */
 ​
     @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;
     }
 ​
     @Benchmark
     @Group("padded")
     public int reader(StatePadded s) {
         return s.readOnly;
     }
 ​
     @Benchmark
     @Group("padded")
     public void writer(StatePadded s) {
         s.writeOnly++;
     }
 ​
     /*
      * APPROACH 2: CLASS HIERARCHY TRICK
      *
      * We can alleviate false sharing with this convoluted hierarchy trick,
      * using the fact that superclass fields are usually laid out first.
      * In this construction, the protected field will be squashed between
      * paddings.
 ​
      * It is important to use the smallest data type, so that layouter would
      * not generate any gaps that can be taken by later protected subclasses
      * fields. Depending on the actual field layout of classes that bear the
      * protected fields, we might need more padding to account for "lost"
      * padding fields pulled into in their superclass gaps.
      */
 ​
     public static class StateHierarchy_1 {
         int readOnly;
     }
 ​
     public static class StateHierarchy_2 extends StateHierarchy_1 {
         byte p01, p02, p03, p04, p05, p06, p07, p08;
         byte p11, p12, p13, p14, p15, p16, p17, p18;
         byte p21, p22, p23, p24, p25, p26, p27, p28;
         byte p31, p32, p33, p34, p35, p36, p37, p38;
         byte p41, p42, p43, p44, p45, p46, p47, p48;
         byte p51, p52, p53, p54, p55, p56, p57, p58;
         byte p61, p62, p63, p64, p65, p66, p67, p68;
         byte p71, p72, p73, p74, p75, p76, p77, p78;
     }
 ​
     public static class StateHierarchy_3 extends StateHierarchy_2 {
         int writeOnly;
     }
 ​
     public static class StateHierarchy_4 extends StateHierarchy_3 {
         byte q01, q02, q03, q04, q05, q06, q07, q08;
         byte q11, q12, q13, q14, q15, q16, q17, q18;
         byte q21, q22, q23, q24, q25, q26, q27, q28;
         byte q31, q32, q33, q34, q35, q36, q37, q38;
         byte q41, q42, q43, q44, q45, q46, q47, q48;
         byte q51, q52, q53, q54, q55, q56, q57, q58;
         byte q61, q62, q63, q64, q65, q66, q67, q68;
         byte q71, q72, q73, q74, q75, q76, q77, q78;
     }
 ​
     @State(Scope.Group)
     public static class StateHierarchy extends StateHierarchy_4 {
     }
 ​
     @Benchmark
     @Group("hierarchy")
     public int reader(StateHierarchy s) {
         return s.readOnly;
     }
 ​
     @Benchmark
     @Group("hierarchy")
     public void writer(StateHierarchy s) {
         s.writeOnly++;
     }
 ​
     /*
      * APPROACH 3: ARRAY TRICK
      *
      * This trick relies on the contiguous allocation of an array.
      * Instead of placing the fields in the class, we mangle them
      * into the array at very sparse offsets.
      */
 ​
     @State(Scope.Group)
     public static class StateArray {
         int[] arr = new int[128];
     }
 ​
     @Benchmark
     @Group("sparse")
     public int reader(StateArray s) {
         return s.arr[0];
     }
 ​
     @Benchmark
     @Group("sparse")
     public void writer(StateArray s) {
         s.arr[64]++;
     }
 ​
     /*
      * APPROACH 4:
      *
      * @Contended (since JDK 8):
      *  Uncomment the annotation if building with JDK 8.
      *  Remember to flip -XX:-RestrictContended to enable.
      */
 ​
     @State(Scope.Group)
     public static class StateContended {
         int readOnly;
 ​
 //        @sun.misc.Contended
         int writeOnly;
     }
 ​
     @Benchmark
     @Group("contended")
     public int reader(StateContended s) {
         return s.readOnly;
     }
 ​
     @Benchmark
     @Group("contended")
     public void writer(StateContended s) {
         s.writeOnly++;
     }
 ​
     public static void main(String[] args) throws RunnerException {
         Options opt = new OptionsBuilder()
                 .include(JMHSample_22_FalseSharing.class.getSimpleName())
                 .threads(Runtime.getRuntime().availableProcessors())
                 .build();
 ​
         new Runner(opt).run();
     }
 }

运行结果如下:

bash 复制代码
 Benchmark                                    Mode  Cnt     Score     Error   Units
 JMHSample_22_FalseSharing.baseline          thrpt   25  4308.413 ± 244.357  ops/us
 JMHSample_22_FalseSharing.baseline:reader   thrpt   25   553.168 ±  49.802  ops/us
 JMHSample_22_FalseSharing.baseline:writer   thrpt   25  3755.246 ± 259.901  ops/us
 JMHSample_22_FalseSharing.contended         thrpt   25  3803.586 ± 288.437  ops/us
 JMHSample_22_FalseSharing.contended:reader  thrpt   25   610.349 ±  44.564  ops/us
 JMHSample_22_FalseSharing.contended:writer  thrpt   25  3193.237 ± 267.186  ops/us
 JMHSample_22_FalseSharing.hierarchy         thrpt   25  4408.923 ± 242.622  ops/us
 JMHSample_22_FalseSharing.hierarchy:reader  thrpt   25  1109.683 ±  63.995  ops/us
 JMHSample_22_FalseSharing.hierarchy:writer  thrpt   25  3299.239 ± 188.135  ops/us
 JMHSample_22_FalseSharing.padded            thrpt   25  4344.750 ± 311.144  ops/us
 JMHSample_22_FalseSharing.padded:reader     thrpt   25  1101.085 ±  83.329  ops/us
 JMHSample_22_FalseSharing.padded:writer     thrpt   25  3243.665 ± 233.775  ops/us
 JMHSample_22_FalseSharing.sparse            thrpt   25  3844.397 ± 103.099  ops/us
 JMHSample_22_FalseSharing.sparse:reader     thrpt   25  1013.405 ±  63.424  ops/us
 JMHSample_22_FalseSharing.sparse:writer     thrpt   25  2830.991 ±  93.341  ops/us
 ​

可以看出来,差距还是很明显的。

分支预测

条件分支指令通常具有两路后续执行分支。即不采取(not taken)跳转,顺序执行后面紧挨JMP的指令;以及采取(taken)跳转到另一块程序内存去执行那里的指令。

是否条件跳转,只有在该分支指令在指令流水线中通过了执行阶段(execution stage)才能确定下来。

如果没有分支预测器,处理器将会等待分支指令通过了指令流水线的执行阶段,才把下一条指令送入流水线的第一个阶段---取指令阶段(fetch stage)。这种技术叫做流水线停顿(pipeline stalled)或者流水线冒泡(pipeline bubbling)或者分支延迟间隙。这是早期的RISC体系结构处理器采用的应对分支指令的流水线执行的办法。

分支预测器猜测条件表达式两路分支中哪一路最可能发生,然后推测执行这一路的指令,来避免流水线停顿造成的时间浪费。如果后来发现分支预测错误,那么流水线中推测执行的那些中间结果全部放弃,重新获取正确的分支路线上的指令开始执行,这招致了程序执行的延迟。

简而言之,CPU在处理有规律的数据比没有规律的数据要快,CPU可以"预测"这种规律。我们在基准测试时需要注意样本数据的规律性对结果也会产生影响。

java 复制代码
 private static final int COUNT = 1024 * 1024;
 ​
 private byte[] sorted;
 private byte[] unsorted;
 ​
 @Setup
 public void setup() {
     sorted = new byte[COUNT];
     unsorted = new byte[COUNT];
     Random random = new Random(1234);
     random.nextBytes(sorted);
     random.nextBytes(unsorted);
     Arrays.sort(sorted);
 }
 ​
 @Benchmark
 @OperationsPerInvocation(COUNT)
 public void sorted(Blackhole bh1, Blackhole bh2) {
     for (byte v : sorted) {
         if (v > 0) {
             bh1.consume(v);
         } else {
             bh2.consume(v);
         }
     }
 }
 ​
 @Benchmark
 @OperationsPerInvocation(COUNT)
 public void unsorted(Blackhole bh1, Blackhole bh2) {
     for (byte v : unsorted) {
         if (v > 0) {
             bh1.consume(v);
         } else {
             bh2.consume(v);
         }
     }
 }
 ​

运行结果如下,差距挺明显的。

bash 复制代码
 Benchmark                               Mode  Cnt  Score   Error  Units
 JMHSample_36_BranchPrediction.sorted    avgt   25  2.249 ± 0.023  ns/op
 JMHSample_36_BranchPrediction.unsorted  avgt   25  6.201 ± 0.121  ns/op

顺序访问与非顺序访问

因为CPU存在缓存行的缘故,对内存的顺序访问与非顺序访问也会对测试结果产生影响。

ini 复制代码
 private final static int COUNT = 4096;
 private final static int MATRIX_SIZE = COUNT * COUNT;
 ​
 private int[][] matrix;
 ​
 @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();
         }
     }
 }
 ​
 @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]);
         }
     }
 }

运行结果同样差距挺明显的(大约6倍)。

bash 复制代码
 Benchmark                          Mode  Cnt   Score   Error  Units
 JMHSample_37_CacheAccess.colFirst  avgt   25  12.284 ± 0.149  ns/op
 JMHSample_37_CacheAccess.rowFirst  avgt   25   2.654 ± 0.024  ns/op

内存布局的影响

ini 复制代码
 public static final int N = 20_000_000;
 ​
 private int[] intArray = new int[N];
 private List<Integer> intList = new ArrayList<>(N);
 private List<Integer> shuffledIntList = new ArrayList<>(N);
 ​
 @Setup
 public void setup() {
     Random random = new Random(1234);
     for (int i = 0; i < N; i++) {
         intArray[i] = random.nextInt();
         intList.add(intArray[i]);
         shuffledIntList.add(intArray[i]);
     }
     Collections.shuffle(shuffledIntList);
 }
 ​
 @Benchmark
 public long sumArray() {
     long sum = 0;
     for (int i = 0; i < N; i++) {
         sum += intArray[i];
     }
     return sum;
 }
 ​
 @Benchmark
 public long sumArrayList() {
     long sum = 0;
     for (int i = 0; i < N; i++) {
         sum += intList.get(i);
     }
     return sum;
 }
 ​
 @Benchmark
 public long sumArrayListShuffled() {
     long sum = 0;
     for (int i = 0; i < N; i++) {
         sum += shuffledIntList.get(i);
     }
     return sum;
 }

运行结果如下:

bash 复制代码
 Benchmark                                       Mode  Cnt    Score    Error  Units
 JMHSample_39_MemoryAccess.sumArray              avgt   25    6.183 ±  0.193  ms/op
 JMHSample_39_MemoryAccess.sumArrayList          avgt   25   40.094 ±  0.612  ms/op
 JMHSample_39_MemoryAccess.sumArrayListShuffled  avgt   25  290.487 ± 11.585  ms/op

参考

  1. github.com/openjdk/jmh
  2. www.cnkirito.moe/java-jmh/
  3. hezhiqiang8909.gitbook.io/java/docs/j...
相关推荐
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
憨子周1 小时前
2M的带宽怎么怎么设置tcp滑动窗口以及连接池
java·网络·网络协议·tcp/ip
霖雨2 小时前
使用Visual Studio Code 快速新建Net项目
java·ide·windows·vscode·编辑器
SRY122404192 小时前
javaSE面试题
java·开发语言·面试
Fiercezm3 小时前
JUC学习
java
无尽的大道3 小时前
Java 泛型详解:参数化类型的强大之处
java·开发语言
ZIM学编程3 小时前
Java基础Day-Sixteen
java·开发语言·windows
我不是星海3 小时前
1.集合体系补充(1)
java·数据结构
P.H. Infinity3 小时前
【RabbitMQ】07-业务幂等处理
java·rabbitmq·java-rabbitmq
爱吃土豆的程序员3 小时前
java XMLStreamConstants.CDATA 无法识别 <![CDATA[]]>
xml·java·cdata