用JMH来验证伪共享

现代计算机为了提高主存访问速度,通常会有多级缓存。CPU不直接读写主存,而是对L1/L2/L3缓存进行读写。为了提高效率,缓存加载并不是一次一个字节,而是一次加载一个缓存行大小的数据到缓存。如果CPU对缓存行的数据进行了修改,就需要将缓存行写回主存。

由于现代计算机的处理器通常不止一个核,那么如果不同的核心对同一个缓存行进行频繁的读写,就可能存在缓存行频繁的加载和写回主存,造成的后果就是缓存行频繁失效,每次操作都需要重新加载缓存行。而缓存命中和缓存miss情况下CPU耗费的时间是有较大差距的,这种情况下通常会导致吞吐量降低。这种情况我们称为伪共享。

JMH是什么?JMH是Java Microbenchmark Harness,是一个可以用来为JVM平台的语言进行Mirco性能测试的工具。JMH给我们提供了一个Jar,里面包含了一些注解,可以方便的编写函数级的性能测试代码。这里,我们用JMH来验证伪共享问题以及文共享问题的解决方法。

使用下面的mvn命令生成JMH项目:

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

删除原来的test文件,创建新的测试类,这里,我们取名FalseSharing.java,代码如下:

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
package cc.databus;

import org.openjdk.jmh.annotations.*;

public class FalseSharing {

@State(Scope.Group)
public static class StateBaseLine {
int readOnly;
int writeOnly;
}

@Benchmark
@Warmup(iterations = 2, time = 1)
@Group("onlyReader")
@Fork(2)
public int onlyReader(StateBaseLine s) {
return s.readOnly;
}

@Benchmark
@Warmup(iterations = 2, time = 1)
@Group("onlyWriter")
@Fork(2)
public int onlyWriter(StateBaseLine s) {
return ++s.writeOnly;
}

@Benchmark
@Warmup(iterations = 2, time = 1)
@Group("baseline")
@Fork(2)
public int reader(StateBaseLine s) {
// 只读操作
return s.readOnly;
}

@Benchmark
@Group("baseline")
@Warmup(iterations = 2, time = 1)
@Fork(2)
public int writer(StateBaseLine s) {
// 只写
return ++s.writeOnly;
}


@State(Scope.Group)
public static class PaddedStateBaseLine {
int a1, a2, a3, a4, a5, a6, a7, a8;
@sun.misc.Contended("group1")
int readOnly;
int b1, b2, b3, b4, b5, b6, b7, b8;
@sun.misc.Contended("group2")
int writeOnly;
}

@Benchmark
@Warmup(iterations = 2, time = 1)
@Group("padded")
@Fork(2)
public int reader(PaddedStateBaseLine s) {
// 只读操作
return s.readOnly;
}

@Benchmark
@Group("padded")
@Warmup(iterations = 2, time = 1)
@Fork(2)
public int writer(PaddedStateBaseLine s) {
// 只写
return ++s.writeOnly;
}

@State(Scope.Group)
public static class ContendedStateBaseLine {
@sun.misc.Contended("group1")
int readOnly;
@sun.misc.Contended("group2")
int writeOnly;
}


@Benchmark
@Warmup(iterations = 2, time = 1)
@Group("contended")
@Fork(2)
public int reader(ContendedStateBaseLine s) {
// 只读操作
return s.readOnly;
}

@Benchmark
@Group("contended")
@Warmup(iterations = 2, time = 1)
@Fork(2)
public int writer(ContendedStateBaseLine s) {
// 只写
return ++s.writeOnly;
}
}

StateBaseLine是基准类,里面两个变量。用例”onlyReader”和”onlyWriter”分别测试只有一个线程读和只有一个线程写的情况下的吞吐率。用例”baseline”测试两个线程并发,一个只读,一个只写情况下的读写吞吐量。

PaddedStateBaseLine是针对PaddedStateBaseLine利用缓存行填充技术优化过的测试类,并使用Case “padded”来一个线程并发读另一个线程并发写下的读写吞吐量。

ContendedStateBaseLine是针对PaddedStateBaseLine利用`@sun.misc.Contended注解优化后的测试类。@sun.misc.Contended是JDK8引入的一个注解,用来配置缓存行,要激活该注解,需要在运行时加上-XX:-RestrictContended`。

打包运行
用mvn打包,mvn clean package,然后再target目录下运行:

1
java -XX:-RestrictContended -jar benchmarks.jar

等一段时间后,结果汇总如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on
why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial
experiments, perform baseline and negative tests that provide experimental control, make sure
the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts.
Do not assume the numbers tell you what you want them to tell.

Benchmark Mode Cnt Score Error Units
FalseSharing.baseline thrpt 10 211079347.163 ± 12979874.593 ops/s
FalseSharing.baseline:reader thrpt 10 92321173.374 ± 3526938.376 ops/s
FalseSharing.baseline:writer thrpt 10 118758173.789 ± 13578361.094 ops/s
FalseSharing.contended thrpt 10 811290127.902 ± 61100161.388 ops/s
FalseSharing.contended:reader thrpt 10 423756427.724 ± 32221584.599 ops/s
FalseSharing.contended:writer thrpt 10 387533700.179 ± 28902009.709 ops/s
FalseSharing.onlyReader thrpt 10 465359309.521 ± 15972954.246 ops/s
FalseSharing.onlyWriter thrpt 10 425846494.818 ± 11523619.968 ops/s
FalseSharing.padded thrpt 10 816262551.151 ± 76219231.454 ops/s
FalseSharing.padded:reader thrpt 10 426366243.949 ± 40813471.320 ops/s
FalseSharing.padded:writer thrpt 10 389896307.202 ± 35422709.014 ops/s

可以看到,在只有一个线程读的情况下,读吞吐量大概在465359309.521(FalseSharing.onlyReader),在只有一个线程写的情况下,写的吞吐量在425846494.818(FalseSharing.onlyWriter)。

而两个线程并发读写的情况下,读的吞吐量降到了92321173.374(FalseSharing.baseline:reader),下降了一个数量级,写的吞吐量降到了118758173.789(FalseSharing.baseline:writer)。

而分别使用缓存行填充和Contented注解优化后的并发读写的吞吐量都得到了大幅度的提升。

总结

缓存行伪共享问题大部分时候我们并不需要关心,但是在追求高吞吐量的情况下,则需要注意是否存在伪共享的问题。一个典型的例子是dsruptor这个高并发框架,为了提高吞吐量,就利用了缓存行padding这个技术。

需要注意的是,如果你选择使用Contended注解来解决缓存行问题,需要在启动参数中加上-XX:-RestrictContended标签,否则优化是不生效的。

Redis的事务 Redis持久化策略

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×