缓存系统中是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。由于从代码中很难看出是否会出现伪共享,有人将其描述成无声的性能杀手。
本文介绍了伪共享的产生以及Java中如何避免伪共享,并配以案例演示!
众所周知,CPU的运算速度远远快于主内存、硬盘的运算速度。为了解决计算机系统中主内存与CPU 之间运行速度差问题,避免直接访问内存或者磁盘,降低因为速度差对CPU整体吞吐量的影响,一般会在CPU与主内存之间添加一级或者多级高速缓冲结构( Cache )。这个Cache 一般是被集成到CPU 内部的,所以也叫CPU Cache。
按照数据读取顺序和与 CPU 结合的紧密程度,CPU 缓存可以分为一级缓存L1,二级缓存L2,部分高端 CPU 还具有三级缓存L3。每一级缓存中所储存的全部数据都是下一级缓存的一部分,越靠近 CPU 的缓存越快也越小。L1和L2只能被一个单独的 CPU 核使用,L3被CPU 核共享,主存由所有 CPU 核共享
当 CPU 执行运算的时候,它先去 L1 查找所需的数据,再去 L2,然后是 L3,最后如果这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以如果你在做一些很频繁的事,你要确保数据在 L1 缓存中。
引用《深入理解计算机系统》书中关于CPU缓存和内存、硬盘存储器的层次结构:
现代多核架构,一个物理 CPU 可以存在多个物理 Core,而每个 Core 又可以存在多个硬件线程:
计算机缓存系统中是以缓存行(cache line)为单位存储的,Cache Line 是 CPU 和主存之间数据传输的最小单位,缓存行通常是 64 字节。
当一行 Cache Line 被从内存拷贝到 Cache 里,Cache 里会为这个 Cache Line 创建一个条目。这个 Cache 条目里既包含了拷贝的内存数据,即 Cache Line,又包含了这行数据在内存里的位置等元数据信息。
当CPU 访问某个变量时,首先会去看CPU Cache 内是否有该变量,如果有则直接从中获取,否则就去主内存里面获取该变量,然后把该变量所在内存区域的一个Cache 行大小的内存复制到Cache 中。由于存放到Cache 行的是内存块而不是单个变量,所以可能会把多个连续的变量存放到一个Cache 行中。
由此,所以通常情况下访问连续存储的数据会比随机访问要快,访问数组结构通常比链结构快,因为通常数组在内存中是连续分配的。注意:一些JVM实现中大数组不是分配在连续空间的。
如上图,变量x 和y 同时被放到了CPU 的L1、L1、L3,一个core一次只能运行一条线程。
当线程l使用core1对变量x 进行更新时,首先会修改core1的一级缓存变量x 所在的缓存行,这时候在缓存一致性协议(MESI)下,core2中变量x对应的缓存行失效。那么线程2 在写入变量y 时就只能去二级缓存里查找,这就破坏了一级缓存。而一级缓存比二级缓存更快,这也说明了多个线程不可能同时去修改自己所使用的CPU 中相同缓存行里面的变量。更坏的情况是,如果CPU 只有一级缓存,则会导致频繁地访问主内存。
因为缓存与内存交换数据的单位就是缓存行,这就造成了造成多个变量被放入了一个缓存行中,为了保证缓存数据一致性,根据MESI协议,会使其他core相同缓存行数据过期,如果多个线程同时去写入缓存行中不同的变量,虽然明面上不同线程读写不同的数据,但是由于数据在同一缓存行上,造成缓存频繁失效,就会无意中影响彼此的性能,这就是伪共享。由于从代码中很难看出是否会出现伪共享,有人将其描述成无声的性能杀手。
为了避免由于 false sharing 导致 Cache Line 从 L1,L2,L3 到主存之间重复载入,我们可以使用数据填充追加字节的方式来避免,即单个数据填充满一个CacheLine。这本质是一种空间换时间的做法。
在JDK 8 之前一般都是通过代码手动字节填充的方式来避免该问题,也就是创建一个变量时使用填充字段填充该变量所在的缓存行,这样就避免了将多个变量存放在同一个缓存行中。Doug Lea的jsr166中早期的LinkedTransferQueue。就是使用了追加字节填充来解决伪共享,另外在早起ConcurrentHashMap、以及无锁并发框架Disruptor中均使用这种技术。
早期的LinkedTransferQueue中的部分源码如下:
static final class PaddedAtomicReference<T> extends AtomicReference<T> {
// 追加15个对象引用,一个对象引用占据4个字节
Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
PaddedAtomicReference(T r) {
super(r);
}
}
//父类
public class AtomicReference<V> implements java.io.Serializable {
private volatile V value;
public AtomicReference(V initialValue) {
value = initialValue;
}
}
这个内部类PaddedAtomicReference相对于父类AtomicReference仅仅做了一件事情,就将共享变量追加到64字节,其中一个对象的引用占4个字节。它追加了15个变量共占60个字节,再加上父类的Value变量,一共64个字节(这其中涉及的对象内存布局:Java中的对象内存布局、压缩指针、对象大小计算以及对象访问定位的详解)。
Doug lea使用追加到64字节的方式来填满快速缓冲区的缓存行。避免头接点和尾节点载入到同一个缓存行,使得头尾节点在改动时不会互相锁定。
JDK 8开始,提供了一个sun.misc.Contended 注解,用来解决伪共享问题,加上这个注解的类会自动补齐缓存行。jdk8中已经使用sun.misc.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;
Thread 类里面这三个变量默认被初始化为0 ,这三个变量会在ThreadLocalRandom 类中使用。在默认情况下,@Contended 注解只用于Java 核心类, 比如此包下的类。如果用户类路径下的类需要使用这个注解, 则需要添加JVM参数:-XX:-RestrictContended。填充的宽度默认为128,要自定义宽度则可以设置-XX: ContendPaddingWidth参数。
运行下面的案例,可以看见VolatileLong2、VolatileLong3比VolatileLong更块,实际上VolatileLong2的填充变量p0、p1……,多或者少一两个这样的变量对程序影响都很小,我们的目的是让不同的VolatileLong对象处于不同的缓存行,就可以避免伪共享了,多或者少几个字节影响不大。
public class FalseSharing implements Runnable {
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final static int ARRAY_INDEX = 5;
private final static VolatileLong3[] LONGS = new VolatileLong3[ARRAY_INDEX];
public static void main(final String[] args) {
//初始化变量数组
for (int i = 0; i < LONGS.length; i++) {
LONGS[i] = new VolatileLong3();
}
//使用线程池
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 5, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());
long start = System.nanoTime();
for (int i = 0; i < ARRAY_INDEX; i++) {
int finalI = i;
threadPoolExecutor.submit(() -> {
long i1 = ITERATIONS + 1;
while (0 != --i1) {
//一个线程,不断地更改指定索引处的值
LONGS[finalI].value = i1;
}
});
}
//判断线程池任务是否完毕
do {
threadPoolExecutor.shutdown();
} while (!threadPoolExecutor.isTerminated());
System.out.println("duration = " + (System.nanoTime() - start));
}
@Override
public void run() {
long i = ITERATIONS + 1;
while (0 != --i) {
//不断地更改索引处的值
LONGS[ARRAY_INDEX].value = i;
}
}
//没有填充字节
public final static class VolatileLong {
public volatile long value = 0L;
}
//传统方法
public final static class VolatileLong2 {
//以及基本类型的long占用8个字节
volatile long p0, p1, p2, p3, p4, p5, p6;
public volatile long value = 0L;
}
//jdk1.8新方法
//添加vm options参数 -XX:-RestrictContended
@sun.misc.Contended
public final static class VolatileLong3 {
public volatile long value = 0L;
}
}
《深入理解计算机系统》
CPU高速缓存那些事儿
Cache Line 伪共享发现与优化
CPU缓存一致性协议MESI