如何做Java微基准测试

官方的Sample,主要是一些测试中遇到的问题和陷阱

http://hg.openjdk.java.net/code-tools/jmh/file/tip/jmh-samples/src/main/java/org/openjdk/jmh/samples/

简介

JMH,即Java Microbenchmark Harness,这是专门用于进行代码的微基准测试的一套工具API。

JMH 由 OpenJDK/Oracle 开发,测量精度可以精确到微秒级。

测试精度

上图给出了不同类型测试的耗时数量级,可以发现 JMH 可以达到 微秒 级别的的精度。

这样几个数量级的测试所面临的挑战也是不同的。

  • 毫秒级别的测试并不是很困难
  • 微秒级别的测试是具备挑战性的,但并非无法完成,JMH 就做到了
  • 纳秒级别的测试,目前还没有办法精准测试
  • 皮秒级别…Holy Shit

图解:

Linpack : Linpack benchmark 一类基础测试,度量系统的浮点计算能力

SPEC:Standard Performance Evaluation Corporation 工业界的测试标准组织

pipelining:系统总线通信的耗时

第一个例子

首先,导入JMH需要的jar包:

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.21</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.21</version>
<scope>provided</scope>
</dependency>

接着,我们来试着写一个例子。就拿最近占小狼发的Calendar和Joda比较的例子来说吧:

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
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(1)
public class DateBenchMark {

@Benchmark
public Calendar runCalendar() {
return Calendar.getInstance();
}

@Benchmark
public DateTime runJoda() {
return new DateTime();
}

@Benchmark
public long runSystem() {
return System.currentTimeMillis();
}

public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(DateBenchMark.class.getSimpleName())
.build();

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

运行结果里确实可以看到Joda的DateTime比Calendar性能要好:

1
2
3
4
Benchmark                  Mode  Cnt    Score     Error  Units
DateBenchMark.runCalendar avgt 3 207.051 ± 358.224 ns/op
DateBenchMark.runJoda avgt 3 58.860 ± 15.683 ns/op
DateBenchMark.runSystem avgt 3 33.148 ± 6.270 ns/op

这个例子里的注解都可以换成方法的方式在main方法中指定,比如可以改成这样:

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
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class DateBenchMark {

@Benchmark
public Calendar runCalendar() {
return Calendar.getInstance();
}

@Benchmark
public DateTime runJoda() {
return new DateTime();
}

@Benchmark
public long runSystem() {
return System.currentTimeMillis();
}

public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(DateBenchMark.class.getSimpleName())
.forks(1)
.measurementIterations(3)
.measurementTime(TimeValue.seconds(1))
.warmupIterations(3)
.warmupTime(TimeValue.seconds(1))
.build();

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

具体的方法可以自行参考 ChainedOptionsBuilder 类,因所有的方法几乎都可以用注解来实现,所以我们下面着重讲解注解的使用。

生成jar包执行

对于一些我们自己的一些小测试,直接用上面的方式写一个main函数手动执行就好了。但是对于大型的测试,需要测试的时间比较久、线程数比较多,加上测试的服务器需要,一般要放在Linux服务器里去执行。JMH官方提供了生成jar包的方式来执行。

首先我们需要在maven里增加一个plugin:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>2.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<finalName>benchmarks</finalName>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jmh.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>

接着执行maven的命令生成可执行jar包并执行。

1
2
$ mvn clean install
$ java -jar target/benchmarks.jar DateBenchMark

jar的执行命令后面可以加上 -h 来提示可选的命令行参数,用来替换main方法中的方法。

IDEA插件

如果你在用Intellij IDEA的话,那么你可以去plugin里搜JMH来安装,github地址:https://github.com/artyushov/idea-jmh-plugin

它的主要功能有两个:

一、帮助你创建@Benchmark方法,可以右键点击 Generate... 来触发,也可以使用快捷键 ctrl+N

jmh1

二、可以让你像Junit一样方便的来进行基准测试,不需要写main方法。点击某个@Benchmark方法名右键run就只会进行光标所在方法的基准测试,而如果光标在类名上,右键run的就是整个类的所有基准测试。

jmh2

jmh3

注解

下面我把一些常用的注解全部分析一遍,看完之后你就可以得心应手的使用了。

@BenchmarkMode

基准测试类型,对应Mode选项,可用于类或者方法上。 需要注意的是,这个注解的value是一个数组,可以把几种Mode集合在一起执行,如:@BenchmarkMode({Mode.SampleTime, Mode.AverageTime})

  • Throughput:整体吞吐量,每秒执行了多少次调用。
  • AverageTime:用的平均时间,每次操作的平均时间。
  • SampleTime:随机取样,最后输出取样结果的分布,例如“99%的调用在xxx毫秒以内,99.99%的调用在xxx毫秒以内”。
  • SingleShotTime:上模式都是默认一次 iteration 是 1s,唯有 SingleShotTime 是只运行一次。往往同时把 warmup 次数设为0,用于测试冷启动时的性能。
  • All:上面的所有模式都执行一次,适用于内部JMH测试。

@Warmup

预热所需要配置的一些基本测试参数。可用于类或者方法上。一般我们前几次进行程序测试的时候都会比较慢,所以要让程序进行几轮预热,保证测试的准确性。为什么需要预热?因为 JVM 的 JIT 机制的存在,如果某个函数被调用多次之后,JVM 会尝试将其编译成为机器码从而提高执行速度。所以为了让 benchmark 的结果更加接近真实情况就需要进行预热。

  • iterations:预热的次数。
  • time:每次预热的时间。
  • timeUnit:时间的单位,默认秒。
  • batchSize:批处理大小,每次操作调用几次方法。

@Measurement

实际调用方法所需要配置的一些基本测试参数。可用于类或者方法上。参数和@Warmup一样。

@Threads

每个进程中的测试线程,可用于类或者方法上。一般选择为cpu乘以2。如果配置了 Threads.MAX ,代表使用 Runtime.getRuntime().availableProcessors() 个线程。

@Fork

进行 fork 的次数。可用于类或者方法上。如果 fork 数是2的话,则 JMH 会 fork 出两个进程来进行测试。

@Benchmark

方法级注解,表示该方法是需要进行 benchmark 的对象,用法和 JUnit 的 @Test 类似。

@Param

@Param 可以用来指定某项参数的多种情况。只能作用在字段上。特别适合用来测试一个函数在不同的参数输入的情况下的性能。使用该注解必须定义 @State 注解。

1
2
@Param(value = {"a", "b", "c"})
private String param;

最后的结果可能是这个样子的:

1
2
3
4
Benchmark                    (param)  Mode  Cnt    Score   Error  Units
FirstBenchMark.stringConcat a ss 330.752 us/op
FirstBenchMark.stringConcat b ss 186.050 us/op
FirstBenchMark.stringConcat c ss 222.559 us/op

@Setup&@TearDown

@Setup主要实现测试前的初始化工作,只能作用在方法上。用法和Junit一样。使用该注解必须定义 @State 注解。

@TearDown主要实现测试完成后的垃圾回收等工作,只能作用在方法上。用法和Junit一样。使用该注解必须定义 @State 注解。

这两个注解都有一个 Level 的枚举value,它有三个值(默认的是Trial):

  • Trial:在每次Benchmark的之前/之后执行。
  • Iteration:在每次Benchmark的iteration的之前/之后执行。
  • Invocation:每次调用Benchmark标记的方法之前/之后都会执行。

可见,Level的粒度从Trial到Invocation越来越细。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@TearDown(Level.Iteration)
public void check() {
assert x > Math.PI : "Nothing changed?";
}

@Benchmark
public void measureRight() {
x++;
}

@Benchmark
public void measureWrong() {
double x = 0;
x++;
}

@State

该注解定义了给定类实例的可用范围。JMH可以在多线程同时运行的环境测试,因此需要选择正确的状态。只能作用在上。被该注解定义的类通常作为 @Benchmark 标记的方法的入参,JMH根据scope来进行实例化和共享操作,当然@State可以被继承使用,如果父类定义了该注解,子类则无需定义。

Scope有如下3种值:

  • Benchmark:同一个benchmark在多个线程之间共享实例。
  • Group:同一个线程在同一个group里共享实例。group定义参考注解 @Group
  • Thread:不同线程之间的实例不共享。

首先说一下Benchmark,对于同一个@Benchmark,所有线程共享实例,也就是只会new Person 1次

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@State(Scope.Benchmark)
public static class BenchmarkState {
Person person = new Person(21, "ben", "benchmark");
volatile double x = Math.PI;
}

@Benchmark
public void measureShared(BenchmarkState state) {
state.x++;
}

public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(JMHSample_03_States.class.getSimpleName())
.threads(8)
.warmupTime(TimeValue.seconds(1))
.measurementTime(TimeValue.seconds(1))
.forks(1)
.build();

new Runner(opt).run();
}

再说一下thread,这个比较好理解,不同线程之间的实例不共享。对于上面我们设定的线程数为8个,也就是会new Person 8次。

1
2
3
4
5
6
7
8
9
10
@State(Scope.Thread)
public static class ThreadState {
Person person = new Person(21, "ben", "thread");
volatile double x = Math.PI;
}

@Benchmark
public void measureUnshared(ThreadState state) {
state.x++;
}

而对于Group来说,同一个group的作为一个执行单元,所以 measureGroupmeasureGroup2 共享8个线程,所以一个方法也就会执行new Person 4次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@State(Scope.Group)
public static class GroupState {
Person person = new Person(21, "ben", "group");
volatile double x = Math.PI;
}

@Benchmark
@Group("ben")
public void measureGroup(GroupState state) {
state.x++;
}

@Benchmark
@Group("ben")
public void measureGroup2(GroupState state) {
state.x++;
}

@Group

结合@Benchmark一起使用,把多个基准方法归为一类,只能作用在方法上。同一个组中的所有测试设置相同的名称(否则这些测试将独立运行——没有任何警告提示!)

@GroupThreads

定义了多少个线程参与在组中运行基准方法。只能作用在方法上。

@OutputTimeUnit

这个比较简单了,基准测试结果的时间类型。可用于类或者方法上。一般选择秒、毫秒、微秒。

@CompilerControl

该注解可以控制方法编译的行为,可用于类或者方法或者构造函数上。它内部有6种模式,这里我们只关心三种重要的模式:

  • CompilerControl.Mode.INLINE:强制使用内联。
  • CompilerControl.Mode.DONT_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
public void target_blank() {
}

@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public void target_dontInline() {
}

@CompilerControl(CompilerControl.Mode.INLINE)
public void target_inline() {
}

@CompilerControl(CompilerControl.Mode.EXCLUDE)
public void target_exclude() {
}

@Benchmark
public void baseline() {
}

@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.338 ± 0.475 ns/op
JMHSample_16_CompilerControl.blank avgt 3 0.343 ± 0.213 ns/op
JMHSample_16_CompilerControl.dontinline avgt 3 2.247 ± 0.421 ns/op
JMHSample_16_CompilerControl.exclude avgt 3 82.814 ± 7.333 ns/op
JMHSample_16_CompilerControl.inline avgt 3 0.322 ± 0.023 ns/op

避免JIT优化

我们在测试的时候,一定要避免JIT优化。对于有一些代码,编译器可以推导出一些计算是多余的,并且完全消除它们。 如果我们的基准测试里有部分代码被清除了,那测试的结果就不准确了。比如下面这一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private double x = Math.PI;

@Benchmark
public void baseline() {
// do nothing, this is a baseline
}

@Benchmark
public void measureWrong() {
// This is wrong: result is not used and the entire computation is optimized away.
Math.log(x);
}

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

由于 measureWrong 方法被编译器优化了,导致效果和 baseline 方法一样变成了空方法,结果也证实了这一点:

1
2
3
4
Benchmark                           Mode  Cnt   Score   Error  Units
JMHSample_08_DeadCode.baseline avgt 5 0.311 ± 0.018 ns/op
JMHSample_08_DeadCode.measureRight avgt 5 23.702 ± 0.320 ns/op
JMHSample_08_DeadCode.measureWrong avgt 5 0.306 ± 0.003 ns/op

如果我们想方法返回值还是void,但是需要让Math.log(x)的耗时加入到基准运算中,我们可以使用JMH提供给我们的类 Blackhole ,使用它的 consume 来避免JIT的优化消除。

1
2
3
4
@Benchmark
public void measureRight_2(Blackhole bh) {
bh.consume(Math.log(x));
}

但是有返回值的方法就不会被优化了吗?你想的太多了。。。重新改改刚才的代码,让字段 x 变成final的。

1
private final double x = Math.PI;

运行后的结果发现 measureRight 被JIT进行了优化,从 23.7ns/op 降到了 2.5ns/op

1
JMHSample_08_DeadCode.measureRight    avgt    5  2.587 ± 0.081  ns/op

当然 Math.log(Math.PI ); 这种返回写法和字段定义成final一样,都会被进行优化。

优化的原因是因为JVM认为每次计算的结果都是相同的,于是就会把相同代码移到了JMH的循环之外。

结论:

  1. 基准测试方法一定不要返回void。
  2. 如果要使用void返回,可以使用 Blackholeconsume 来避免JIT的优化消除。
  3. 计算不要引用常量,否则会被优化到JMH的循环之外。

实战

最后我们来使用JMH测试不同框架的序列化性能,代码地址:https://github.com/benjaminwhx/p_rpc/blob/master/serialize/src/main/java/com/github/BenchmarkTest.java

测试结果图:

serialize1

测试的主要参数如下:

1
2
3
4
5
6
7
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@Fork(3)
@Threads(8)

3个进程,8个线程,每次预热3次,每次5秒。执行5次,每次5秒,最后算出的值为平均时间,单位纳秒。

12 个测试陷阱

陷阱 1:死码消除

死码消除死码消除

measureWrong 方法想要测试 Math.log 的性能,得到的结果和空方法 baseline 一致,而 measureRight 相比 measureWrong 多了一个 return,正确的得到了测试结果。

这是由于 JIT 擅长删除“无效”的代码,这给我们的测试带来了一些意外,当你意识到 DCE 现象后,应当有意识的去消费掉这些孤立的代码,例如 return。JMH 不会自动实施对冗余代码的消除。

死码消除 这个概念很多人其实并不陌生,注释的代码,不可达的代码块,可达但不被使用的代码等等,我这里补充一些 Aleksey 提到的概念,用以阐释为何一般测试方法难以避免引用对象发生死码消除现象:

