基准测试(Benchmark) JMH 实战篇

上一篇文章介绍了基准测试的基本概念以及 Java性能测试工具 JMH 的简单使用。这一篇文章将通过实例详细介绍 JMH 的使用方法,并验证几个提高程序性能的方式是否正确并给出作者的结论。

字符串拼接基准测试

Java 中有这样一条优化建议,在循环中使用”+“号拼接字符串会带来很大的性能损失,应该使用StringBuilder。

这样的建议是否准确呢?我们可以设计基准测试来验证,使用的当然是我们的主角 JMH,下面是代码实现 (源码可以在 GitHub 上查看:地址)

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
import java.util.concurrent.TimeUnit;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
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;

/**
* 字符串拼接基准测试
*
* 测试结果:
* <pre>
* Benchmark Mode Cnt Score Error Units
* StringAppendBenchmarkTenK.stringAddBenchmark avgt 25 82.590 ± 14.824 ms/op
* StringAppendBenchmarkTenK.stringBufferBenchmark avgt 25 0.127 ± 0.005 ms/op
* StringAppendBenchmarkTenK.stringBuilderBenchmark avgt 25 0.146 ± 0.010 ms/op
* </pre>
*
* @author KevinZhang <kevin.zhang.me@gmail.com>
*/

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class StringAppendBenchmarkTenK {

public static final int TEN_K = 10000;

@Benchmark
public String stringAddBenchmark() {
String targetString = "";
for (int i = 0; i < TEN_K; i++) {
targetString += "hello";
}
return targetString;
}

@Benchmark
public String stringBuilderBenchmark() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < TEN_K; i++) {
sb.append("hello");
}
return sb.toString();
}

@Benchmark
public String stringBufferBenchmark() {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < TEN_K; i++) {
sb.append("hello");
}
return sb.toString();
}

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

new Runner(opt).run();
}

}

可以看到有几个注解:

@BenchmarkMode 代表基准测试模式,包含

  • Throughput 模式 :吞吐量模式,测试单位时间内可以执行测试方法的次数(默认1秒)
  • Average Time 模式:平均时间模式,测试平均每次操作需要多长时间,它的值等于 Throughput 模式的倒数
  • Sample Time模式:时间取样模式,测试单位时间运行,自动取样执行的时间进行测量,其结果包含统计信息,比如 P99 、数据分布情况等等
  • Single Shot Time模式:测试单次方法运行的时间,没有 预热阶段,适合测试冷启动所需要的时间
  • All模式:同时使用以上所有模式

@Benchmark 代表这个方法是一个基准测试用例,作为测试用例的入口,类似JUnit 中 @Test 注解

@OutputTimeUnit 设置输出数据的单位,此处是毫秒,加上AverageTime 模式,最后的单位是 ms/op:每次操作需要多少毫秒

测试结果

下面是在我在本地运行的结果:

1
2
3
4
Benchmark                                         Mode  Cnt   Score    Error  Units
StringAppendBenchmarkTenK.stringAddBenchmark avgt 25 82.590 ± 14.824 ms/op
StringAppendBenchmarkTenK.stringBufferBenchmark avgt 25 0.127 ± 0.005 ms/op
StringAppendBenchmarkTenK.stringBuilderBenchmark avgt 25 0.146 ± 0.010 ms/op

我们可以看到 stringAddBenchmark 代表简单Sting 相加的方式,使用平均时间模式,平均每次执行需要82.590毫秒,误差在 ± 14.824 ms。其他两个的意义与之类似。

结果分析

从结果中我们可以看到,

  1. 使用简单的字符串相加比使用 StringBuffer 和 StringBuilder 慢了两个数量级;
  2. StringBuilder 比 StringBuffer要慢一些;

结果 1 的原因是:String 在字符串拼接时,每次循环会创建新的 StringBuffer 对象(不是 String 对象),然后把原来的对象销毁,而 StringBuffer / StringBuilder 在初始化时预留了一定的空间,在调用 append 方法时只有在预留空间不足时才会发生数组拷贝。

结果 2 的原因是:StringBuffer 是线程安全的,append 方法是加锁的;StringBuilder 是非线程安全的。两个类除了在线程安全上的区别,其他几乎没有任何差别。

1
2
3
4
5
6
7
8
9
10
11
// StringBuilder 的 toString 方法
public StringBuilder append(String str) {
super.append(str);
return this;
}
// StringBuffer 的 toString 方法
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}

展望

分析至此,我们还可以得出一个结论,**尽管在没有多线程竞争的情况下,加锁仍会损失一部分性能。**这个结论可以设计另一个基准测试来验证,读者有兴趣可以自己设计。

线程安全的 Long 类型

说起线程安全的 long 类型的实现,不难想到有使用悲观锁锁synchronized 、乐观锁 AtomicLong 这两种实现,有没有性能更高的实现呢?肯定是有的,java 8 中发布的 LongAdder 就是为替代 AtomicLong 而存在的。

LongAdder 原理,简单来讲就是在多线程竞争激烈的情况下,LongAdder 将维护的值分散到多个段中,来减少CAS的重试,当需要获得结果时,只需要把各个段相加就可以了(类比 ConcurrentHashMap 分段锁的实现)。与 AtomicLong 多线程 CAS 更新单个值相比,理论上性能会有提升。

