阅读 97

Java 伪共享的原理深度解析以及避免方法

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

本文介绍了伪共享的产生以及Java中如何避免伪共享,并配以案例演示!

缓存系统中的缓存是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,因为都会导致同一个缓存行失效而会无意中影响彼此的性能,这就是伪共享(false sharing)。由于从代码中很难看出是否会出现伪共享,有人将其描述成无声的性能杀手。

1 CPU Cache

众所周知,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 又可以存在多个硬件线程:

在这里插入图片描述

2 Cache Line

计算机缓存系统中是以缓存行(cache line)为单位存储的,Cache Line 是 CPU 和主存之间数据传输的最小单位,缓存行通常是 64 字节。

当一行 Cache Line 被从内存拷贝到 Cache 里,Cache 里会为这个 Cache Line 创建一个条目。这个 Cache 条目里既包含了拷贝的内存数据,即 Cache Line,又包含了这行数据在内存里的位置等元数据信息。

当CPU 访问某个变量时,首先会去看CPU Cache 内是否有该变量,如果有则直接从中获取,否则就去主内存里面获取该变量,然后把该变量所在内存区域的一个Cache 行大小的内存复制到Cache 中。由于存放到Cache 行的是内存块而不是单个变量,所以可能会把多个连续的变量存放到一个Cache 行中。

由此,所以通常情况下访问连续存储的数据会比随机访问要快,访问数组结构通常比链结构快,因为通常数组在内存中是连续分配的。注意:一些JVM实现中大数组不是分配在连续空间的。

3 伪共享

在这里插入图片描述

如上图,变量x 和y 同时被放到了CPU 的L1、L1、L3,一个core一次只能运行一条线程。

当线程l使用core1对变量x 进行更新时,首先会修改core1的一级缓存变量x 所在的缓存行,这时候在缓存一致性协议(MESI)下,core2中变量x对应的缓存行失效。那么线程2 在写入变量y 时就只能去二级缓存里查找,这就破坏了一级缓存。而一级缓存比二级缓存更快,这也说明了多个线程不可能同时去修改自己所使用的CPU 中相同缓存行里面的变量。更坏的情况是,如果CPU 只有一级缓存,则会导致频繁地访问主内存。

因为缓存与内存交换数据的单位就是缓存行,这就造成了造成多个变量被放入了一个缓存行中,为了保证缓存数据一致性,根据MESI协议,会使其他core相同缓存行数据过期,如果多个线程同时去写入缓存行中不同的变量,虽然明面上不同线程读写不同的数据,但是由于数据在同一缓存行上,造成缓存频繁失效,就会无意中影响彼此的性能,这就是伪共享。由于从代码中很难看出是否会出现伪共享,有人将其描述成无声的性能杀手。

4 避免伪共享

为了避免由于 false sharing 导致 Cache Line 从 L1,L2,L3 到主存之间重复载入,我们可以使用数据填充追加字节的方式来避免,即单个数据填充满一个CacheLine。

该方法本质上是一种空间换时间的做法。

4.1 传统方法

在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字节的方式来填满快速缓冲区的缓存行。避免头结点和尾节点载入到同一个缓存行,使得头尾节点在改动时不会互相锁定。

4.2 注解

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参数。

5 案例

运行下面的案例,可以看见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;
    }
}

复制代码

参考资料:

  1. 《深入理解计算机系统》
  2. CPU高速缓存那些事儿
  3. Cache Line 伪共享发现与优化
  4. CPU缓存一致性协议MESI

如果有什么不懂或者需要交流,可以留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!

文章分类
后端
文章标签