封面来源:本文封面来源于网络,如有侵权,请联系删除。

本文参考:

本文涉及的代码:mofan212/jmh-study

1. 下载构建官方源码

GitHub 搜索 jmh,进入 openjdk/jmh 页面,clone 官方源码。

用 IDEA 打开项目,执行以下命令(二选一):

1
2
3
4
# 不执行单元测试,也不编译测试类
mvn install -Dmaven.test.skip=true
# 或者:不执行单元测试,但编译测试类
mvn install -DskipTests=true

也可以使用 IDEA 的 Maven 图形化界面完成。

进入 jmh-samples 模块,打开 JMHSample_01_HelloWorld 类,运行其中的 main() 方法。

运行过程中可能出现以下错误:

java: 程序包sun.misc不存在

需要手动调整 IDEA 的 Java Compiler 设置,将它们设置成当前使用的 JDK 版本,之后便可成功运行。

2. Hello World

从官方示例入手,执行以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class JMHSample_01_HelloWorld {

@Benchmark
public void wellHelloThere() {

}

public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(JMHSample_01_HelloWorld.class.getSimpleName())
.forks(1) // 设置总共测几轮
.build();

new Runner(opt).run();
}

}

执行时间比较长,需要耐心等待。

wellHelloThere()@Benchmark 注解标记,表示本次测试的就是 wellHelloThere() 方法,尽管它是一个空方法。

main() 运行完成后,控制台会打印出测试报告。前几行是 JMH 与当前测试环境的信息,之后有:

# 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: indi.mofan.JMHSample_01_HelloWorld.wellHelloThere
  • Warmup 表示测试前的预热信息,此处进行 5 轮预热,每次 10 秒。
  • Measurement 表示测试维度,此处进行 5 轮测试,每次 10 秒。
  • Threads 表示测试时的线程信息,此处表示测试时在 1 个线程中执行。
  • Benchmark mode 表示基准模式,Throughput 表示将测试结果以吞吐量的信息显示出来。

再来看测试结果:

Result "indi.mofan.JMHSample_01_HelloWorld.wellHelloThere":
  2651579229.605 ±(99.9%) 18997454.078 ops/s [Average]
  (min, avg, max) = (2644241675.367, 2651579229.605, 2657354438.867), stdev = 4933578.495
  CI (99.9%): [2632581775.527, 2670576683.683] (assumes normal distribution)

99.9% 的情况下,平均吞吐量为 2651579229.605 ops/s,误差 ±18997454.078 ops/s

之后列举出最小值、平均值、最大值,stdev 表示标准差,也就是方差开根号。

CI 表示置信区间,这里表示 99.9% 的概率下,测试结果在区间 [2632581775.527, 2670576683.683] 范围内。

3. 使用示例

使用 JMH 时需要导入以下依赖:

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>

<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
</dependency>

3.1 @Warmup 与 @Measurement

在执行第一个示例时,耗时较长,这主要是因为测试过程中的预热次数与测试次数较长,这些信息可以通过 @Warmup@Measurement 注解进行修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Warmup(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS) // 预热一轮,每次一秒
@Measurement(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS) // 测试一轮,每次一秒
public class Sample_01_2_Warmup {
@Benchmark
public void wellHelloThere() {

}

public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(Sample_01_2_Warmup.class.getSimpleName())
.forks(1) // 设置总共测几轮
.build();

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

运行上述代码,发现相比于第一个示例的运行时间,快了许多。

至于最终结果到底准不准,那就另当别论了。

为了能够更快地看到测试结果,后续测试都将采用这两个配置。

3.2 @BenchmarkMode

JMH 提供了多种 Benchmark Mode,使用 @BenchmarkMode 注解来指定采用的 Benchmark Mode。

@BenchmarkMode 注解经常与 @OutputTimeUnit 注解搭配使用,使用 @OutputTimeUnit 指定输出报告的时间单位。

当前 JMH 可以采用的 Benchmark Mode:

  • Mode.Throughput:吞吐量测试,每单位时间测试方法会执行多少次
  • Mode.AverageTime:平均耗时时间,每次操作耗时,采用相同的单位时,与 Mode.Throughput 互为倒数
  • Mode.SampleTime:抽样测试,在执行过程中采样,最快的、50% 快的、90%、95%、99%、99.9%、99.99%、100%,可以使用抽样测试对算法的稳定性进行测试
  • Mode.SingleShotTime:冷启动测试,测试方法在测试中只会运行一次,用于测试冷启动的性能

@BenchmarkMode 注解并不是只能接收单一的 Mode,它接收的是 Mode 数组,因此能够对测试方法设置多个 Mode。

除此之外还提供了 Mode.All 选项,使用当前 JMH 支持的所有 Benchmark Mode 进行测试。

3.3 @State

基本使用

@State 注解 只能作用在类 上,它描述了类对象的作用域,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 该静态内部类会在 Benchmark 启动时初始化,可以用作方法的入参
* 所有测试线程共享一个实例,用于测试有状态实例在多线程共享下的性能
* 一般用来测试多线程竞争下的性能
*/
@State(Scope.Benchmark)
public static class BenchmarkState {
volatile double x = Math.PI;
}

/**
* 该静态内部类会在 Benchmark 各个线程之前初始化,可以用作方法的入参
* 所有测试线程各用各的
*/
@State(Scope.Thread) // 不同线程,不同对象
public static class ThreadState {
volatile double x = Math.PI;
}

@State 标记的类会在 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
/**
* 根据 main 方法的配置,会启动 4 个线程去一起执行
* 每个线程的入参都是不同的
*/
@Benchmark
public void measureUnshared(ThreadState state) {
state.x++;
}

/**
* 启动 4 个线程,但入参都是一个实例,竞争会非常激烈
*/
@Benchmark
public void measureShared(BenchmarkState state) {
state.x++;
}

public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(Sample_03_States.class.getSimpleName())
// 执行每个 Benchmark 时,创建 4 个线程去执行
.threads(4)
.forks(1)
.build();

new Runner(opt).run();
}