  1. Fast object combinator.
  2. Need to escape object to limit thread-local optimizations.
  3. Publishing the object ⇒ reference heap write ⇒ store barrier.

很绝望,个人水平有限,我没能 get 到这些点,只能原封不动地贴给大家看了。

JMH 提供了专门的 API — Blockhole 来避免死码消除问题。

1
2
3
4
@Benchmark
public void measureRight(Blackhole bh) {
bh.consume(Math.log(PI));
}

陷阱 2:常量折叠与常量传播

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

1
i = 320 * 200 * 32;

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

有些编译器,常数折叠会在初期就处理完,例如 Java 中的 final 关键字修饰的变量就会被特殊处理。而将常数折叠放在较后期的阶段的编译器,也相当常见。

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
private double x = Math.PI;

// 编译器会对 final 变量特殊处理
private final double wrongX = Math.PI;

@Benchmark
public double baseline() { // 2.220 ± 0.352 ns/op
return Math.PI;
}

@Benchmark
public double measureWrong_1() { // 2.220 ± 0.352 ns/op
// 错误,结果可以被预测,会发生常量折叠
return Math.log(Math.PI);
}

@Benchmark
public double measureWrong_2() { // 2.220 ± 0.352 ns/op
// 错误,结果可以被预测,会发生常量折叠
return Math.log(wrongX);
}

@Benchmark
public double measureRight() { // 22.590 ± 2.636 ns/op
return Math.log(x);
}

经过 JMH 可以验证这一点:只有最后的 measureRight 正确测试出了 Math.log 的性能,measureWrong_1,measureWrong_2 都受到了常量折叠的影响。

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

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

传播可以理解变量的替换,如果进行持续传播,上式会变成:

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

陷阱 3:永远不要在测试中写循环

这个陷阱对我们做日常测试时的影响也是巨大的,所以我直接将他作为了标题:永远不要在测试中写循环!

本节设计不少知识点,循环展开(loop unrolling),JIT & OSR 对循环的优化。对于前者循环展开的定义,建议读者直接查看 wiki 的定义,而对于后者 JIT & OSR 对循环的优化,推荐两篇 R 大的知乎回答:

循环长度的相同、循环体代码相同的两次 for 循环的执行时间相差了 100 倍?

OSR(On-Stack Replacement)是怎样的机制?

对于第一个回答,建议不要看问题,直接看答案;第二个回答,阐释了 OSR 都对循环做了哪些手脚。

测试一个耗时较短的方法,入门级程序员(不了解动态编译的同学)会这样写,通过循环放大,再求均值。

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("ns/op :" + (endTime - startTime));
}
}

