目录
缓存系统中是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。
一、计算机的基本结构
下图是计算的基本结构。L1、L2、L3分别表示一级缓存、二级缓存、三级缓存,越靠近CPU的缓存,速度越快,容量也越小。所以L1缓存很小但很快,并且紧靠着在使用它的CPU内核;L2大一些,也慢一些,并且仍然只能被一个单独的CPU核使用;L3更大、更慢,并且被单个插槽上的所有CPU核共享;最后是主存,由全部插槽上的所有CPU核共享。
当CPU执行运算的时候,它先去L1查找所需的数据、再去L2、然后是L3,如果最后这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以如果你在做一些很频繁的事,你要尽量确保数据在L1缓存中。另外,线程之间共享一份数据的时候,需要一个线程把数据写回主存,而另一个线程访问主存中相应的数据。
下面是从CPU访问不同层级数据的时间概念:
从CPU到 | 大约需要的CPU周期 | 大约需要的时间 |
---|---|---|
主存 | 约60-80ns | |
QPI 总线传输(between sockets, not drawn) | 约20ns | |
L3 cache | 约40-45 cycles | 约15ns |
L2 cache | 约10 cycles | 约3ns |
L1 cache | 约3-4 cycles | 约1ns |
寄存器 | 1cycle |
二、缓存行
Cache是由很多个cache line组成的。每个cache line通常是64字节,并且它有效地引用主内存中的一块儿地址。一个Java的long类型变量是8字节,因此在一个缓存行中可以存8个long类型的变量。
CPU每次从主存中拉取数据时,会把相邻的数据也存入同一个cache line。
在访问一个long数组的时候,如果数组中的一个值被加载到缓存中,它会自动加载另外7个。因此能非常快的遍历这个数组。事实上,可以非常快速的遍历在连续内存块中分配的任意数据结构。
示例:
package com.thread.falsesharing;
/**
* @Author: 98050
* @Time: 2018-12-19 23:25
* @Feature: cache line特性
*/
public class CacheLineEffect {
private static long[][] result;
public static void main(String[] args) {
int row =1024 * 1024;
int col = 8;
result = new long[row][];
for (int i = 0; i < row; i++) {
result[i] = new long[col];
for (int j = 0; j < col; j++) {
result[i][j] = i+j;
}
}
long start = System.currentTimeMillis();
for (int i = 0; i < row; i++) {
for (int j = 0; j < col; j++) {
result[i][j] = 0;
}
}
System.out.println("使用cache line特性,循环时间:" + (System.currentTimeMillis() - start));
long start2 = System.currentTimeMillis();
for (int i = 0; i < col; i++) {
for (int j = 0; j < row; j++) {
result[j][i] = 1;
}
}
System.out.println("没有使用cache line特性,循环时间:" + (System.currentTimeMillis() - start2));
}
}
结果:
三、伪共享
如上图变量x,y同时被放到了CPU的一级和二级缓存,当线程1使用CPU1对变量x进行更新时候,首先会修改cpu1的一级缓存变量x所在缓存行,这时候缓存一致性协议会导致cpu2中变量x对应的缓存行失效,那么线程2写入变量x的时候就只能去二级缓存去查找,这就破坏了一级缓存,而一级缓存比二级缓存更快。更坏的情况下如果cpu只有一级缓存,那么会导致频繁的直接访问主内存。
示例:
package com.thread.falsesharing;
/**
* @Author: 98050
* @Time: 2018-12-20 12:06
* @Feature: 伪共享
*/
public class FalseSharing implements Runnable {
/**
* 线程数
*/
public static int NUM_THREADS = 4;
/**
* 迭代次数
*/
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
private static VolatileLong[] longs;
public static long SUM_TIME = 0L;
public FalseSharing(final int arrayIndex) {
this.arrayIndex = arrayIndex;
}
public void run() {
long i = ITERATIONS + 1;
while (0 != --i){
longs[arrayIndex].value = i;
}
}
public final static class VolatileLong {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6; //缓存行填充
}
private static void runTest() throws InterruptedException {
Thread[] thread = new Thread[NUM_THREADS];
for (int i = 0; i < thread.length; i++) {
thread[i] = new Thread(new FalseSharing(i));
}
for (Thread t : thread){
t.start();
}
for (Thread t : thread){
t.join();
}
}
public static void main(String[] args) throws InterruptedException {
Thread.sleep(10000);
for (int i = 0; i < 10; i++) {
System.out.println(i);
if (args.length == 1){
NUM_THREADS = Integer.parseInt(args[0]);
}
longs = new VolatileLong[NUM_THREADS];
for (int j = 0; j < longs.length; j++) {
longs[j] = new VolatileLong();
}
final long start = System.nanoTime();
runTest();
final long end = System.nanoTime();
SUM_TIME += end - start;
}
System.out.println("平均耗时:" + SUM_TIME / 10);
}
}
四个线程修改一数组不同元素的内容。元素的类型是 VolatileLong,只有一个长整型成员 value 和 6 个没用到的长整型成员。value 设为 volatile 是为了让 value 的修改对所有线程都可见。程序分两种情况执行,第一种情况为不屏蔽缓存行填充,第二种情况为屏蔽缓存行填充。为了"保证"数据的相对可靠性,程序取 10 次执行的平均时间。执行情况如下:
两个逻辑一模一样的程序,前者的耗时大概是后者的 2倍。那么这个时候,我们再用伪共享(False Sharing)的理论来分析一下,前者 longs 数组的 4 个元素,由于 VolatileLong 只有 1 个长整型成员,所以一个数组单元就是16个字节(long数据类型8个字节+类对象的字节码的对象头8个字节),进而整个数组都将被加载至同一缓存行(16*4字节),但有4个线程同时操作这条缓存行,于是伪共享就悄悄地发生了。
伪共享在多核编程中很容易发生,而且非常隐蔽。例如, ArrayBlockingQueue 中有三个成员变量:
- takeIndex:需要被取走的元素下标
- putIndex:可被元素插入的位置的下标
- count:队列中元素的数量
这三个变量很容易放到一个缓存行中,但是之间修改没有太多的关联。所以每次修改,都会使之前缓存的数据失效,从而不能完全达到共享的效果。
如上图所示,当生产者线程put一个元素到ArrayBlockingQueue时,putIndex会修改,从而导致消费者线程的缓存中的缓存行无效,需要从主存中重新读取。线程越多,核越多,对性能产生的负面效果就越大。
四、如何避免伪共享
缓存行填充
一条缓存行有 64 字节,而 Java 程序的对象头固定占 8 字节(32位系统)或 12 字节( 64 位系统默认开启压缩, 不开压缩为 16 字节),所以只需要填 6 个无用的长整型补上6*8=48字节,让不同的 VolatileLong 对象处于不同的缓存行,就避免了伪共享( 64 位系统超过缓存行的 64 字节也无所谓,只要保证不同线程不操作同一缓存行就可以)。
Java8中已经提供了官方的解决方案,Java8中新增了一个注解:@sun.misc.Contended。加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置-XX:-RestrictContended才会生效。