Default State

在基本使用中,@State 标记的都是静态内部类,静态内部类的作用和普通类没什么区别。

为了更方便地使用,JMH 提供了 Default State,当 @State 注解作用在 @Benchmark 注解标记的方法所在的类上时,测试方法直接使用的成员变量与测试方法接收当前类实例并使用其成员变量没有区别。

比如下面两种表示是等价的:

1
2
3
4
5
6
7
8
9
10
@State(Scope.Thread)
public class Sample_04_DefaultState {

double x = Math.PI;

@Benchmark
public void measure() {
x++;
}
}
1
2
3
4
5
6
7
8
9
10
@State(Scope.Thread)
public class Sample_04_DefaultState {

double x = Math.PI;

@Benchmark
public void measure(Sample_04_DefaultState state) {
state.x++;
}
}

3.4 @Setup 和 @TearDown

基本使用

@Setup@TearDown 两个注解可以认为是 @State 注解生命周期的一部分:

  • @Setup:该注解只能作用在方法上,并且标记的方法所在的类必须被 @State 注解标记。@Setup 注解标记的方法用于完成 启动 Benchmark 前的准备工作
  • @TearDown:该注解与 @Setup 注解类似,只不过该注解标记的方法用于完成 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
@State(Scope.Thread)
@Warmup(iterations = 1, time = 1)
@Measurement(iterations = 1, time = 1)
public class Sample_05_StateFixtures {

double x;

/**
* 启动 Benchmark 前的准备工作。
* {@code @Setup} 注解必须在 {@code @State} 标记的类下才能使用,
* 实际上也算是 {@code @State} 管理的对象的生命周期的一部分
*/
@Setup
public void prepare() {
x = Math.PI;
}

/**
* Benchmark 结束后的检查工作。
* 与 {@code @Setup} 注解类似,也必须在 {@code @State} 标记的类下使用。
*/
@TearDown
public void check() {
assert x > Math.PI : "Nothing changed?";
}

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

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

public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(Sample_05_StateFixtures.class.getSimpleName())
.forks(1)
.jvmArgs("-ea")
.build();

new Runner(opt).run();
}

}

FixtureLevel

可以为 @Setup@TearDown 设置 Level 类型的 value 值,默认值是 Level.Trial,表示被标记的方法在整个基准测试中只执行一次。

除此之外,还有两个值可供选择:

  • Level.Iteration:每轮循环完成之前(或之后)才会执行
  • Level.Invocation:每次方法被调用之前(或之后)才会执行
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
@State(Scope.Thread)
@Warmup(iterations = 1, time = 1)
@Measurement(iterations = 3, time = 1)
public class Sample_06_FixtureLevel {

double x;

/*
* Level.Trial: before or after the entire benchmark run (the sequence of iterations)
* Level.Iteration: before or after the benchmark iteration (the sequence of invocations)
* Level.Invocation; before or after the benchmark method invocation (WARNING: read the Javadoc before using)
*
* Level.Trial: 整个完整的基准测试完成之前(或之后)才会执行
* Level.Iteration: 每轮循环完成之前(或之后)才会执行一次
* Level.Invocation: 每次方法被调用之前(或之后),就会执行一次
*/
@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++;
}

public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(Sample_06_FixtureLevel.class.getSimpleName())
.forks(1)
.jvmArgs("-ea")
// 默认 false,即使 assert 错误也不会让整个测试失败
// 如果设置为 true,assert 失败时,整个测试也就失败了
.shouldFailOnError(false) // switch to "true" to fail the complete run
.build();

new Runner(opt).run();
}

}

FixtureLevelInvocation

合理使用 @Setup@TearDown 注解的 value 值,能够模拟一些实际场景,比如模仿线程池的冷启动:

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
@Warmup(iterations = 1, time = 1)
@Measurement(iterations = 1, time = 1)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public class Sample_07_FixtureLevelInvocation {

@State(Scope.Benchmark) // 全局公用这个实例
public static class NormalState {
ExecutorService service;

@Setup(Level.Trial) // 全局只执行一次
public void up() {
service = Executors.newCachedThreadPool();
}

@TearDown(Level.Trial) // 全局只执行一次
public void down() {
service.shutdown();
}

}

public static class LaggingState extends NormalState {
public static final int SLEEP_TIME = Integer.getInteger("sleepTime", 10);

// 模拟冷启动线程池
@Setup(Level.Invocation) // 每次调用都会执行
public void lag() throws InterruptedException {
// 每次 Benchmark 方法被调用前,睡 10ms
TimeUnit.MILLISECONDS.sleep(SLEEP_TIME);
}
}

@Benchmark
public double measureHot(NormalState e,
final Scratch s) throws ExecutionException, InterruptedException {
// 在线程池中执行任务,阻塞地等待结果的响应
return e.service.submit(new Task(s)).get();
}

@Benchmark
public double measureCold(LaggingState e,
final Scratch s) throws ExecutionException, InterruptedException {
return e.service.submit(new Task(s)).get();
}


@State(Scope.Thread) // 每个线程都会用各自的实例
public static class Scratch {
private double p;
public double doWork() {
p = Math.log(p);
return p;
}
}

public static class Task implements Callable<Double> {
private Scratch s;

public Task(Scratch s) {
this.s = s;
}

@Override
public Double call() {
return s.doWork();
}
}

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

new Runner(opt).run();
}

}

