引入
在JMH的例子中,JMHSample_37_CacheAccess这个例子很有意思,测试一个二维数组通过row
、col
两种不同的方式遍历数据的平均时间。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
3Benchmark 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 Cache
。CPU 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
4public 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
7abstract 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
52public 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
}
}
测试结果:
- 不填充,不加
@Contended
注解:64473661814
- 使用填充:
7221347389
- 加
@Contended
注解:7034235497
通过测试结果,可以看出伪共享对新能的影响有多大。
@Contended
@Contended
注解可以加在类或者字段上,会增加目标实例大小,使目标占满整个cache line
。默认情况,只会对JDK内部的类生效。如果想要对自己的代码生效,需要在jvm启动时增加-XX:-RestrictContended
参数。
当@Contended
加在字段上时,还可以增加group
参数,这样,相同group
的字段会在内存上连续分配。当我们每一次操作都会更新对象的多个属性时很有用,会提高效率。但要谨慎使用。1
2
3
4
5
6public class VolatileLong {
@Contended("group0")
public volatile long value1 = 0L;
@Contended("group0")
public volatile long value2 = 0L;
}
相关工具
- 查看对象内存布局
1
java -cp a.jar -XX:+PrintFieldLayout obj
该命令只在debug版本的jvm中生效
- 查看对象大小Classmexer