关于java:一段代码两倍时差直击并发编程伪共享

36次阅读

共计 4949 个字符,预计需要花费 13 分钟才能阅读完成。

一、前言

【闲话开篇】:这段时间我的项目靠近序幕,我终于闲了一点,又拿起了新近未看完的书《JAVA 高并发程序设计》。看到其中介绍《无锁的缓存框架:Disruptor》时,接触到了一个概念——伪共享 (false sharing),说是会影响并发程序的执行性能,被很多人形容成无声的性能杀手,忽然感觉到了本人常识的匮乏,罪过啊。
原文解析

伪共享(false sharing),到底是怎么一回事呢?不急,咱们先倒杯水边喝边回顾,以前上学时丢下的计算机组成原理相干知识点。

二、概念解析

CPU 缓存(三级)

CPU 缓存(Cache Memory)是位于 CPU 与内存之间的长期存储器,它的容量比内存小很多,然而替换速度却比内存要快得多。CPU 和主内存之间有好几级缓存,CPU 缓存能够分为一级缓存,二级缓存,局部高端 CPU 还具备三级缓存。每一级缓存中所贮存的全副数据都是下一级缓存的一部分,越凑近 CPU 的缓存越快也越小。

高速缓存的呈现次要是为了解决 CPU 运算速度与内存读写速度不匹配的矛盾,因为 CPU 运算速度要比内存读写速度快很多,这样会使 CPU 破费很长时间期待数据到来或把数据写入内存。在缓存中的数据是内存中的一小部分,但这一小部分是短时间内 CPU 行将拜访的,当 CPU 调用大量数据时,就可避开内存间接从缓存中调用,从而放慢读取速度。

如果咱们的程序正在屡次对同一数据块做雷同的运算,那么在执行运算的时候把它加载到离 CPU 很近的缓存中就能大大的进步程序运行速度。

咱们以 L1、L2、L3 别离示意一级缓存、二级缓存、三级缓存,依照数据读取程序和与 CPU 联合的严密水平,速度是 L1 >L2 > L3 > 主存,容量是 L1< L2< L3< 主存。

L1 缓存很小然而很快,并且紧靠着在应用它的 CPU 内核,L2 大一些,也慢一些,并且依然只能被一个独自的 CPU 核应用,L3 更大,更慢,并且被单个插槽上的所有 CPU 核共享,最初是主存,由全副插槽上的所有 CPU 核共享。领有三级缓存的的 CPU,到三级缓存时可能达到 95% 的命中率,只有不到 5% 的数据须要从内存中查问。
三级缓存示意图:

缓存行

缓存行 (Cache Line) 是 CPU 缓存中的最小单位,CPU 缓存由若干缓存行组成,一个缓存行的大小通常是 64 字节(备注:取决于 CPU,本文基于 64 字节,其余长度的如 32 字节等本文不作探讨),并且它无效地援用主内存中的一块地址。一个 Java 的 long 类型是 8 字节,因而在一个缓存行中能够存 8 个 long 类型的变量。
所以,如果你拜访一个 long 数组,当数组中的一个值被加载到缓存中,它会额定加载另外 7 个,以至你能十分快地遍历这个数组。事实上,你能够十分疾速的遍历在间断的内存块中调配的任意数据结构。而如果你在数据结构中的项在内存中不是彼此相邻的(如链表),你将得不到缓存加载所带来的劣势,并且在这些数据结构中的每一项都可能会呈现缓存未命中的状况。

MESI 协定

MESI 协定是基于 Invalidate 的高速缓存一致性协定,并且是反对回写高速缓存的最罕用协定之一。

缓存行状态

CPU 的缓存是以缓存行 (cache line) 为单位的,MESI 协定形容了多核处理器中一个缓存行的状态。(当初支流的处理器都是用它来保障缓存的相干性和内存的相干性。)

在 MESI 协定中,每个缓存行有 4 个状态,别离是:M(批改,Modified):本地处理器曾经批改缓存行,即是脏行,它的内容与内存中的内容不一样,并且此 cache 只有本地一个拷贝(专有)
E(专有,Exclusive):缓存行内容和内存中的一样,而且其它处理器都没有这行数据
S(共享,Shared):缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝
I(有效,Invalid):缓存行生效, 不能应用

状态转换

在 MESI 协定中,每个 Cache 的 Cache 控制器不仅晓得本人的读写操作,而且也监听其它 Cache 的读写操作。每个 Cache line 所处的状态依据本核和其它核的读写操作在 4 个状态间进行迁徙。MESI 协定状态迁徙图如下:


初始:一开始时,缓存行没有加载任何数据,所以它处于 I 状态。本地写(Local Write):如果本地处理器写数据至处于 I 状态的缓存行,则缓存行的状态变成 M。本地读(Local Read):如果本地处理器读取处于 I 状态的缓存行,很显著此缓存没有数据给它。此时分两种状况:(1)其它处理器的缓存里也没有此行数据,则从内存加载数据到此缓存行后,再将它设成 E 状态,示意只有我
    一家有这条数据,其它处理器都没有
    (2)其它处理器的缓存有此行数据,则将此缓存行的状态设为 S 状态。(备注:如果处于 M 状态的缓存行,再
    由本地处理器写入 / 读出,状态是不会扭转的)近程读(Remote Read):假如咱们有两个处理器 c1 和 c2,如果 c2 须要读另外一个处理器 c1 的缓存行内容,c1 须要把它缓存行的内容通过内存控制器 (Memory Controller) 发送给 c2,c2 接到后将相应的缓存行状
    态设为 S。在设置之前,内存也得从总线上失去这份数据并保留。近程写(Remote Write):其实确切地说不是近程写,而是 c2 失去 c1 的数据后,不是为了读,而是为了写。也算是
    本地写,只是 c1 也领有这份数据的拷贝,这该怎么办呢?c2 将收回一个 RFO (Request For Owner) 申请,它须要领有这行数据的权限,其它处理器的相应缓存行设为 I,除了它自已,谁不能动这行数据。这保障了数据
    的平安,同时解决 RFO 申请以及设置 I 的过程将给写操作带来很大的性能耗费。