LaggingState 中的线程池任务在执行之前,会先休眠 10 ms,而 NormalState 中的线程池任务在执行之前则不会,前者用于模拟线程池的冷启动。

3.5 Dead Code

在使用 JMH 时,一些错误的使用方式可能会导致测试代码被 JVM 优化掉,进而导致基准测试结果不准确。

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
@Warmup(iterations = 1, time = 1)
@Measurement(iterations = 1, time = 1)
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class JMHSample_08_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 baseline() {
// do nothing, this is a baseline
}

@Benchmark
public void measureWrong() {
// 没有使用计算结果,JVM 将优化这段代码,相当于在测试空方法
compute(x);
}

@Benchmark
public double measureRight() {
// 正确的做法,将结果返回,让 JVM 认为计算结果不能省略
return compute(x);
}

public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(JMHSample_08_DeadCode.class.getSimpleName())
.forks(1)
// JDK8 下尽量设置为 server 模式,充分利用 JIT
.jvmArgs("-server")
.build();

new Runner(opt).run();
}

}

上述示例代码中:

  • baseline():是一个空方法
  • measureWrong():由于计算结果并没有返回,JVM 会自动优化,使其耗时测得与 baseline() 一样
  • measureRight():计算结果正常返回,JVM 不会自动优化,测出真正的执行效率

3.6 Blackhole

为了减少 JVM 的优化,JMH 提供了 Blackhole 对象,使用该对象对执行结果进行消费,让测试结果尽可能准确。

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

double x1 = Math.PI;
double x2 = Math.PI * 2;

private double compute(double d) {
for (int c = 0; c < 10; c++) {
d = d * d / Math.PI;
}
return d;
}

/**
* 基准
*/
@Benchmark
public double baseline() {
return compute(x1);
}

/**
* 错误示例
*/
@Benchmark
public double measureWrong() {
// 编译器会自动识别,在 JIT 的时候直接不执行,使得 JMH 的测试结果不准确
compute(x1);
// 真正有用的计算
return compute(x2);
}

/**
* 正确示例 1
*/
@Benchmark
public double measureRight_1() {
// 所有的计算结果都使用了
return compute(x1) + compute(x2);
}

/**
* 正确示例 2
*/
@Benchmark
public void measureRight_2(Blackhole bh) {
// 为了防止编译器“自作主张”,使用 JMH 提供的 Blackhole 对象对执行结果进行消费
bh.consume(compute(x1));
bh.consume(compute(x2));
}

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

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

3.7 常量折叠

常量折叠,或者叫常数折叠。百度百科对 常数折叠 的解释是这样的:常数折叠是编译器最佳化技术,被使用在现代的编译器中。进阶的常数传播形式,或称之为稀疏有条件的常量传播,可以更精确地传播常数及无缝的移除无用的程式码。

简单来说:当计算结果是可预测的时候,编译器会在编译时期将结果计算出来,运行时直接使用计算结果,而不是每次都执行运算过程。

由于常量折叠的存在,在使用 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
54
55
56
57
58
59
60
@Warmup(iterations = 1, time = 1)
@Measurement(iterations = 1, time = 1)
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class Sample_10_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 baseline() {
return Math.PI;
}

/**
* 错误示例:结果是可预测的,因此计算过程会被省略,直接返回计算结果
*/
@Benchmark
public double measureWrong_1() {
return compute(Math.PI);
}

/**
* 错误示例:传入的 wrongX 被 final 修饰,错误原因与 {@code measureWrong_1()} 一样
*/
@Benchmark
public double measureWrong_2() {
return compute(wrongX);
}

/**
* 正确示例:传入的 x 未被 final 修饰,计算结果不可预测
*/
@Benchmark
public double measureRight() {
return compute(x);
}

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

new Runner(opt).run();
}

}

3.8 错误的循环

假设需要计算两个数相加的效率,那么可以有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class Sample_11_Loops {

int x = 1;
int y = 2;

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

// --snip--
}

基于此,是否可以写一个循环,循环中进行 10 次相加运算,这 10 次循环相加的效率是否等于 1 次相加运算的 10 倍效率呢?

继续增加循环次数,100 次、1000 次、10000 次、100000 次,最终的效率是否是 1 次相加运算的效率的对应倍数呢?

JMH 提供了 @OperationsPerInvocation 注解,该注解接收 int 类型的 value 值,表示运行一次目标方法相当于运行了给定次数的 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
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(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);
}

测试报告会和预期的一致吗?

measureRight()measureWrong_1() 的效率类似,但随着循环次数的增加,运行效率也在不断增加,循环 100000 次的效率与 1 次计算的效率更是天差地别。

这告诉我们在测试时,不要利用循环多次的效率来倒推每次的执行的效率,这往往是不准确的。对同种计算循环多次时,JVM 会对其进行优化,以提高执行效率。

