我们知道为解决CPU与内存速度不一致问题,会在它俩中间添加一级或多级高速缓冲存储器(Cache),在Cache内部是按行存储的,每一行成为一个Cache行。Cache行是Cache与主内存进行数据交换的单位,大小一般为2的幂次数字节。
当CPU访问某个变量的时候,首先会看CPU Cache内是否有该变量,如果有则直接取走,否则去主内存里获取该变量,并把该变量所在内存区域的一个Cache行大小的内存复制到Cache中。因为一下子是把一个Cache行大小的内存块放进去,所以可能会导致把多个变量存放到一个Cache行中。当多个线程同时修改一个缓存行里面的多个变量时,由于同时只能有一个线程操作缓存行,所以相比将每个变量放到一个缓存行,性能会有所下降,这就是伪共享。可以看一下一个例子,如图所示:
该图中,主内存的一个Cache行块大小的内存被分别放到了两个CPU各自的缓存中,如果此时CPU0中的Thread0在修改其中的红色变量,则需要改变此变量所在的Cache Line,那么根据缓存一致性协议,CPU1中的Cache Line就会失效。若Thread1要修改蓝色变量,则需要重新从主内存中读取这块内存。这也说明了多个线程不可能同时去修改自己所使用的CPU中相同缓存行里面的变量,且可能会导致频繁的去访问主内存。
1.伪共享出现的原因
通过上面的讲述其实也能明白为什么会出现这种问题,因为多个变量被放入了一个缓存行中,并且多个线程同时去写入缓存行中不同的变量。由于缓存与内存交换数据的单位是缓存行,当访问一个变量时,会把这个变量所在内存中大小为缓存行的内存放入缓存行,所以多个变量会放在一个缓存行中。举一个例子:
long a;
long b;
long c;
long d;
声明了四个long变量,假设缓存行大小为32字节,当CPU访问变量a时,发现它没有在缓存中,就会去主内存中把变量a以及内存地址附近的b、c、d放入缓存行。所以地址连续的多个变量才有可能会被放到一个缓存行中。当创建数组时,数组里的多个元素就会被放入同一个缓存行中。
当然如果只是单线程的话,那么多个变量放入一个一个缓存行中队代码执行是有利的,代码会执行更快,看一下下面的代码:
public class TestForContent {
static final int LINE_NUM = 1024;
static final int COLM_NUM = 1024;
public static void cache() {
long[][] array = new long[LINE_NUM][COLM_NUM];
long startTime = System.currentTimeMillis();
//数组是按行存储的,所以接近顺序取数据
for (int i = 0; i < LINE_NUM; ++i) {
for (int j = 0; j < COLM_NUM; ++j) {
array[i][j] = i*2 + j;
}
}
long endTime = System.currentTimeMillis();
long cacheTime = endTime - startTime;
System.out.println("cache time:" + cacheTime);
}
public static void nocache() {
long[][] array = new long[LINE_NUM][COLM_NUM];
long startTime = System.currentTimeMillis();
//数组是按行存储,所以不是顺序取数据
for (int i = 0; i < COLM_NUM; ++i) {
for (int j = 0; j < LINE_NUM; ++j) {
array[i][j] = i*2 + j;
}
}
long endTime = System.currentTimeMillis();
System.out.println("no cache time:" + (endTime - startTime));
}
public static void main(String[] args) {
cache();
nocache();
}
}
cache()函数基本接近于顺序取数据,而nocache()则是跳跃式访问数组元素,可能执行过程中会有多次淘汰cache的情况出现,所以慢一些。所以在单个线程下修改一个缓存行中的多个变量,会充分利用程序运行的局部性原则,加速程序运行。而在多线程下并发修改一个缓存行中的多个变量时就会竞争缓存行,从而降低程序运行性能。
2. 如何避免伪共享
JDK8之前一般是通过字节填充的方式避免该问题,==也就是创建一个变量时使用填充字段填充该变量所在的缓存行,这样就避免了将多个变量存放在同一个缓存行中,如下:
public final static class FilledLong {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6;
}
假如缓存行是64字节,那在FilledLong类里填充了6个long类型的变量,每个long变量占8字节,加上value一共56字节,然后FilledLong是一个类对象,类对象的字节码的对象头占用8字节,所以一个FilledLong对象实际会占用64字节的内存,正好可以放入一个缓存行。
当然JDK6中提供了更方便的工具:sun.misc.Contended注解用来解决伪共享问题。如下代码:
@sun.misc.Contended
public final static class FilledLong {
public volatile long value = 0L;
}
这里使用注解修饰的类,当然也可以修饰变量。但要注意,默认情况下@Contended注解只用于Java核心类,比如rt包下的类。如果要使用这个注解,则需要添加JVM参数:-XX:-RestrictContended。填充的宽度默认为128,要自定义宽度可以设置=XX:ContendedPaddingWidth参数。