伪共享

理解了上述一些概念之后,咱们提出一个疑难?如果有多个线程操作不同的成员变量,但它们是雷同的 <font color=’red’> 缓存行 </font>,这个时候会产生什么?

没错,<font color=’red’> 伪共享 </font>(False Sharing)问题就产生了!咱们来看一张经典的 CPU 缓存行示意图:

正文:一个运行在处理器 core1 上的线程想要更新变量 X 的值,同时另外一个运行在处理器 core2 上的线程想要更新变量 Y 的值。然而,这两个频繁改变的变量都处于同一条缓存行。两个线程就会轮流发送 RFO (Request For Owner) 音讯,占得此缓存行的领有
权。当 core1 获得了拥有权开始更新 X,则 core2 对应的缓存行须要设为 I 状态(生效态)。当 core2 获得了拥有权开始更新 Y,则 core1    对应的缓存行须要设为 I 状态(生效态)。轮流篡夺拥有权岂但带来大量的 RFO 音讯,而且如果某个线程须要读此行数据时,L1 和 L2 缓存上都是生效数据,只有 L3 缓存上是同步好的数据。从后面的内容咱们晓得,读 L3 的数据会影响性能,更坏的状况是跨槽
读取,L3 都呈现缓存未命中,只能从主存上加载。

举例说明:

咱们以 Java 外面的 ArrayBlockingQueue 为例采纳生产生产模型阐明,ArrayBlockingQueue 有三个成员变量:

    - takeIndex:须要被取走的元素下标 
    - putIndex:可被元素插入的地位的下标 
    - count:队列中元素的数量

这三个变量很容易放到一个缓存行中,然而批改并没有太多的关联。所以每次批改,都会使之前缓存的数据生效,从而不能齐全达到共享的成果。

当生产者线程 put 一个元素到 ArrayBlockingQueue 时,putIndex 会批改,从而导致消费者线程的缓存中的缓存行有效,须要向上从新读取,这种无奈充沛应用缓存行个性的景象,称为伪共享。

看到此处,咱们能够自行总结,对于伪共享给出一个 <font color=’red’> 非标准的定义 </font>:
CPU 缓存零碎中是以缓存行为单位存储的,当多线程批改相互独立的变量时,如果这些变量共享同一个缓存行,就会无心中影响彼此的性能,这就是伪共享。

三、程序模仿

程序用四个线程批改一数组不同元素的内容,元素类型为 VolatileLong,蕴含一个长整型成员 value 和 6 个没用到的长整型成员,value 设为 volatile 是为了让 value 的批改对所有线程都可见。次要代码如下:

public class FalseShare 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 FalseShare(final int arrayIndex) {this.arrayIndex = arrayIndex;}

    private static void exeTest() throws InterruptedException {Thread[] threads = new Thread[NUM_THREADS];
        for (int i = 0; i < threads.length; i++) {threads[i] = new Thread(new FalseShare(i));
        }
        for (Thread t : threads) {t.start();
        }
        for (Thread t : threads) {t.join();
        }
    }

    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;     // 缓存行填充
    }

    public static void main(final String[] args) throws Exception {for (int j = 0; j < 10; j++) {System.out.println("第" + j + "次...");
            longs = new VolatileLong[NUM_THREADS];
            for (int i = 0; i < longs.length; i++) {longs[i] = new VolatileLong();}
            long start = System.nanoTime();
            exeTest();
            long end = System.nanoTime();
            SUM_TIME += end - start;
        }
        System.out.println("均匀耗时:" + SUM_TIME / 10);
    }
}

第一次执行:

//        public long p1, p2, p3, p4, p5, p6;     // 缓存行填充

第二次执行:

          public long p1, p2, p3, p4, p5, p6;     // 缓存行填充

程序每次运行,循环 10 次,取均匀耗时,耗时后果如下:

第一次:均匀耗时:28305116160

第二次:均匀耗时:14071204270

【实例阐明】:一个缓存行有 64 字节,一个 long 占 8 个字节,而 Java 程序的对象头固定占 8 字节 (32 位零碎) 或 12 字节(64 位零碎默认开启压缩, 不开压缩为 16 字节),所以咱们只须要填 6 个无用的长整型补上 6 *8=48 字节,让不同的 VolatileLong 对象处于不同的缓存行,就防止了伪共享(64 位零碎超过缓存行的 64 字节也无所谓,只有保障不同线程不操作同一缓存行就能够了)。

四、伪共享防止

1. 缓存行填充(让不同线程操作的对象处于不同的缓存行)

1)缓存行手动填充

2)应用 Contended 注解主动进行填充

2. 应用编译批示,来强制每一个变量对齐(略)

五、总结

1)CPU 缓存是以 <font color=’red’> 缓存行 </font> 为单位进行操作的,产生伪共享问题的本源在于不同的 CPU 核同时操作同一个缓存行;
2)能够通过缓存行填充来解决伪共享问题,且 Java8 中引入了 @sun.misc.Contended 注解来主动填充;
3)一个缓存行有四种状态,别离为:M(批改)E(专有)S(共享)I(有效);
4)每个缓存行所处的状态依据本核和其它核的读写操作在 4 个状态间进行迁徙;
5)不是所有的场景都须要解决伪共享问题,因为 CPU 缓存是无限的,填充会就义掉一部分缓存;

正文完
 0