3.9 @Fork

JMH 提供了一个 @Fork 注解,那他有什么用呢?一起探究下。

提供 printRunningJvmName() 方法,返回正在运行的 JVM 的名称:

1
2
3
4
5
6
7
public static void printRunningJvmName(String name) {
System.out.println();
System.out.println("---------------------------------------");
System.out.println(name + " jvm name is : " + ManagementFactory.getRuntimeMXBean().getName());
System.out.println("---------------------------------------");
System.out.println();
}

@Fork(0)

@Fork 注解可以作用在类上和方法上,当以 @Fork(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
@Warmup(iterations = 1, time = 1)
@Measurement(iterations = 1, time = 1)
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class Sample_12_Forking_0 {

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

@Setup(Level.Trial)
public void setup() {
printRunningJvmName("setup");
}

public static void main(String[] args) throws RunnerException {
printRunningJvmName("main");
Options opt = new OptionsBuilder()
.include(Sample_12_Forking_0.class.getSimpleName())
.build();
// 执行 main 方法与执行 Benchmark 是在同一个 JVM 中
new Runner(opt).run();
}
}

从最终的测试报告可知,执行 main() 方法与执行 Benchmark 是在同一个 JVM 中。

@Fork(1)

仅仅修改 @Fork(0)@Fork(1),最终的测试报告又会有什么变化呢?

1
2
3
4
5
@Benchmark
@Fork(1)
public int measure_1_c1() {
return 1;
}

此时执行 main() 方法与执行 Benchmark 不在同一个 JVM 中,也就是在执行 JMH 的整个测试时,会 fork 出一个进程,创建出崭新的 JVM,在这个 JVM 中执行 Benchmark。

@Fork(n)

如果修改为 @Fork(10) 呢?

1
2
3
4
5
@Benchmark
@Fork(10)
public int measure_1_c1() {
return 1;
}

@Fork(10) 表示在执行 JMH 的整个测试时,fork 出 10 个进程来执行 Benchmark。

fork 进程的操作不是并行的,也就是说,先 fork 出一个进程,创建出一个 JVM,执行 Benchmark,之后销毁 JVM 再 fork 出一个进程,重复 10 次。

注意,这里重复 10 次只是重复创建 10 次 JVM,执行 setup() 方法虽然也只执行了 10 次,但后者还和 @Warmup@Measurement@Setup 注解有关。

@Fork

如果不显式传入 @Fork 注解的 value 值,其默认值是 -1,这相当于显式传入 5

1
2
3
4
5
@Benchmark
@Fork // 不传具体的值,即为 -1,相当于显式传入 5
public int measure_1_c1() {
return 1;
}

但不建议这么使用,如果要传入 5,应当显式指定 value 值为 5

首先,显式传入的方式更加清晰明确,其次在 @Fork 注解的 Java doc 中并未说明不传具体的值就相当于传入 5,这是在自行测试后得出来的,但指不定在后续的版本中就修改了这个规则呢?

官方示例

首先定义 Counter 接口:

1
2
3
public interface Counter {
int inc();
}

再定义 Counter1Counter2 作为 Counter 的实现类,但它们实现的逻辑都一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static class Counter1 implements Counter {
private int x;

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

public static class Counter2 implements Counter {
private int x;

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

接着定义 measure() 方法,该方法接收一个Counter 实例,在内部通过循环调用 10 次 Counterinc() 方法:

1
2
3
4
5
6
7
public int measure(Counter c) {
int s = 0;
for (int i = 0; i < 10; i++) {
s += c.inc();
}
return s;
}

最后就是一系列 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
Counter c1 = new Counter1();
Counter c2 = new Counter2();

@Benchmark
@Fork(0)
public int measure_1_c1() {
/*
* JVM 在执行这段代码时,会假设 Counter 接口只有一个实现
* 使用了方法内联的 JIT 优化手段
*/
return measure(c1);
}

@Benchmark
@Fork(0)
public int measure_2_c2() {
/*
* JVM 在执行这段代码的时候,发现接口的实现类有多个,
* 使用 Java 的动态方法机制(运行时多态),跟方法内联相比,速度会显著降低
*/
return measure(c2);
}

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

初看会认为每个 Benchmark 的测试结果都一样,但在实际运行后会发现它们之间存在着差异。

当测试 measure_1_c1() 时,传入 c1 调用 measure() 方法,JVM 在执行该方法时,会假设 Counter 接口只有一个实现,使用方法内联的 JIT 优化手段,提高执行效率。在测试 measure_2_c2() 时,传入 c2 调用 measure() 方法,同一个 JVM 在执行该方法时,发现 Counter 接口的实现类实际上有多个,使用 Java 的动态方法机制(运行时多态),相比于方法内联,这种方式的执行效率会降低。后续在测试 measure_3_c1_again() 时也是相同情况。

测试 measure_4_forked_c1() 时,该方法被 @Fork(1) 标记,表示测试时会新建一个 JVM 进行测试,此时又会使用方法内联进行优化,最后测试的 measure_5_forked_c2() 也是这样。

什么时候使用 @Fork

现有如下测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@State(Scope.Thread)
public static class SleepyState {
public long sleepTime;

@Setup
public void setup() {
sleepTime = (long) (Math.random() * 1000);
System.out.println();
System.out.println("-----------------------------------");
System.out.println("sleepTime = " + sleepTime);
System.out.println("-----------------------------------");
}
}

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

目的很简单,在执行 Benchmark 前,获取一个 [0, 1000) 之间的随机数,然后休眠随机数值的毫秒。

最终测得该方法的平均耗时时间应该和获取的随机数差不多。

但问题也来了,随机数的范围很大,每次执行获取的值不同,极端情况下,第一次得到的是 0,第二次得到的是 999,难道这两次能反映整体的平均耗时时间吗?

显然是不行的。

根据大数定律,随着测试次数的增加,最终的效率就越趋近于真实执行效率。

此时可以利用 @Fork 注解,比如在 @Fork(5)@Fork(20) 的情况下,理论上 后者应该更趋近于真实执行效率,即 500 ms/op,当然真正执行后 @Fork(5) 更接近也是有可能的。

1
2
3
4
5
6
7
8
9
10
11
@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);
}

3.10 @Group 与 @GroupThreads

在计算两个都被 @Benchmark 注解标记的方法的执行效率时,在测试报告中,它们的执行效率会分开。

如果需要合并多个 Benchmark,可以使用 @Group 注解。使用 @Group 时,需要指定一个 value 值,JMH 会将具有相同 value 值的方法放到同一个 Benchmark 中执行,基于此可以创造一些线程之间的竞争关系。

与之搭配的还有 @GroupThreads 注解,该注解能够指定参与运行特定组的 Benchmark 方法的线程数。

回顾前文中的 @State 注解,它描述了类对象的作用域,它还有个 value 值是 Scope.Group,这在前文中没有提到,@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
27
28
@State(Scope.Group)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class Sample_15_Asymmetric {

private AtomicInteger counter;

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

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

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

// --snip--
}

定义了名为 g1 的组,该组中有两个方法,分别是:

  • inc():同时有三个线程执行 AtomicIntegerincrementAndGet() 方法
  • get():只有一个线程执行 AtomicIntegerget() 方法

也就是说在 g1 中,同时会有四个线程在执行方法,incrementAndGet() 方法是线程安全的,其底层使用了 CAS,当有多个线程执行该方法时,会存在竞争。

除此之外,还定义了 g2g3 组:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Benchmark
@Group("g2")
public int inc1() {
// 只有一个线程在自增,效率很高
return counter.incrementAndGet();
}

@Benchmark
@Group("g3")
public int get1() {
// 只有一个线程在 get,效率拉满
return counter.get();
}

这两个组中只有一个线程在执行自增或 get,相比于存在线程竞争的 g1,这两个组中的方法的效率 应该 更高。

当然这不是一定的,这与硬件条件还有关系,如果发现 g1 的执行效率比 g2 更高,尝试增加 g1 的线程竞争关系,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Benchmark
@Group("g1")
@GroupThreads(100)
public int inc() {
return counter.incrementAndGet();
}

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

此时有 100 个线程执行自增,30 个线程执行 get。

4. 初阶实战示例

4.1 排序算法的效率

十大排序算法的复杂度、稳定性如下图(来源:十大经典排序算法 | 菜鸟教程)所示:

排序算法比较表

更多实现细节可以参考:

单从平均时间复杂度上来看,冒泡排序、选择排序、插入排序的性能是低于希尔排序、归并排序、快速排序和堆排序的。事实真是如此吗?

设定待排序数组内部元素取值为 0 到 100 的整数,编写 Benchmark 对上述排序算法效率进行比较。

数组长度为 10

Benchmark                    Mode  Cnt     Score   Error  Units
SortBenchmark.bubbleSort     avgt        148.637          ns/op
SortBenchmark.bucketSort     avgt        614.930          ns/op
SortBenchmark.countingSort   avgt        229.073          ns/op
SortBenchmark.heapSort       avgt        175.842          ns/op
SortBenchmark.insertSort     avgt        118.565          ns/op
SortBenchmark.mergeSort      avgt        665.085          ns/op
SortBenchmark.quickSort      avgt        187.735          ns/op
SortBenchmark.radixSort      avgt       5351.363          ns/op
SortBenchmark.selectionSort  avgt        122.999          ns/op
SortBenchmark.shellSort      avgt        149.182          ns/op

以快速排序 quickSort 和插入排序 insertSort 为例,前者的平均时间复杂度为 O(nlogn)O(nlogn),后者为 O(n2)O(n^2),但根据测试报表可知,在数组长度为 10 的情况下,插入排序的效率更高。

数组长度为 100

Benchmark                    Mode  Cnt      Score   Error  Units
SortBenchmark.bubbleSort     avgt       10199.883          ns/op
SortBenchmark.bucketSort     avgt        3765.742          ns/op
SortBenchmark.countingSort   avgt        2896.813          ns/op
SortBenchmark.heapSort       avgt        3061.878          ns/op
SortBenchmark.insertSort     avgt        1335.316          ns/op
SortBenchmark.mergeSort      avgt       12285.984          ns/op
SortBenchmark.quickSort      avgt        2555.055          ns/op
SortBenchmark.radixSort      avgt       23673.644          ns/op
SortBenchmark.selectionSort  avgt        4337.397          ns/op
SortBenchmark.shellSort      avgt        2487.184          ns/op

此时依旧是插入排序的效率更高。

数组长度为 10000

Benchmark                    Mode  Cnt         Score   Error  Units
SortBenchmark.bubbleSort     avgt       61518470.000          ns/op
SortBenchmark.bucketSort     avgt        1275125.000          ns/op
SortBenchmark.countingSort   avgt          25470.588          ns/op
SortBenchmark.heapSort       avgt         538462.500          ns/op
SortBenchmark.insertSort     avgt        2728411.765          ns/op
SortBenchmark.mergeSort      avgt       55002733.333          ns/op
SortBenchmark.quickSort      avgt         362605.556          ns/op
SortBenchmark.radixSort      avgt       43962510.000          ns/op
SortBenchmark.selectionSort  avgt       14798706.667          ns/op
SortBenchmark.shellSort      avgt         440547.059          ns/op

此时情况发生了好转,快速排序的效率明显领先于插入排序。

总结

在判断某一算法的实际效率时,不能一昧迷信时间复杂度,在数组长度较小的情况下,O(n2)O(n^2) 的插入排序效率也能比 O(nlogn)O(nlogn) 的快速排序效率高。

正因如此,JDK 底层对 Arrays.sort() 方法的实现也不是简单选用一种排序算法,不同的数组情况,将选择不同的排序算法,比如当数组长度较小时,选择插入排序作为排序逻辑。

4.2 Map > for ?

对于 for 循环来说,认为其时间复杂度为 O(n)O(n),而对于根据 key 从 Map 中获取对应的 value,认为其时间复杂度为 O(1)O(1)。单看时间复杂度,Map 的效率大于 for 循环,事实真是如此吗?

以枚举为例

以枚举的使用为例,实际开发过程中,枚举类中并不是只有枚举项,通常还会包含其他附加描述信息,比如 codevalue 等,然后在使用时根据这些附加信息获取对应的枚举项。

以根据 value 获取枚举项为例:

  • 既可以遍历每个枚举项,找到与传入 value 对应的枚举项
  • 也可以用 value 为 key,枚举项为 value 构造 Map,通过该 Map 获取对应的枚举项
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
/**
* @author mofan
* @date 2023/12/26 23:13
*/
public enum EnumMatch {
A(1),
B(2),
C(3),
D(4),
E(5),
F(6),
G(7),
H(8),
I(9),
J(10),
NULL(-1),
;

private final Integer value;

EnumMatch(Integer value) {
this.value = value;
}

public static EnumMatch matchWithFor(Integer value) {
for (EnumMatch enumMatch : values()) {
if (enumMatch.value.equals(value)) {
return enumMatch;
}
}
return NULL;
}

private static final Map<Integer, EnumMatch> ENUM_MAP = new HashMap<>();

static {
for (EnumMatch enumMatch : values()) {
ENUM_MAP.put(enumMatch.value, enumMatch);
}
}

public static EnumMatch matchWithMap(Integer value) {
EnumMatch result = ENUM_MAP.get(value);
if (result == null) {
result = EnumMatch.NULL;
}
return result;
}
}

EnumMatch 中除 NULL 外,共有 10 个有效的枚举项。

随机生成一个 1 到 10 的随机数,测试使用 forMap 获取对应枚举项的效率:

Benchmark                      Mode  Cnt   Score   Error  Units
EnumMatchBenchmark.matchByFor  avgt       27.367          ns/op
EnumMatchBenchmark.matchByMap  avgt       19.456          ns/op

此时通过 Map 获取对应枚举项的效率要更高些,但两者的差距并不大。

枚举通常用于类型的表示,其个数往往在 5 个以内。

假设只有 3 个有效的枚举项,随机生成一个 1 到 3 的随机数,测试 forMap 的效率:

Benchmark                      Mode  Cnt   Score   Error  Units
EnumMatchBenchmark.matchByFor  avgt       21.513          ns/op
EnumMatchBenchmark.matchByMap  avgt       19.134          ns/op

尽管依旧是 Map 的效率领先,但二者的差距基本可以忽略不计。

从代码简洁的角度来看,显然是使用 for 更加简洁、清晰,减少了样板代码的出现。

以对象为例

业务开发中,实体类往往存在一个表示唯一标识的字段,后续可能需要从一组数据中筛选出给定标识指向的实体对象。对于筛选方式的实现往往有:

  • 简单暴力使用 for 循环

  • 性能极致使用 Map

  • 简洁易读使用 Stream

在一般的认知中,这三种方式的筛选效率关系为:

Map > for > Stream

事实真是如此吗?

使用整型字段 a 作为 TestObj 对象中的唯一标识,现在需要从 10 个 TestObj 对象中筛选出唯一标识与给定整数相同的 TestObj 对象,使用上述三种筛选方式实现并测试它们的效率:

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
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 2, time = 5)
@Measurement(iterations = 2, time = 5)
@State(Scope.Thread)
@Fork(1)
public class TestObjBenchmark {
List<TestObj> list;
Map<Integer, TestObj> map;

@Setup(Level.Trial)
public void prepareValue() {
list = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
TestObj obj = new TestObj();
obj.setA(i);
list.add(obj);
}

map = new HashMap<>();
for (TestObj obj : list) {
map.put(obj.getA(), obj);
}
}

