伪共享

引入

在JMH的例子中,JMHSample_37_CacheAccess这个例子很有意思,测试一个二维数组通过rowcol两种不同的方式遍历数据的平均时间。

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

/*
* This sample serves as a warning against subtle differences in cache access patterns.
*
* Many performance differences may be explained by the way tests are accessing memory.
* In the example below, we walk the matrix either row-first, or col-first:
*/

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

如上述代码所示,两种遍历的次数相同,差异是一个按行读取,一个按列读取。那么,最终两种方式的性能会有差异吗?

让我们来看一下测试结果:

1
2
3
Benchmark                          Mode  Cnt   Score   Error  Units
JMHSample_37_CacheAccess.colFirst avgt 25 15.535 ± 0.106 ns/op
JMHSample_37_CacheAccess.rowFirst avgt 25 5.011 ± 0.061 ns/op

通过结果可以得出结论:按行访问的效率远远高于按列访问。为什么会这样呢,先不忙回答这个问题,看完这篇文章你应该就可以得出答案了。

缓存行 cache line

当前CPU频率的不断提升,内存的访问速度却并没有什么突破。传统CPU 直连内存的方式会因为等待内存响应而降低效率。所以为减少处理器访问内存所需平均时间,就出现了CPU高速缓存CPU CacheCPU Cache位于金字塔式存储体系中自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。

当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。

缓存是由缓存行组成的,CPU在操作缓存时是以缓存行为单位的。一个缓存行的大小通常是64 字节,通过指令才可以查看缓存行大小:

1
cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size

long[] a数组为例,因为数组是连续内存,所以当a[0]被加载到缓存后,则a[1]~a[7]也同时被加载到了缓存。如果这时要访问a[1],cpu就可以直接从缓存中获取,无需加载。看到这里,是不是上边那个问题已经有了一半的答案了呢。为什么是一半呢,因为另一半的答案在于java内存中二维数组的结构。引用JMHSample_37_CacheAccess中的说明:

Notably, colFirst accesses are much slower, and that’s not a surprise: Java’s multidimensional
arrays are actually rigged, being one-dimensional arrays of one-dimensional arrays. Therefore,
pulling n-th element from each of the inner array induces more cache misses, when matrix is large.

伪共享

在单线程下遍历数组,cache line 给我们带来了更高的效率。但多线程情况下,这种好处可能就变成了坏处。想象一下,多个线程同时修改的对象位于同一个cache line,其中一个线程修改了对象,导致其它线程对应的cache line会失效,需要重新加载,效率降低。这种情况就被称为伪共享。多线程竞争激烈的情况下,伪共享会大大影响性能。

那怎么能避免伪共享呢,其实想想伪共享产生的原理,答案就呼之欲出了:只要确保一个cache line只有一个对象。

避免伪共享

怎么才能确保一个cache line只有一个对象来避免伪共享呢。继续以long[] a数组为例,一个long占8个字节,它的长度是固定的,我们不可能把它的长度变成64。但我们可以把long[]替换成另外一个对象数组,对象提供long属性,同时整个对象占64或者以上字节。这样就能满足我们的需求了。

1
2
3
4
public class PaddingLong{
public long value = ;
public long p1, p2, p3, p4, p5, p6; // 填充数据
}

7个long+对象头长度刚好大于64。

江湖传说,在 Java7 中,上述的代码public long p1, p2, p3, p4, p5, p6; 会被认为是无效代码而被优化掉,这时候可以使用继承的方法。

1
2
3
4
5
6
7
abstract class AbstractPaddingObject{
protected long p1, p2, p3, p4, p5, p6;// 填充数据
}

public class PaddingObject extends AbstractPaddingObject{
public volatile long value = 0L; // 实际数据
}

但在JAVA8环境下测试,发现避免伪共享有效,说明public long p1, p2, p3, p4, p5, p6;并没有被优化掉。不过,在java8环境下,我们也无需用上述两种方式填充,更简单的方式是使用@Contended

1
2
3
4
@Contended
public final static class VolatileLong {
public volatile long value = 0L;
}

完整实例

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
public final class FalseSharing implements Runnable {
public final static int NUM_THREADS = 4;
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;

private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];

static {
for (int i = 0; i < longs.length; i++) {
longs[i] = new VolatileLong();
}
}

public FalseSharing(final int arrayIndex) {
this.arrayIndex = arrayIndex;
}

public static void main(final String[] args) throws Exception {
final long start = System.nanoTime();
runTest();
System.out.println("duration = " + (System.nanoTime() - start));
}

private static void runTest() throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];

for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new FalseSharing(i));
}

for (Thread t : threads) {
t.start();
}

for (Thread t : threads) {
t.join();
}
}

public void run() {
long i = ITERATIONS + 1;
while (0 != --i) {
longs[arrayIndex].value = i;
}
}

@Contended
public final static class VolatileLong {
public volatile long value = 0L;
// public long p1, p2, p3, p4, p5, p6; // comment out
}
}

测试结果:

  1. 不填充,不加@Contended注解:64473661814
  2. 使用填充:7221347389
  3. @Contended注解:7034235497

通过测试结果,可以看出伪共享对新能的影响有多大。

@Contended

@Contended 注解可以加在类或者字段上,会增加目标实例大小,使目标占满整个cache line。默认情况,只会对JDK内部的类生效。如果想要对自己的代码生效,需要在jvm启动时增加-XX:-RestrictContended参数。

@Contended加在字段上时,还可以增加group参数,这样,相同group的字段会在内存上连续分配。当我们每一次操作都会更新对象的多个属性时很有用,会提高效率。但要谨慎使用。

1
2
3
4
5
6
public class VolatileLong {
@Contended("group0")
public volatile long value1 = 0L;
@Contended("group0")
public volatile long value2 = 0L;
}

相关工具

  1. 查看对象内存布局
    1
    java -cp a.jar -XX:+PrintFieldLayout obj

该命令只在debug版本的jvm中生效

  1. 查看对象大小Classmexer