并发编程-伪共享
在学习什么是伪共享的前提下,我们先来了解一下计算机系统中的一些知识点
-
CPU Cache(CPU 缓存)
为了解决计算机系统中主内存和CPU之间运行速度的差距问题,在CPU和主内存之间添加了一级或者多级高速缓冲存储器(Cache),目前主流的大多数CPU都带有三级缓存(L1/L2/L3)。它们被集成到CPU内部,简称CPU Cache。(我个人的理解:就是我只和那些和我差不多优秀的人在一起玩,CPU和L1进行数据交互,L1和L2数据交互,L3和主内存数据交互,这样就能减少由于CPU和主内存的巨大运行速率落差带来的性能影响)
存储器存储的空间大小:主内存>L3>L2>L1>寄存器
存储器速度快慢排序:寄存器>L1>L2>L3>主内存
-
Cache Line(缓存行)
CPU Cache中的数据是按行存储的,每一行为一个Cache Line(缓存行)。它是CPU Cache和主内存进行数据交换通信的单位,Cache Line 的大小一般为2的幂次数字节。目前主流的CPU的Cache Line都是64Bytes。
-
CPU读取存储器数据的过程(存储器包含寄存器/L1/L2/L3/主内存)
- CPU读取寄存器中X的值(假设寄存器中存在X),只需要一步,直接读取即可。
- CPU读取L1 Cache的某个值,需要1-3步(可能需要更多步),把cache line锁住,把某个数据读取,然后解锁,如果没锁住就等待。
- CPU读取L2 Cache的某个值,先会到L1 Cache中获取,L1中不存在,如果数据在L2中,L2加锁,把L2中的数据复制到L1中,再执行读取L1中复制的数据,然后再解锁。
- CPU读取L3 Cache也是和L2的一样,只不过先从L3复制到L2,从L2复制到L1,从L1到CPU。
- CPU读取主内存:通知内存控制器占用bus总线带宽,通知内存加锁,发起内存读请求,内存响应数据给L3,再从L2->L1,再从L1->CPU,然后解锁总线锁定,解锁内存。
什么是伪共享?
CPU访问X变量,先从Cache中获取,如果有直接返回,否则从主内存中获取,然后把X所在内存区域的一个缓存行大小的内存复制到Cache中(根据空间局部性原理)。但是由于cache line中的数据可能有多个变量值。当多个线程同时修改同一cache line中的多个变量时,由于只有一个线程能操作cache line,其他线程会等待,这就造成了性能的下降,这就是伪共享。
如上图:变量x,y在同一个cache line中,被同时放到了CPU的L1、L2、L3中,thread1对变量x进行更新时,会先修改CPU1的L1中的变量x所在的cache line,在缓存一致性协议(MESI协议)下,CPU2中变量x对应的cache line会失效,会破坏CPU2中的缓存,CPU2发出读取变量x的指令并且通知CPU1将修改后的数据同步到主内存,然后CPU2再去同步新的变量x的值。
伪共享导致性能下降的案例
public class Test {
static final int LINE_NUM = 1024 * (1 << 4);
static final int COLUMN_NAM = 1024 * (1 << 4);
public static void main(String[] args) {
// 先从左到右,再从上到下插入值
testOne();
// 先从上到下,再从左到右插入值
testTwo();
}
public static void testOne() {
long[][] arr = new long[LINE_NUM][COLUMN_NAM];
long startTime = System.currentTimeMillis();
for (int i = 0; i < LINE_NUM; i++) {
for (int j = 0; j < COLUMN_NAM; j++) {
arr[i][j] = i * 2 + j;
}
}
long endTime = System.currentTimeMillis();
System.out.println("testOne cost time: " + (endTime - startTime));
}
public static void testTwo() {
long[][] arr = new long[LINE_NUM][COLUMN_NAM];
long startTime = System.currentTimeMillis();
for (int i = 0; i < LINE_NUM; i++) {
for (int j = 0; j < COLUMN_NAM; j++) {
arr[j][i] = i * 2 + j;
}
}
long endTime = System.currentTimeMillis();
System.out.println("testTwo cost time: " + (endTime - startTime));
}
}
控制台输出如下:
testOne cost time: 306
testTwo cost time: 6517
分析前提:地址连续的多个变量才有可能会被放到一个cache line中。
分析:testOne比testTwo方法执行的要快,这是因为testOne方法中数组元素的内存地址是连续的,当访问数组第一个元素时,会把该元素后的若干元素一块放到cache line中,这样顺序访问数组元素会直接在cache中直接命中,因此不再会从主内存中读取。而testTwo方法中是跳跃式访问数组元素的,不是顺序的,破坏了程序的空间局部性原则。因为cache是有容量大小的,cache中缓存的数据会被不断刷新替换掉成新的数据,存在可能还没被读取就被替换掉了的可能。
如何避免伪共享?
-
JDK8以前:
-
字节填充:在创建一个变量时使用字节填充该变量所在的cache line,这样就能避免多个变量放在同一cache line导致的缓存失效问题。
public class FilledLong { public volatile long value = 0L; public long l1,l2,l3,l4,l5,l6; }
-
通常cache line为64字节,我们知道java中long类型为8个字节,在我们定义的long类型value变量后,填充6个long类型的变量,再加上创建对象的对象头所占用的8字节,所以创建一个FilledLong对象实际会占用64字节,刚好可以放入一个cache line中,就可以解决伪共享问题。
-
JDK8之后:
-
sun.misc.Contended注解:可以解决伪共享
@Contended public class FilledLong { public volatile long value = 0L; }
该注解也可以用来修饰变量,在Thread类中就有体现:
这些变量是在使用ThreadLocalRandom时体现的,这里不再过多描述。
// The following three initially uninitialized fields are exclusively // managed by class java.util.concurrent.ThreadLocalRandom. These // fields are used to build the high-performance PRNGs in the // concurrent code, and we can not risk accidental false sharing. // Hence, the fields are isolated with @Contended. /** The current seed for a ThreadLocalRandom */ @sun.misc.Contended("tlr") long threadLocalRandomSeed; /** Probe hash value; nonzero if threadLocalRandomSeed initialized */ @sun.misc.Contended("tlr") int threadLocalRandomProbe; /** Secondary seed isolated from public ThreadLocalRandom sequence */ @sun.misc.Contended("tlr") int threadLocalRandomSecondarySeed;
**注意点:**默认是情况下,@Contended注解只能用于Java核心类,比如rt包下的类。
如果在用户类路径下使用该注解,需要添加JVM参数:-XX:-RestrictContended。默认填充的宽度:128,要自定义宽度可以设置:-XX:ContendedPaddingWidth参数
-