int a = -1;

@Setup(Level.Invocation)
public void prepareA() {
a = new RandomDataGenerator().nextInt(1, 10);
}

@Benchmark
public TestObj getByStream() {
return list.stream()
.filter(i -> i.getA().equals(a))
.findFirst()
.orElse(null);
}

@Benchmark
public TestObj getByFor() {
for (TestObj obj : list) {
if (obj.getA().equals(a)) {
return obj;
}
}
return null;
}

@Benchmark
public TestObj getByMap() {
return map.get(a);
}

public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(TestObjBenchmark.class.getSimpleName())
.build();
new Runner(options).run();
}
}

输出的测试报告如下:

Benchmark                     Mode  Cnt   Score   Error  Units
TestObjBenchmark.getByFor     avgt    2  37.708          ns/op
TestObjBenchmark.getByMap     avgt    2  22.698          ns/op
TestObjBenchmark.getByStream  avgt    2  59.334          ns/op

执行效率的大小关系与先前预想的一致,但它们之间的差距其实也不大。

尽管使用 Stream 的效率最低,但在效率要求不高的场景下,使用 Stream 会让代码更加简洁、清晰,并能极大减少样板代码的输出。

4.3 报表可视化

除了在控制台中输出测试报告外,JMH 允许以不同的文件格式输出测试报告。比如以 JSON 文件的形式,将测试报告输出到当前模块下 target 目录的 result.json 文件中,则需要在构造 Options 对象时进行以下配置:

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(TestObjBenchmark.class.getSimpleName())
// 报表可视化,输出 JSON 文件
.resultFormat(ResultFormatType.JSON)
// 输出到当前模块下的 target 目录下
.result("./official-sample/target/result.json")
.build();
new Runner(options).run();
}