实际上,这段代码的结果是不可预测的,太多影响因子会干扰结果。原理暂时不表,通过 JMH 来看看几个测试方法,下面的 Benchmark 尝试对 reps 方法迭代不同的次数,想从中获得 reps 真实的性能。(注意,在 JMH 中使用循环也是不可取的,除非你是 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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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
@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(1000)
public int measureWrong_1000() {
return reps(1000);
}

@Benchmark
@OperationsPerInvocation(10000)
public int measureWrong_10000() {
return reps(10000);
}

@Benchmark
@OperationsPerInvocation(100000)
public int measureWrong_100000() {
return reps(100000);
}

结果如下:

1
2
3
4
5
6
7
8
Benchmark                               Mode  Cnt  Score   Error  Units
JMHSample_11_Loops.measureRight avgt 5 2.343 ± 0.199 ns/op
JMHSample_11_Loops.measureWrong_1 avgt 5 2.358 ± 0.166 ns/op
JMHSample_11_Loops.measureWrong_10 avgt 5 0.326 ± 0.354 ns/op
JMHSample_11_Loops.measureWrong_100 avgt 5 0.032 ± 0.011 ns/op
JMHSample_11_Loops.measureWrong_1000 avgt 5 0.025 ± 0.002 ns/op
JMHSample_11_Loops.measureWrong_10000 avgt 5 0.022 ± 0.005 ns/op
JMHSample_11_Loops.measureWrong_100000 avgt 5 0.019 ± 0.001 ns/op

如果不看事先给出的错误和正确的提示,上述的结果,你会选择相信哪一个?实际上跑分耗时从 2.358 随着迭代次数变大,降为了 0.019。手动测试循环的代码 BadMicrobenchmark 也存在同样的问题,实际上它没有做预热,效果只会比 JMH 测试循环更加不可信。

Aleksey 在视频中给出结论:假设单词迭代的耗时是 𝑀 ns. 在 JIT,OSR,循环展开等因素的多重作用下,多次迭代的耗时理论值为 𝛼𝑀 ns, 其中 𝛼 ∈ [0; +∞)。

正确的测试循环的姿势可以看这里:here

陷阱 4:使用 Fork 隔离多个测试方法

相信我,这个陷阱中涉及到的例子绝对是 JMH sample 中最诡异的,并且我还没有找到科学的解释(说实话视频中这一段我尝试听了好几遍,没听懂,原谅我的听力)

首先定义一个 Counter 接口,并实现了两份代码完全相同的实现类:Counter1,Counter2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public interface Counter {
int inc();
}

public class Counter1 implements Counter {
private int x;

@Override
public int inc() {
return x++;
}
}

public class Counter2 implements Counter {
private int x;

@Override
public int inc() {
return x++;
}
}

接着让他们在 同一个 VM 中按照先手顺序进行评测:

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
public int measure(Counter c) {
int s = 0;
for (int i = 0; i < 10; i++) {
s += c.inc();
}
return s;
}

/*
* These are two counters.
*/
Counter c1 = new Counter1();
Counter c2 = new Counter2();

/*
* We first measure the Counter1 alone...
* Fork(0) helps to run in the same JVM.
*/
@Benchmark
@Fork(0)
public int measure_1_c1() {
return measure(c1);
}

/*
* Then Counter2...
*/
@Benchmark
@Fork(0)
public int measure_2_c2() {
return measure(c1);
}

/*
* Then Counter1 again...
*/
@Benchmark
@Fork(0)
public int measure_3_c1_again() {
return measure(c1);
}

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

@Benchmark
@Fork(1)
public int measure_5_forked_c2() {
return measure(c2);
}

这一个例子中多了一个 Fork 注解,让我来简单介绍下它。Fork 这个关键字顾名思义,是用来将运行环境复制一份的意思,在我们之前的多个测试中,实际上每次测评都是默认使用了 相互隔离的,完全一致 的测评环境,这得益于 JMH。每个试验运行在单独的 JVM 进程中。也可以指定 (额外的) JVM 参数,例如这里为了演示运行在同一个 JVM 中的弊端,特地做了反面的教材:Fork(0)。试想一下 c1,c2,c1 again 的耗时结果会如何?

1
2
3
4
5
6
Benchmark                                 Mode  Cnt   Score   Error  Units
JMHSample_12_Forking.measure_1_c1 avgt 5 2.518 ± 0.622 ns/op
JMHSample_12_Forking.measure_2_c2 avgt 5 14.080 ± 0.283 ns/op
JMHSample_12_Forking.measure_3_c1_again avgt 5 13.462 ± 0.164 ns/op
JMHSample_12_Forking.measure_4_forked_c1 avgt 5 3.861 ± 0.712 ns/op
JMHSample_12_Forking.measure_5_forked_c2 avgt 5 3.574 ± 0.220 ns/op

你会不会感到惊讶,第一次运行的 c1 竟然耗时最低,在我的认知中,JIT 起码会启动预热的作用,无论如何都不可能先运行的方法比之后的方法快这么多!但这个结果也和 Aleksey 视频中介绍的相符。

JMH samples 中的这个示例主要还是想要表达同一个 JVM 中运行的测评代码会互相影响,从结果也可以发现:c1,c2,c1_again 的实现相同,跑分却不同,因为运行在同一个 JVM 中;而 forked_c1 和 forked_c2 则表现出了一致的性能。所以没有特殊原因,Fork 的值一般都需要设置为 >0。

陷阱 5:方法内联

熟悉 C/C++ 的朋友不会对方法内联感到陌生,方法内联就是把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用(减少了操作指令周期)。在 Java 中,无法手动编写内联方法,但 JVM 会自动识别热点方法,并对它们使用方法内联优化。一段代码需要执行多少次才会触发 JIT 优化通常这个值由 -XX:CompileThreshold 参数进行设置:

  • 1、使用 client 编译器时,默认为 1500;
  • 2、使用 server 编译器时,默认为 10000;

但是一个方法就算被 JVM 标注成为热点方法,JVM 仍然不一定会对它做方法内联优化。其中有个比较常见的原因就是这个方法体太大了,分为两种情况。

  • 如果方法是经常执行的,默认情况下,方法大小小于 325 字节的都会进行内联(可以通过 -XX:MaxFreqInlineSize=N 来设置这个大小)
  • 如果方法不是经常执行的,默认情况下,方法大小小于 35 字节才会进行内联(可以通过 -XX:MaxInlineSize=N 来设置这个大小)

我们可以通过增加这个大小,以便更多的方法可以进行内联;但是除非能够显著提升性能,否则不推荐修改这个参数。因为更大的方法体会导致代码内存占用更多,更少的热点方法会被缓存,最终的效果不一定好。

如果想要知道方法被内联的情况,可以使用下面的 JVM 参数来配置

1
2
3
-XX:+PrintCompilation // 在控制台打印编译过程信息
-XX:+UnlockDiagnosticVMOptions // 解锁对 JVM 进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对 JVM 进行诊断
-XX:+PrintInlining // 将内联方法打印出来

方法内联的其他隐含条件

  • 虽然 JIT 号称可以针对代码全局的运行情况而优化,但是 JIT 对一个方法内联之后,还是可能因为方法被继承,导致需要类型检查而没有达到性能的效果
  • 想要对热点的方法使用上内联的优化方法,最好尽量使用 final、private、static 这些修饰符修饰方法,避免方法因为继承,导致需要额外的类型检查,而出现效果不好情况。

方法内联也可能对 Benchmark 产生影响;或者说有时候我们为了优化代码,而故意触发内联,也可以通过 JMH 来和非内联方法进行性能对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
}
Benchmark Mode Cnt Score Error Units
JMHSample_16_CompilerControl.blank avgt 3 0.323 ± 0.544 ns/op
JMHSample_16_CompilerControl.dontinline avgt 3 2.099 ± 7.515 ns/op
JMHSample_16_CompilerControl.inline avgt 3 0.308 ± 0.264 ns/op

