前言

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

快速上手

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

1
2
3
4
5
6
7
8
9
10
<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>

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

1
2
3
4
5
6
7
$ mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DgroupId=org.sample \
-DartifactId=test \
-Dversion=1.0

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 文件,通过命令行的方式执行。

1
2
3
$ cd test/
$ mvn clean verify
$ java -jar target/benchmarks.jar

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

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

1
2
3
4
5
6
7
8
9
10
11
# 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

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

1
2
3
4
5
6
7
8
9
10
11
12
# 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

最终统计信息如下:

1
2
3
4
5
6
7
8
9
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

1
2
3
4
5
6
7
//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范围

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
//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=4的情况

threads=1的情况

image-20230808132525945

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
* 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)设置作用域为组,可以模拟多线程情景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@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 作用域是这个线程组。运行结果也是分组展示:

img

Setup和TearDown

1
2
3
4
5
6
7
8
9
10
11
12
@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级别

示例代码:

1
2
3
4
@Setup(Level.Invocation)
public void setup() {
System.out.println("setup......");
}

CompilerControl

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

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

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

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();
}

运行结果:

1
2
3
4
5
6
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:这些线程, 谁先初始化谁就先执行。
1
2
3
4
5
6
7
8
9
10
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框架在测量过程中判断是否停止测量的标志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@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

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

1
2
3
4
5
6
@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:按我们指定的格式统计,如按吞吐量统计。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@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++;
}
}

运行结果:

1
2
3
4
5
6
7
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在编译期完成的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@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 允许使用一份基准测试代码运行多组数据。

1
2
3
4
5
6
7
8
9
10
@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 类加载剖析
1
2
3
4
5
6
7
8
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)的范畴。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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方法可以防止即时编译器将所传入的值给优化掉。

1
2
3
4
5
6
7
private double compute() {
//your code
}
@Benchmark
public void measure(Blackhole bh) {
bh.consume(compute());
}

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

1
2
3
4
@Benchmark
public void testMethod(Blackhole bh) {
bh.consume(3+4);
}

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

1
2
3
4
@Benchmark
public void consume() {
Blackhole.consumeCPU(1000);
}

使用fork隔离多个测试方法

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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 为:

1
2
3
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,若是一个变量从未被修改也可作为常量,或者直接将一个变量被明确地被标注为常量,例如下面的描述:

1
i = 320 * 200 * 32;

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

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

1
2
3
int x = 14;
int y = 7 - x / 2;
return y * (28 / x + 2);

传播x变量将会变成:

1
2
3
int x = 14;
int y = 7 - 14 / 2;
return y * (28 / 14 + 2);

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

1
2
3
int x = 14;
int y = 0;
return 0;

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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得分相同,这也再次印证了该过程存在常量折叠。

1
2
3
4
5
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

永远不要在测试中写循环

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

1
2
3
4
5
6
7
8
9
10
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),是一种牺牲程序的大小来加快程序执行速度的优化方法。可以由程序员完成,也可由编译器自动优化完成。循环展开最常用来降低循环开销,为具有多个功能单元的处理器提供指令级并行。也有利于指令流水线的调度。

举例:

1
2
for (i = 1; i <= 60; i++) 
a[i] = a[i] * b + c;

可以如此循环展开:

1
2
3
4
5
6
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官方的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

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);
}

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

1
2
3
4
5
6
7
8
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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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)所需的缓存系统开销。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
@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();
}
}

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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可以“预测”这种规律。我们在基准测试时需要注意样本数据的规律性对结果也会产生影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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);
}
}
}

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

1
2
3
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存在缓存行的缘故,对内存的顺序访问与非顺序访问也会对测试结果产生影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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倍)。

1
2
3
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

内存布局的影响

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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;
}

运行结果如下:

1
2
3
4
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. https://github.com/openjdk/jmh
  2. https://www.cnkirito.moe/java-jmh/
  3. https://hezhiqiang8909.gitbook.io/java/docs/javalib/jmh