在拿到 result.json 文件后,将该文件拖入以下任意一个网站中,即可实现报表可视化:

5. 高阶使用技巧

5.1 调整 JVM 编译器策略

首先介绍下 JVM 的方法内联:在编译过程中遇到方法调用时,将调用的目标方法体纳入编译范围之中,并取代原方法调用的优化手段。

比如现有一个私有方法 b()

1
2
3
4
5
6
private void b() {
int i = 0;
// do something
i++;
System.out.println(i);
}

a() 方法中调用了 b() 方法:

1
2
3
4
5
6
7
private void a() {
for (int i = 0; i < 10; i++) {
// 调用私有方法,执行 invokespecial 指令
// 方法入栈、分配栈上内存
b();
}
}

在调用 b() 方法时,执行 invokespecial 指令。

方法调用也是有性能消耗的,比如会进行方法入栈、分配栈上内存等。

JVM 在编译上述代码时,发现对 b() 方法的调用可以优化为方法内联,即:

1
2
3
4
5
6
7
private void a() {
for (int i = 0; i < 10; i++) {
int i = 0;
i++;
System.out.println(i);
}
}

此时不再有对 b() 方法的调用,能够提高执行效率。

注意: 不是说任何方法的调用都能优化成方法内联,具体细节不再展开,详细内容可以参考周志明《深入理解Java虚拟机》一书中第四部分【程序编译与代码优化】中【编译器优化技术】的【方法内联】。