可以发现,内联与不内联的性能差距是巨大的,有一些空间换时间的味道,在 JMH 中使用 CompilerControl.Mode 来控制内联是否开启。

陷阱 6:伪共享与缓存行

又遇到了我们的老朋友:CPU Cache 和缓存行填充。这个并发性能杀手,我在之前的文章中专门介绍过,如果你没有看过,可以戳这里:JAVA 拾遗 — CPU Cache 与缓存行。在 Benchmark 中,有时也不能忽视缓存行对测评的影响。

受限于篇幅,在此不展开有关伪共享的陷阱,完整的测评可以戳这里:JMHSample_22_FalseSharing

JMH 为解决伪共享问题,提供了 @State 注解,但并不能在单一对象内部对个别的字段增加,如果有必要,可以使用并发包中的 @Contended 注解来处理。

Aleksey 曾为 Java 并发包提供过优化,其中就包括 @Contended 注解。

陷阱 7:分支预测

分支预测(Branch Prediction)是这篇文章中介绍的最后一个 Benchmark 中的“捣蛋鬼”。还是从一个具体的 Benchmark 中观察结果。下面的代码尝试遍历了两个长度相等的数组,一个有序,一个无序,并在迭代时加入了一个判断语句,这是分支预测的关键:if(v > 0)

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
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);
}
}
}
Benchmark Mode Cnt Score Error Units
JMHSample_36_BranchPrediction.sorted avgt 25 2.752 ± 0.154 ns/op
JMHSample_36_BranchPrediction.unsorted avgt 25 8.175 ± 0.883 ns/op