下面是我设计的基准测试的用例(源码可以在 GitHub 上查看:地址),用来验证线程安全Long类型的不同实现。

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
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.TearDown;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.runner.RunnerException;

import com.technologiesinsight.jmh.helper.LunchHelper;


/**
* synchronized 锁 vs AtomicLong vs LongAdder 基准测试
*
* <pre>
* Benchmark Mode Cnt Score Error Units
* ThreadSafeLong.testAtomicLongIncrement avgt 25 383.194 ± 23.359 ms/op
* ThreadSafeLong.testLongAdderIncrement avgt 25 108.105 ± 2.911 ms/op
* ThreadSafeLong.testPrimitiveLongIncrement avgt 25 908.964 ± 29.782 ms/op
* </pre>
* @author KevinZhang <kevin.zhang.me@gmail.com>
*/

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
//@Measurement(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS)
//@Warmup(iterations = 0, time = 1, timeUnit = TimeUnit.SECONDS)
//@Fork(1)
@State(Scope.Benchmark)
@SuppressWarnings("unused")
public class ThreadSafeLong {
private static final Integer LOOP = 10000000;
private final Object lock = new Object();

private AtomicLong atomicLong;
private LongAdder longAdder;
private long primitiveLong;


@Setup(Level.Iteration)
public void setUp() {
this.atomicLong = new AtomicLong();
this.longAdder = new LongAdder();
this.primitiveLong = 0L;
}

@Benchmark
@Threads(2)
public long testPrimitiveLongIncrement() {
for (int i = 0; i < LOOP; i++) {
synchronized (lock) {
primitiveLong = primitiveLong + 1;
}
}
return primitiveLong;
}

@Benchmark
@Threads(2)
public long testAtomicLongIncrement() {
for (int i = 0; i < LOOP; i++) {
atomicLong.incrementAndGet();
}
return atomicLong.get();
}

@Benchmark
@Threads(2)
public long testLongAdderIncrement() {
for (int i = 0; i < LOOP; i++) {
longAdder.increment();
}
return longAdder.longValue();
}

@TearDown(Level.Iteration)
public void tearDown() {
long atomicResult = atomicLong.get();
long longAdderResult = longAdder.longValue();
System.out.println(String.format("primitiveLongResult is %s,atomicResult is :%s;longAdderResult is %s", primitiveLong, atomicResult, longAdderResult));
}

public static void main(String[] args) throws RunnerException {
LunchHelper.lunchBenchmark(ThreadSafeLong.class);
}

}

相比于上一个案例,我们发现了几个关于 JMH 的“新面孔”

  • @State(Scope.Benchmark) :有时候测试用例中需要维护一些”状态“(测试用不是一个”纯函数“),”状态“的变化可能会影响测试的结果。所以需要由JMH 管理这些状态,并显示的声明这些状态的声明周期(有效范围)。状态生命周期分为三类,由State的参数指定:
    • Thread 每一个线程创建自己的状态对象。因此共享对象不会有线程安全问题。
    • Group 组内共享状态对象。不同分组可以使用 @Group(“groupName”) 指定
    • Benchmark 一次迭代运行中所有的线程共享状态对象。
  • @Setup(Level.Iteration) :用于执行基准测试前执行一些操作,比如初始化等等。参数 Level表示改方法说明时候执行:
    • Trial 每次一个进程完整运行一遍测试用例之前执行:包括 warmUp 和 正式执行阶段
    • Iteration 每次迭代前会执行。一次完整的运行包含多次迭代过程,每次迭代运行一次测试的方法。
    • Invocation 每次方法调用前会执行。
  • @TearDown : 类似@Setup ,在测试后执行
  • @Thread :同时执行的线程数,用于线程下的基准测试
  • 注释部分 @Measurement 等:用于测试用例开发阶段,提高执行速度,正式测试前需要注释掉
  • LunchHelper.lunchBenchmark(ThreadSafeLong.class) JMH 启动助手

测试结果

1
2
3
4
Benchmark                                  Mode  Cnt    Score    Error  Units
ThreadSafeLong.testAtomicLongIncrement avgt 25 383.194 ± 23.359 ms/op
ThreadSafeLong.testLongAdderIncrement avgt 25 108.105 ± 2.911 ms/op
ThreadSafeLong.testPrimitiveLongIncrement avgt 25 908.964 ± 29.782 ms/op

通过基准测试结果我们可以看到,testAtomicLongIncrement 代表使用 Atomic 类完成自增,运行 25 次后,所得到每次运行平均时间是383.194 ms,误差在± 23.359ms。其他结果的意义与之类似。

分析

从结果中可以看到,在两个线程竞争同一个 long 类型的情况下,LongAdder 性能最好名副其实,AtomicLong 次之,使用 synchronized 加锁性能最差。

在日常开发中,我们可以尝试使用 LongAdder 代替 AtomicInteger 和加锁的方式

展望

通过这个测试案例,我们可以分析出 JMH 测试框架本身的性能损耗,欢迎有兴趣的读者留言交流。

其他测试案例

源码 中还有其他一些测试案例,比如

  • 常见几个Map的测试:位于 com.technologiesinsight.jmh.MapBenchmark

展望

很多公司编程对测试环节于不太重视,追求的是功能快速上线。这样的做法短期内可以提高开发速度,但如果从长远来看,单元测试,基准测试等测试,会减少后期维护、功能扩展的成本;对于开发者来说,掌握基本的测试理论和实践是一项基本的能力。