为了测试方法内联带来的性能影响,JMH 提供了 @CompilerControl 注解,该注解能够告知 JVM 如何对代码进行编译优化(或者说不优化)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void target_blank() {
}

/**
* DONT_INLINE: 不进行方法内联优化
*/
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public void target_dontInline() {
}

/**
* INLINE: 强制进行方法内联优化
*/
@CompilerControl(CompilerControl.Mode.INLINE)
public void target_inline() {
}

/**
* EXCLUDE: 禁止编译,始终使用解释执行
*/
@CompilerControl(CompilerControl.Mode.EXCLUDE)
public void target_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
@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();
}

测试报告如下:

Benchmark                             Mode  Cnt  Score   Error  Units
Sample_16_CompilerControl.baseline    avgt    3  0.196 ± 0.002  ns/op
Sample_16_CompilerControl.blank       avgt    3  0.196 ± 0.006  ns/op
Sample_16_CompilerControl.dontinline  avgt    3  0.811 ± 2.552  ns/op
Sample_16_CompilerControl.exclude     avgt    3  8.508 ± 1.443  ns/op
Sample_16_CompilerControl.inline      avgt    3  0.222 ± 0.792  ns/op

在采用 EXCLUDE 时,执行耗时最长。

dontinline()inline() 相比,后者的执行效率更高,进行方法内联优化后,执行效率能得到提升。

5.2 同步线程

JMH 在默认情况下,如何执行多线程的基准测试?

JMH 会等待所有线程都启动准备好之后,再同时执行 Benchmark,此时系统负载最高,CPU 使用率最高。

这与实际生产环境上不一样,生产环境上显然不可能等待所有线程都准备好之后,再等待请求访问,系统负载应该是逐步上升,CPU 使用率也是逐步提高。

在使用 OptionsBuilder 构造 Options 对象时,提供了 syncIterations() 方法,该方法能够设置线程同步。默认情况下,其值为 true,表示 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
@State(Scope.Thread)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class Sample_17_SyncIterations {

private double src;

/**
* 为演示耗时而存在,没有特殊意义
*/
@Benchmark
public double test() {
double s = src;
for (int i = 0; i < 1000; i++) {
s = Math.sin(s);
}
return s;
}

public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(Sample_17_SyncIterations.class.getSimpleName())
.warmupTime(TimeValue.seconds(1))
.warmupIterations(1)
.measurementTime(TimeValue.seconds(1))
// 设置线程数为 CPU 核心数 * 16
// I9-13980HX 32 核心 -> 32 * 16 = 512 线程
.threads(Runtime.getRuntime().availableProcessors() * 16)
.forks(1)
/*
* 默认值为 true。
* true: 线程同步,所有先线程准备好之后再执行
* false: 线程逐步执行
*/
.syncIterations(true)
.build();

new Runner(opt).run();
}

}