从结果看,有序数组的遍历比无序数组的遍历快了 2-3 倍。关于这点的介绍,最佳的解释来自于 Stack Overflow 一个 2w 多赞的答案:Why is it faster to process a sorted array than an unsorted array?

分叉路口分叉路口

假设我们是在 19 世纪,而你负责为火车选择一个方向,那时连电话和手机还没有普及,当火车开来时,你不知道火车往哪个方向开。于是你的做法(算法)是:叫停火车,此时火车停下来,你去问司机,然后你确定了火车往哪个方向开,并把铁轨扳到了对应的轨道。

还有一个需要注意的地方是,火车的惯性是非常大的,所以司机必须在很远的地方就开始减速。当你把铁轨扳正确方向后,火车从启动到加速又要经过很长的时间。

那么是否有更好的方式可以减少火车的等待时间呢?

有一个非常简单的方式,你提前把轨道扳到某一个方向。那么到底要扳到哪个方向呢,你使用的手段是——“瞎蒙”:

  • 如果蒙对了,火车直接通过,耗时为 0。
  • 如果蒙错了,火车停止,然后倒回去,你将铁轨扳至反方向,火车重新启动,加速,行驶。

如果你很幸运,每次都蒙对了,火车将从不停车,一直前行!如果不幸你蒙错了,那么将浪费很长的时间。

