• CPU中的缓存、缓存一致性、伪共享和缓存行填充


    并发编程为何如此复杂?并发编程为什么会产生可见性、有序性、原子性的线程或内存问题?

    归根结底,还是计算机硬件高速发展的原因。因为多核CPU架构,以及CPU缓存才导致一系列的并发问题。

    1.CPU缓存

    2.总线锁和缓存锁

    3.缓存行

    4.缓存一致性协议(如:intel的MESI)

    5.伪共享(false sharing)问题

    6.伪共享解决方案(如:缓存行填充)

    7.总结

    1.CPU缓存

    相信大家都见过下面这张图或类似的图,计算机的存储层次结构像一座金字塔。越往上访问速度越快、成本更高,所以空间也越小。越往下访问速度越慢、成本越低,空间也就越大。

    CPU的运算速度最快,内存的读写速度无法和其速度匹配。假如定义cpu的一次存储或访问为一个时钟周期,那么内存的一次运算通常需要几十甚至几百个始终周期。如果在CPU直接读取内存进行运算,那么CPU大部分时间都在等在内存的访问,利用率仅有几十分之一甚至几百分之一。为了解决CPU运算速度与内存读写速度不匹配的矛盾,在CPU和内存之间,引入了L1高速缓存、L2高速缓存、L3高速缓存,通过每一级缓存中所存储的数据全部都是下一级缓存中的一部分,当CPU需要数据时,就从缓存中获取,从而加快读写速度,提高CPU利用率、提升整体效率。

    • L1高速缓存:也叫一级缓存。一般内置在内核旁边,是与CPU结合最为紧密的CPU缓存。一次访问只需要2~4个时钟周期
    • L2高速缓存:也叫二级缓存。空间比L1缓存大,速度比L1缓存略慢。一次访问约需要10多个时钟周期
    • L3高速缓存:也叫三级缓存。部分单CPU多核心的才会有的缓存,介于多核和内存之间。存储空间已达Mb级别,一次访问约需要数十个时钟周期。

    当CPU要读取一个数据时,首先从L1缓存查找,命中则返回;若未命中,再从L2缓存中查找,如果还没有则从L3缓存查找(如果有L3缓存的话)。如果还是没有,则从内存中查找,并将读取到的数据逐级放入缓存。

     2.总线锁和缓存锁

    lock前缀,会保证某个处理器对共享内存(一般是缓存行cacheline,这里记住缓存行概念,后续重点介绍)的独占使用。它将本处理器缓存写入内存,该写入操作会引起其他处理器或内核对应的缓存失效。通过独占内存、使其他处理器缓存失效,达到了“指令重排序无法越过内存屏障”的作用

    总线锁 :顾名思义就是,锁住总线。通过处理器发出lock指令,总线接受到指令后,其他处理器的请求就会被阻塞,直到此处理器执行完成。这样,处理器就可以独占共享内存的使用。但是,总线锁存在较大的缺点,一旦某个处理器获取总线锁,其他处理器都只能阻塞等待,多处理器的优势就无法发挥。

    于是,经过发展、优化,又产生了缓存锁。

    缓存锁:不需锁定总线,只需要“锁定”被缓存的共享对象(实际为:缓存行)即可,接受到lock指令,通过缓存一致性协议,维护本处理器内部缓存和其他处理器缓存的一致性。相比总线锁,会提高cpu利用率。

    但是缓存锁也不是万能,有些场景和情况依然必须通过总线锁才能完成。

    这里又出现了两个新概念:缓存行和缓存一致性协议

    3.缓存行

    缓存锁会“锁定”共享对象,如果仅锁定所用对象,那么有大有小、随用随取,对于CPU来说利用率还达不到最大化。所以采用,一次获取一整块的内存数据,放入缓存。那么这一块数据,通常称为缓存行(cache line)。缓存行(cache line)是CPU缓存中可分配、操作的最小存储单元。与CPU架构有关,通常有32字节、64字节、128字节不等。目前64位架构下,64字节最为常用。

    4.缓存一致性协议(如:intel的MESI)

    每个处理器都有自己的高速缓存,而又共享同一主内存。当多个处理器都涉及同一块主内存区域的更改时,将导致各自的的缓存数据不一致。那同步到主内存时该以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,来保证处理器间缓存的一致性。这类协议有MSI、MESI、MOSI等。

    下面重点介绍应用较为广泛的MESI协议。MESI是Modified(修改)、Exclusive(独占)、Shared(共享)、Invaild(失效)四种状态的缩写,是用来修饰缓存行的状态。在每个缓存行前额外使用2bit,来表示此四种状态。

    • Modified(修改):该缓存行仅出现在此cpu缓存中,缓存已被修改,和内存中不一致,等待同步至内存。
    • Exclusive(独占):该缓存行仅出现在此cpu缓存中,缓存和内存中保持一致。
    • Shared(共享):该缓存行可能出现在多个cpu缓存中,且多个cpu缓存的缓存行和内存中的数据一致。
    • Invalid(失效):由于其他cpu修改了缓存行,导致本cpu中的缓存行失效

    在MESI协议中,每个缓存行不仅知道自己的读写操作,而且也监听其它缓存行的读写操作。每个缓存行的状态根据本cpu和其它cpu的读写操作在4个状态间进行迁移。

     它的监听(嗅探)机制:

    • 当缓存行处于Modified状态时,会时刻监听其他cpu对该缓存行对应主内存地址的读取操作,一旦监听到,将本cpu的缓存行写回内存,并标记为Shared状态
    • 当缓存行处于Exclusive状态时,会时刻监听其他cpu对该缓存行对应主内存地址的读取操作,一旦监听到,将本cpu的缓存行标记为Shared状态
    • 当缓存行处于Shared状态时,会时刻监听其他cpu对使缓存行失效的指令(即其他cpu的写入操作),一旦监听到,将本cpu的缓存行标记为Invalid状态(其他cpu进入Modified状态)
    • 当缓存行处于Invalid状态时,从内存中读取,否则直接从缓存读取

    总结:当某个cpu修改缓存行数据时,其他的cpu通过监听机制获悉共享缓存行的数据被修改,会使其共享缓存行失效。本cpu会将修改后的缓存行写回到主内存中。此时其他的cpu如果需要此缓存行共享数据,则从主内存中重新加载,并放入缓存,以此完成了缓存一致性。

    5.伪共享(false sharing)问题

    缓存一致性协议针对的是最小存取单元:缓存行。依照64字节的缓存行为例,内存中连续的64字节都会被加载到缓存行中,除了目标数据还会有其他数据。

    如下图所示,假如变量x和变量y共处在同一缓存行中,core1需要操作变量x,core2需要操作变量y。

    • core1修改缓存行内的变量x后,按照缓存一致性协议,core2需将缓存行置为失效,core1将最新缓存行数据写回内存。
    • core2需重新从内存中加载包含变量y的缓存行数据,并放置缓存。如果core2修改变量y,需要core1将缓存行置为失效,core2将最新缓存写回内存。
    • core1或其他处理器如需操作同一缓存行内的其他数据,同上述步骤。

    上述例子,就是缓存行的伪共享问题。总结来说,就是多核多线程并发场景下,多核要操作的不同变量处于同一缓存行,某cpu更新缓存行中数据,并将其写回缓存,同时其他处理器会使该缓存行失效,如需使用,还需从内存中重新加载。这对效率产生了较大的影响。

     6.伪共享解决方案(如:缓存行填充)

    伪共享问题的解决思路有也很典型:空间换时间。

    以64字节的缓存行为例,伪共享问题产生的前提是,并发情况下,不同cpu对缓存行中不同变量的操作引起的。那么,如果把缓存行中仅存储目标变量,其余空间采用“无用”数据填充补齐64字节,就不会才产生伪共享问题。这种方式就是:缓存行填充(也称缓存行对齐)。

    Talk is cheap,show me the code.

    下面,从三个实例去给大家解释完缓存行填充,让大家也能应用到自己的代码中去。

    6.1.Disruptor为什么这么快

    Disruptor是一个性能极强的开源的无锁并发框架,基于Disruptor的LMAX架构交易平台,号称单线程内每秒可处理600万笔订单。简直是一个不折不扣的性能小钢炮。

    Disruptor框架的核心是它的Ringbuffer环形缓冲。这里不做框架的具体分析,有兴趣可在github下载源码(传送门)。推荐大家阅读并发编程网对Disruptor框架的介绍(传送门)。

    它的定位是高性能并发框架,肯定也会遇到我们上述的缓存伪共享问题,我们看一下Disruptor是怎么解决的?

    public long p1, p2, p3, p4, p5, p6, p7; // cache line padding
    private volatile long cursor = INITIAL_CURSOR_VALUE;
    public long p8, p9, p10, p11, p12, p13, p14; // cache line padding

    Disruptor源码中,有大量类似于上述的代码,在目标变量前后定义多个"无实际含义的"变量进行缓存行填充(cache line padding)。

    基础类型long在java中占用8字节,在额外填充7个long类型的变量,这样在从内存中获取目标变量放入缓存行时,可以达到缓存行中除了目标变量,剩下都是填充变量(由于无业务含义,其他cpu不会对其进行修改)。曲线救国,解决了缓存行伪共享的问题。思想:空间换时间。

    6.2.实验证明

    看了上述代码,可能还有人心有存疑,搞出这么多无用的字段,效率能提高?我不信。

    下面我们就自己写一个demo来证明:缓存行填充能提高并发效率。

    例1:不填充缓存行

    public class CacheLinePaddingBefore {
    
        private static class Entity {
            public volatile long x = 1L;
        }
    
        public static Entity[] arr = new Entity[2];
    
        static {
            arr[0] = new Entity();
            arr[1] = new Entity();
        }
    
        public static void main(String[] args) throws InterruptedException {
    
            Thread threadA = new Thread(() -> {
                for (long i = 0; i < 1000_0000; i++) {
                    arr[0].x = i;
                }
            }, "ThreadA");
    
            Thread threadB = new Thread(() -> {
                for (long i = 0; i < 1000_0000; i++) {
                    arr[1].x = i;
                }
            }, "ThreadB");
    
            final long start = System.nanoTime();
            threadA.start();
            threadB.start();
            threadA.join();
            threadB.join();
            final long end = System.nanoTime();
            System.out.println("耗时:" + (end - start) / 100_0000);
    
        }
    }

    例1思路:

    1、定义一个长度为2的数组arr,数组中是一个仅有一个long类型变量的对象;

    2、定义两个线程A和B,线程A修改arr[0],线程B修改arr[1]。线程A和线程B并发修改1千万次;

    3、此处定义数组的目的是:保证线程A和线程B修改的变量尽可能是连续的,即两个变量在同一缓存行中,以模拟伪共享问题。

    测试结果:多次运行上述demo,平均耗时:240ms左右。

    例2:填充缓存行

    public class CacheLinePaddingAfter {
    
        // 定义7个long类型变量,进行缓存行填充
        private static class Padding{
            public volatile long p1, p2, p3, p4, p5, p6, p7;
        }
    
        private static class Entity extends Padding{
            // 使用@sun.misc.Contended注解,必须添加此参数:-XX:-RestrictContended
            // @sun.misc.Contended
            public volatile long x = 0L;
        }
    
        public static Entity[] arr = new Entity[2];
    
        static {
            arr[0] = new Entity();
            arr[1] = new Entity();
        }
    
        public static void main(String[] args) throws InterruptedException {
    
            Thread threadA = new Thread(() -> {
                for (int i = 0; i < 1000_0000; i++) {
                    arr[0].x = i;
                }
            }, "ThreadA");
    
            Thread threadB = new Thread(() -> {
                for (int i = 0; i < 1000_0000; i++) {
                    arr[1].x = i;
                }
            }, "ThreadB");
    
            final long start = System.nanoTime();
            threadA.start();
            threadB.start();
            threadA.join();
            threadB.join();
            final long end = System.nanoTime();
            System.out.println("耗时:" + (end - start)/100_0000);
    
        }
    }

    例2思路:

    ​ 1、定义一个包含7个long类型的“无实际意义”字段的填充对象;

    ​ 2、实际对象Entity继承填充对象,达到7+1=8个long类型字段,可以填充一整个64字节的缓存行;

    ​ 3、重复例1中的动作。

    测试结果:多次运行上述demo,平均耗时:70ms左右。

    大家也可以直接拿上述两个例子在自己的电脑进行测试。例2的执行效率远超超例1的执行效率。我们通过实践证明:缓存行填充显著提高并发效率

    6.3.JDK8中自带注解@Contended

    Jdk8中引入了@sun.misc.Contended这个注解来解决缓存伪共享问题。使用此注解有一个前提,必须开启JVM参数-XX:-RestrictContended,此注解才会生效。

    此注解在一定程度上同样解决了缓存伪共享问题。但底层原理并非缓存行填充,而是通过对对象头内存布局的优化,将那些可能会被同一个线程几乎同时写的字段分组到一起,避免形成竞争,来达到避免伪共享的目的。此处不再铺开讲述,有兴趣的可阅读文章:并发编程网-有助于减少伪共享的@Contended注解和此文开头提及Aleksey Shipilev的这封邮件

    Jdk内部也大量使用了此注解

    使用注解@Contended改造

    public class CacheLinePaddingAfter {
    
        private static class Entity{
            // 使用@sun.misc.Contended注解,必须添加此参数:-XX:-RestrictContended
            @sun.misc.Contended
            public volatile long x = 0L;
        }
    
        public static Entity[] arr = new Entity[2];
    
        static {
            arr[0] = new Entity();
            arr[1] = new Entity();
        }
    
        public static void main(String[] args) throws InterruptedException {
    
            Thread threadA = new Thread(() -> {
                for (int i = 0; i < 1000_0000; i++) {
                    arr[0].x = i;
                }
            }, "ThreadA");
    
            Thread threadB = new Thread(() -> {
                for (int i = 0; i < 1000_0000; i++) {
                    arr[1].x = i;
                }
            }, "ThreadB");
    
            final long start = System.nanoTime();
            threadA.start();
            threadB.start();
            threadA.join();
            threadB.join();
            final long end = System.nanoTime();
            System.out.println("耗时:" + (end - start)/100_0000);
    
        }
    }

    测试结果:多次运行上述demo,平均耗时:70ms左右。

    7.总结

    文章开头提及本篇文章是上一篇文章volatile底层原理详解(上) 的延伸和补充。所以下面带大家整体回顾下这两张的内容。如果没看上篇文章或对volatile暂无兴趣,请直接看下面的第3点总结即可。

    我们对volatile关键字的作用和原理的了解,从Java代码层面一路聊到计算机的硬件层面,硬件层面从cpu缓存到缓存行、缓存锁,再到缓存一致性协议,最后分析了缓存行的伪共享问题以及它的解决方案。希望看完整篇之后再从头到尾串一遍,将其中的“点”串成“线”,做到举一反三,能对日后的工作有所帮助。最后,通过几个问题,帮助大家回顾文章内容:

    1、并发编程中的三大特性:原子性、可见性、有序性。volatile修饰的变量如何保证可见性、有序性?为何不能保证原子性?

    2、volatile的底层实现:

    2.1、Java代码中如何使用volatile关键字?

    2.2、volatile修饰的变量,编译成class字节码后,变量的访问标志有什么变化?

    2.3、JVM运行时,是判断变量是被的volatile修饰的?赋值和取值同普通变量有何区别的?orderAccess.hpp头文件中是对内存屏障的定义和作用的描述是怎样的?lock前缀指令的作用是什么?

    2.4、打印汇编输出,可以看到JVM级别的实现:lock前缀指令。

    3、可见性问题,有序性问题的产生一部分是cpu硬件架构引起的,那么就有必要了解它的硬件原理,以及如何利用硬件写出高性能的并发程序。

    3.1、金字塔的cpu存储结构要能直观呈现在脑子中。Cpu为什么要有一、二、三级缓存?

    3.2、Lock前缀的指令,能保证某个处理器对共享内存的独占使用,并且达到指令重排序无法越过内存屏障的目的。它是通过对缓存加锁来实现。缓存锁了解一下。

    3.3、缓存锁针对的是对缓存行加锁。缓存行是什么?缓存一致性协议是什么?

    3.4、缓存行伪共享问题是如何出现的?

    3.5、缓存行伪共享问题的解决方案:缓存行填充和JDK8中自带的@Contented注解。并发编程中,我们可以尝试应用,来提高程序运行效率。

  • 相关阅读:
    运维自动化工具 Kickstart
    Ansible 运维自动化 ( 配置管理工具 )
    Redis 主从同步配置
    MONGO db INSTALLATION
    Innobackupex MySQL 全备、增备及恢复
    strace 使用
    LCD硬件原理
    多点触摸_电容屏驱动程序_实践_tiny4412
    单点触摸屏与多点触摸屏的异同
    多点触摸屏——电容屏驱动程序
  • 原文地址:https://www.cnblogs.com/zhangzhongkun/p/16175528.html
Copyright © 2020-2023  润新知