设置为 true 时:

Benchmark                       Mode  Cnt     Score      Error   Units
Sample_17_SyncIterations.test  thrpt    5  8628.210 ± 2454.625  ops/ms

设置为 false 时:

Benchmark                       Mode  Cnt      Score       Error   Units
Sample_17_SyncIterations.test  thrpt    5  47739.259 ± 98840.596  ops/ms

5.3 Control 对象

在 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
@State(Scope.Group)
public class Sample_18_Control_1 {

public final AtomicBoolean flag = new AtomicBoolean(true);

@Benchmark
@Group("pingpong")
public void pingWithoutControl() {
while (true) {
// flag 期望是 false 时,才设置为 true,否则不设置
boolean setSuccess = flag.compareAndSet(true, false);
if (setSuccess) {
// 设置成功才结束循环,否则一直循环
return;
}
}
}

@Benchmark
@Group("pingpong")
public void pongWithoutControl() {
while (true) {
boolean setSuccess = flag.compareAndSet(false, true);
if (setSuccess) {
// 设置成功才结束循环,否则一直循环
return;
}
}
}

// --snip--
}

存在成员变量 flagpingWithoutControl()pongWithoutControl() 方法都在死循环中尝试对 flag 进行修改。

这两个方法是并行运行的, 因此可能出现以下情况,导致陷入死循环:

  • pingWithoutControl() 方法检查 flag,发现其值为 true,尝试将其修改为 false
  • pingWithoutControl() 方法在尝试修改的同时,pongWithoutControl()flagfalse 改成了 true
  • 此时 pingWithoutControl() 的 CAS 操作就会失败,期望值不再是 true,就导致 pingWithoutControl() 一直处在死循环中。

同样的情况也可能发生在 pongWithoutControl() 方法中,不再赘述。

如果有办法判断 JMH 的测试是否已经结束,就能规避此处的死循环。当 JMH 测试结束时,也退出循环。

JMH 提供了 Control 类,当其作为 Benchmark 方法的参数时,JMH 能够将 Control 对象注入到方法中。Control 提供了 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
@State(Scope.Group)
public class Sample_18_Control_2 {

public final AtomicBoolean flag = new AtomicBoolean(true);

@Benchmark
@Group("pingpong")
public void pingWithoutControl(Control cnt) {
while (true) {
// 测试结束,退出循环
if (cnt.stopMeasurement) {
return;
}
// flag 期望是 false 时,才设置为 true,否则不设置
boolean setSuccess = flag.compareAndSet(true, false);
if (setSuccess) {
// 设置成功才结束循环,否则一直循环
return;
}
}
}

@Benchmark
@Group("pingpong")
public void pongWithoutControl(Control cnt) {
while (true) {
// 测试结束,退出循环
if (cnt.stopMeasurement) {
return;
}
boolean setSuccess = flag.compareAndSet(false, true);
if (setSuccess) {
// 设置成功才结束循环,否则一直循环
return;
}
}
}

// --snip--
}

当 JMH 测试结束时,也会退出循环,无论运行几次,都不会出现死循环。

5.4 SpringBoot 环境下使用 JMH

定义 CallService 模拟实际生产环境中的 Service:

1
2
3
4
5
6
7
8
9
10
@Service
public class CallService {

public void call() {
for (int i = 0; i < 100; i++) {
// do something
double d = Math.sqrt(100);
}
}
}

编写测试类使用 JMH 测试 CallServicecall() 方法的性能:

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
@SpringBootTest
@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class CallServiceTest {

@Autowired
private void setCallService(CallService callService) {
BeanHolder.setBean(callService);
}

private CallService callService;

@Setup
public void setup() {
callService = BeanHolder.getBean();
}

@Benchmark
public void call() {
callService.call();
}

@Test
public void executeBenchmark() throws RunnerException {
Options options = new OptionsBuilder()
.include("\\." + this.getClass().getSimpleName() + "\\.")
/*
* forks 一定要设置成 0
* 让 Benchmark 和启动方法在同一个 JVM 中执行
*/
.forks(0)
.warmupIterations(1)
.warmupTime(TimeValue.seconds(1))
.measurementIterations(1)
.measurementTime(TimeValue.seconds(1))
.build();
new Runner(options).run();
}
}

代码不长,有几个注意点:

  • JMH 相关的注解(比如 @State@BenchmarkMode 等)还是放在类上;

  • 注入 CallService 不再采用字段注入,而是使用 Set 注入,并结合 BeanHolder 以便在 @Setup 方法中为成员变量设置初始值;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class BeanHolder {
    private static Object bean;

    public static void setBean(Object bean) {
    BeanHolder.bean = bean;
    }


    @SuppressWarnings("unchecked")
    public static <T> T getBean() {
    return (T) bean;
    }
    }
  • 在被 @Test 注解标记的测试方法中构造 Options 对象,执行 Runnerrun() 方法进行测试;

  • 构造 Options 对象时,forks() 方法的参数 一定要设置成 0,使得 Benchmark 和启动方法在同一个 JVM 中运行;

  • Benchmark 的编写与先前无异