虽然不严谨,但你可以用同样的道理去揣测 CPU 的分支预测,有序数组使得这样的预测大部分情况下是正确的,所以带有判断条件时,有序数组的遍历要比无序数组要快。

这同时也启发我们:在大规模循环逻辑中要尽量避免大量判断(是不是可以抽取到循环外呢?)。

陷阱 8:多线程测试

多线程测试多线程测试

在 4 核的系统之上运行一个测试方法,得到如上的测试结果, Ops/nsec 代表了单位时间内的运行次数,Scale 代表 2,4 线程相比 1 线程的运行次数倍率。

这个图可供我们提出两个问题:

  1. 为什么 2 线程 -> 4 线程几乎没有变化?

  2. 为什么 2 线程相比 1 线程只有 1.87 倍的变化,而不是 2 倍?

    1 电源管理

降频降频

第一个影响因素便是多线程测试会受到操作系统电源管理(Power Management)的影响,许多系统存在能耗和性能的优化管理。 (Ex: cpufreq, SpeedStep, Cool&Quiet, TurboBoost)

当我们主动对机器进行降频之后,整体性能发生下降,但是 Scale 在线程数 1 -> 2 的过程中变成了严谨的 2 倍。

这样的问题并非无法规避,补救方法便是禁用电源管理, 保证 CPU 的时钟频率 。

JMH 通过长时间运行,保证线程不出现 park(time waiting) 状态,来保证测试的精准性。

2 操作系统调度和分时调用模型

造成多线程测试陷阱的第二个问题,需要从线程调度模型出发来理解:分时调度模型和抢占式调度模型。

分时调度模型是指让所有的线程轮流获得 CPU 的使用权, 并且平均分配每个线程占用的 CPU 的时间片,这个也比较好理解;抢占式调度模型,是指优先让可运行池中优先级高的线程占用 CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用 CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU。一个线程会因为以下原因而放弃 CPU。

需要注意的是,线程的调度不是跨平台的,它不仅仅取决于 Java 虚拟机,还依赖于操作系统。在某些操作系统中,只要运行中的线程没有遇到阻塞,就不会放弃 CPU;在某些操作系统中,即使线程没有遇到阻塞,也会运行一段时间后放弃 CPU,给其它线程运行的机会。

无论是那种模型,线程上下文的切换都会造成损耗。到这儿为止,还是只回答了第一个问题:为什么 2 线程相比 1 线程只有 1.87 倍的变化,而不是 2 倍?

由于上述的两个图我都是从 Aleksey 的视频中抠出来的,并不清楚他的实际测试用例,对于 2 -> 4 线程性能差距并不大只能理解为系统过载,按道理说 4 核的机器,运行 4 个线程应该不至于只比 2 个线程快这么一点。

对于线程分时调用以及线程调度带来的不稳定性,JMH 引入了 bogus iterations 的概念,它保障了在多线程测试过程中,只在线程处于忙碌状态的过程中进行测量。

bogus iterationsbogus iterations

bogus iterations 这个值得一提,我理解为“伪迭代”,并且也只在 JVM 的注释以及 Aleksey 的几个博客中有介绍,可以理解为 JMH 的内部原理的专用词。

本文花了大量的篇幅介绍了 JMH 存在的意义,以及 JMH sample 中提到的诸多陷阱,这些陷阱会非常容易地被那些不规范的测评程序所触发。我觉得作为 Java 语言的使用者,起码有必要了解这些现象的存在,毕竟 JMH 已经帮你解决了诸多问题了,你不用担心预热问题,不用自己写比较 low 的循环去评测,规避这些测试陷阱也变得相对容易。

实际上,本文的知识点,仅仅是 Aleksey 博客中的内容、 JMH 的 38 个 sample 的冰山一角,有兴趣的朋友可以戳这里查看所有的 JMH sample

陷阱内心 os:像我这么 diao 的陷阱,还有 30 个!

坚持技术分享,您的支持将鼓励我继续创作!