在学习《Java并发编程的艺术》这本书的过程中,发现整本书的中心便是多线程之间的可见性问题和CAS无锁算法结合 volatile 关键字在各个并发工具类里的运用。

所以要真正掌握并发编程原理,首要有必要跳过的妨碍便是 volatile 原理。因为 volatile 的底层原理涉及到硬件层面的多处理器缓存架构、缓存共同性协议、CPU内存屏障指令,然后再到 JVM 软件层面的 Java 内存模型 JMM,以及 JMM 怎么去界说线程的本地内存和主内存之间的联系等。

能够说,了解了 volatile 就等于打通了整个并发编程从底层到顶层的主线。这也是十分高频的并发编程面试切入点:说一下 volatile 的原理?

本文作为自己梳理过程中的笔记,会从以下次序进行顺腾摸瓜,从底层硬件原理到顶层软件层面来论述 volatile 的原理:

硬件层面

  1. 多核处理器缓存架构导致多个处理器之间的缓存共同性问题

  2. 为了处理缓存共同性问题引进了缓存共同性协议

  3. 缓存共同性协议存在堵塞问题

  4. 为了处理缓存共同性的堵塞问题引进了写缓冲区和失效行列

  5. 写缓冲区和失效行列将缓存改写音讯异步化之后仍是存在时间短推迟导致线程可见性

  6. 为了处理可见性问题处理器层面又引进了内存屏障指令

软件层面

  1. 硬件层面上,处理器其实并不知道何时需求运用内存屏障指令,它仅仅供给内存屏障的才能,然后将决定权交给软件层

  2. 软件层面上JVM 笼统处理 Java 内存模型 JMM 来界说线程本地内存和主内存之间的联系,并供给 volatile 关键字来让软件层面调用底层的内存屏障来处理多线程之间的可见性问题

  3. 详细工作交给 JVM 在编译期对 volatile 变量的指令代码尾部增加 lock 前缀指令

  4. lock 前缀指令的会调用底层处理器的内存屏障

这便是从底层到顶层构成的一个闭环,下图是从Java层到硬件层的视角:

volatile底层原理:从CPU架构到内存屏障之旅

一、多核处理器缓存架构和缓存缓存共同性问题

volatile底层原理:从CPU架构到内存屏障之旅

每个 CPU 都有自己的高速缓存,它们同享一个主内存:从主内存读取同一个地址的数据放到本身的高速缓存里,就造成了数据不共同的或许性,所以就产生了 cache coherence 问题,为了处理 cache coherence 问题,引进了缓存共同性协议。

二、多核处理器缓存架构之间的缓存共同性协议

缓存共同性协议的效果:确保当 CPU 回写一杯锁定的缓存行时,会使其他 CPU 对应的缓存行失效。

volatile底层原理:从CPU架构到内存屏障之旅

1、MESI 协议(Modified Exclusive Shared Invalid)

MESI 是缓存协议的一种,运用最广泛。

volatile底层原理:从CPU架构到内存屏障之旅

1.1、MESI 协议的原理

MESI 协议的原理是在 CPU 缓存行中保存一个标志位,每个 Cache line 有4个状况,可用2个bit表明。MESI 是指4种状况的首字母:

  • M: Modified 被修正了

    • 这个状况表明当时 cache line 只被缓存在该 CPU 中,而且是被修正正的脏数据,即和主存中的数据不共同,该缓存行中的内存需求在未来的某个时间点刷入主存。
    • 当写回主存之后,该缓存行的状况会变成 E 独享状况。
  • E: Exclusive

    • 该 cache line 只被缓存到该 CPU 中,其他 CPU 没缓存它。它是洁净的数据,即与主存数据共同。
    • E 状况的 cache line 在任何时刻,当有其他 CPU 读取该内存时,变成 S 同享状况。
    • 相同地,当 CPU 修正该缓存行数据时,状况变为 M Modified。
  • S: Share

    • 缓存行为 S 状况意味着它被缓存到多个 CPU 中,而且各个缓存中的数据与主存一直,是洁净的数据。
    • 当其间一个 CPU 修正这个缓存数据时,其他 CPU 中的该缓存行能够被作废,变为 I Invalid 状况
  • I: Invalid

    • 缓存失效,CPU 中的缓存现已不能运用了,因为被其他 CPU 修正正了,你要读取该数据有必要从头读取主存。

一个写请求只要在该缓存行的状况是 M 或许 E 时才能被履行,假如缓存行处于 S 状况,意味着多个 CPU 都缓存了它,有必要将其他 CPU 的该缓存行置为 Invalid 状况。

CPU的读取遵循下面几点

  • 假如缓存状况是 I,表明这个 cache line 失效了:直接从内存中读取
  • 假如缓存状况是 M 或 E 的 CPU 嗅探到其他 CPU 有读操作,就把自己的缓存写入到内存中,并将自己的状况设置为 S。

回写法(Write BACK):每次 CPU 修正了缓存数据,不会当即更新到内存,而是比及某个合适的机遇才会更新到内存中去。这样做的意图是提升功率。(回写法中,缓存的改动不会当即写入主存)

1.2、MESI 协议的完成思路

  • 假如 CPU1 修正了某个同享变量的数据,需求播送给其他 CPU
  • 缓存中没有这个数据的 CPU 直接丢弃这个播送音讯,无需处理
  • 缓存中有这个数据的 CPU 监听到播送后,将相应的 cache line 置为 invalid 状况
  • 当这些 CPU 下次读取这个数据时发现缓存行失效就去内存读取

以上的播送监听实践上是多处理器之间的总线嗅探技能。

1.3、Snooping:缓存共同性协议用到的 Snooping 嗅探技能

缓存共同性协议用到了 Snooping 嗅探技能:Snooping 是一种播送机制,会监听总线上的一切活动,而这些活动以播送的方法在总线上进行传达。在多核处理器场景,处理器经过嗅探技能来监听其他处理器操作主内存或内部缓存。

处理器修正内存或缓存后,把事情和缓存行的标志位以播送的方式在总线传达,其他处理器经过嗅探机制来监听这些播送并做出相应的缓存行失效操作。

三、缓存共同性协议存在的堵塞问题

在 MESI 中,依靠总线嗅探机制,整个过程是串行的,或许会产生堵塞

读写操作详细可分红4种类型(L 和 R 别离为 Local 和 Remote,右边的 R 和 W 则为 Read 和 Write):

  • LR-本内核读取本cache
  • LW-本内核写本cache
  • RR-其它内核读其对应的cache
  • RW-其它内核写其对应的cache

假如 CPU0 产生 LW 时,首要会发 Invalidate 音讯给到其他缓存了该数据的 CPU1,而且要等候 CPU1 的承认回执CPU0 在这段时间内都会处于堵塞状况。

volatile底层原理:从CPU架构到内存屏障之旅

四、写缓冲区和失效行列:缓存共同性堵塞处理方案

volatile底层原理:从CPU架构到内存屏障之旅

volatile底层原理:从CPU架构到内存屏障之旅

假如严厉按照 MESI 协议,会有严重的功用问题(堵塞), 为了防止堵塞带来的资源糟蹋,CPU 引进的处理方案是写缓冲区(Store Buffer)和失效行列(Invalid Queue)。

4.1、写缓冲区 Store Buffer

CPU0 只需求在写入同享数据时,直接把数据写入 Store Buffer中,一同发送 invalidate 音讯给其他 CPU,然后就持续去处理其他指令了(异步)。(写高速缓存比写主存要快100倍)

当其他 CPU 发送了 invalidate acknowledge 音讯时,CPU0 再将 Store Buffer 中的数据写入缓存行中,最后再从缓存行写入主存。(在原来的缓存行基础上细分了粒度,增加了 Store Buffer)

4.2、失效行列 Innvalid Queue

失效行列也是归于每个 CPU,运用失效行列后,产生 RW 对应的 CPU 缓存不再同步地失效缓存并发送承认回执,而是将失效音讯放入失效行列,当即发送承认回执。 后续 CPU 会在空闲时,对失效行列中的音讯进行处理,将对应的 CPU 缓存失效。

经过上述功用优化后,又引进了新的问题:内存重排序。因为发通知从同步变为异步,要怎么确保异步期间,其他 CPU 恪守缓存共同性协议呢?这儿剧透处理方案:内存屏障。

五、内存重排序:写缓冲区和失效行列存引进的新问题

首要这儿需求区分一下各种重排序的差异,这是个简单混淆的概念。

5.1、重排序的分类

volatile底层原理:从CPU架构到内存屏障之旅

重排序有以下三种:

  1. 编译器重排序:关于没有依靠联系的句子,编译器对它们重排序
  2. CPU 指令重排序:CPU 指令级别的重排序,意图是让没有依靠联系的多条指令并行履行
  3. 内存系统的重排序:CPU 运用高速缓存和读、写缓冲区(Store Buffer + Invalid Queue),导致指令履行次序和最终写入主内存的次序不完全共同。

第一种归于编译器重排序,后两种归于处理器重排序(产生在处理器内部)。指令重排序好了解,刚开始听到内存重排序的概念不是特别了解。

5.2、产生内存重排序的原因

MESI 经过引进写缓冲区和失效行列这种异步机制之后,存在以下问题:在某些中心状况下,多个 CPU 之间的数据并不共同,看起来就像是被重排序了,这便是所谓的内存重排序问题。写缓冲区和失效行列都有或许引起内存重排序。

  • 引进 Store Buffer 后,或许造成缓冲区数据没能及时写入主内存
    • 即使读写指令本身是按照次序履行的,因为数据写入缓冲区之后刷入主内存的机遇是不确定的,所以或许会造成乱序的现象。
  • 引进 Invalid Queue 后,或许会读取到过期的数据
    • CPU 在嗅探到 cache line 失效播送时,仅仅将音讯放到失效行列就宣布失效承认应对(异步)
    • 假如在失效行列还没处理之前,CPU 又读取到某个已放入失效行列里的变量缓存数据,就会读取到过期数据。

示例剖析:内存重排序

volatile底层原理:从CPU架构到内存屏障之旅

如上图表所示,当时有两个 CPU 一同履行:

  • CPU0 和 CPU1 别离履行S1和S2
  • 然后再别离履行S3和S4
  • 当 CPU1 履行到 S4时,CPU0 对 X = 1 的修正或许还在 CPU0 的写缓冲区中未刷入主内存。
  • 所以 CPU1 读取到的 X 值仍是 0,而实践上指令没有重排序,仅仅因为写缓冲区导致结果看起来重排序了

为了处理上述例子中内存重排序的问题,CPU 硬件层面提出了内存屏障作为处理方案。

六、引进CPU内存屏障指令处理可见性问题

内存屏障本质是一组 CPU 指令,用于完成对内存操作的次序限制。在Java里运用 volatile 关键字来完成内存屏障。

6.1、内存屏障的效果

  • 制止屏障两侧的指令重排序
  • 强制改写内存和缓存失效:强制把写缓冲区中的脏数据刷入主内存;让其他 CPU 的缓存行失效

6.2、内存屏障有两个指令:读写屏障

经过对读、写屏障的各种组合战略,组成各种类型的内存屏障,来制止对应各种类型的重排序。

  1. 写屏障(Store Barrier):针对写缓冲区 Store Buffer
  • 在指令之前刺进 Load Barrier 读屏障,强制从头从主内存加载数据,能够让高速缓存中的数据失效
  • 告知处理器,在写屏障之前的一切 store buffer 数据都刷入主内存。即写屏障之前的写操作指令,关于屏障之后的读操作是可见的
  1. 读屏障(Load Barrier):针对失效行列 Invalid Queue
  • 在指令之后刺进 Store Barrier 写屏障,强制将写入缓存中的最新数据刷入主内存 ,让其他线程可见
  • 告知 CPU 在履行任何的加载前,先处理一切在失效行列 Invalid Queue 中的音讯。然后才能判别哪些数据失效,需求从头填充缓存行

6.3、内存屏障分几种?(腾讯面试题)

  • LoadLoad屏障:关于这样的句子Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被拜访前,确保Load1要读取的数据被读取结束。
  • StoreStore屏障:关于这样的句子Store1; StoreStore; Store2,在Store2及后续写入操作履行前,确保Store1的写入操作对其它处理器可见。
  • LoadStore屏障:关于这样的句子Load1; LoadStore; Store2,在Store2及后续写入操作被刷入主存前,确保Load1要读取的数据被读取结束。
  • StoreLoad屏障:关于这样的句子Store1; StoreLoad; Load2,在Load2及后续一切读取操作履行前,确保Store1的写入对一切处理器可见。

StoreLoad 屏障

StoreLoad 屏障(即 X 代表 Store操作,Y 代表 Load 操作)能够制止该指令之前的写操作与其右侧的任何读操作之间进行重排序。实践效果便是墙左边的写操作会被当即刷入主存,墙之后的读操作会从主存读最新的值。然后确保了可见性。

留意:根本内存屏障并不全面制止重排序,比方 StoreLoad 屏障左边的多个指令依然能够重排序,但在到了屏障之前一切履行结果都会刷入内存,以便给墙后边的指令读取到最新的值。

它的开支是四种屏障中最大的。在大多数处理器的完成中,这个屏障是个全能屏障,兼具其它三种内存屏障的功用。

LoadLoad 屏障(针对失效行列)

LoadLoad 屏障是经过处理掉失效行列里的失效音讯,来完成制止 LoadLoad 重排序的。

否则的话,CPU0写同享变量时宣布的失效音讯到相同持有该同享变量副本的 CPU1之后,CPU1仅仅将失效音讯入队然后就宣布承认回执给 CPU0,然后 CPU0 将同享变量刷入主存,此时 CPU1 又从自己的本地内存读取这个还没来得及被失效的同享变量,所以就读到旧的副本数据。

而 LoadLoad 屏障便是确保了 CPU1 在读该同享变量前先处理掉失效行列里的失效音讯,及时将该同享变量置为失效(删除高速缓存中的副本),之后 CPU1才会从头去主内存读取最新的同享变量值,确保了可见性。

低效的方法:假如 LoadLoad 仅仅不管三七二十一,直接让屏障指令前后的同享变量从主内存读取,这是很低效的,许多情况下并不需求,比方这些数据没被修正正的情况下,你还去从头从主存读取,是比较耗时的。所以这儿粒度细化成处理失效行列的音讯,失效行列的音讯代表对应的变量数据现已被其他 CPU 修正正,你需求从头去主内存读取数据,按需读取才是功率最佳实践。

StoreStore 屏障(针对写缓冲区)

StoreStore 屏障是经过对写缓冲区里面的 Entry 进行符号来完成制止 StoreStore 重排序。

StoreStore 屏障会将写缓冲区中现有的 Entry 做符号,表明这些 Entry的写操作要先于屏障之后的写操作被提交。所以,处理器在履行写操作时,假如发现写缓冲区中存在被符号的 Entry 条目,则将这个写操作写入缓冲区,然后使得 StoreStore 屏障之前的任何写操作先于该屏障之后的写操作被提交。

总结:StoreLoad 屏障能够完成其他 3 种根本内存屏障的效果,但开支也是最大的:StoreLoad 屏障会清空失效行列,并将写缓冲区中的 Entry 条目刷入高速缓存。

6.4、volatile 怎么防止指令重排

volatile 详细是经过以下内存屏障战略,防止指令重排:

  • volatile 写操作之前,刺进 StoreStore 屏障,确保当时 volatile 变量的写操作不会和之前的写操作重排序。(之前的写操作会先刷入主内存,这样 volatile 变量的写操作天然不会和前面重排序了)
  • volatile 写操作之后,刺进 StoreLoad 屏障,确保当时 volatile 变量的写操作不会和之后的读操作冲排序。(确保当时的 volatile 变量写操作会先于后边的读操作被强刷入主内存)
  • 在 volatile 读操作之后,刺进 LoadLoad 屏障、 LoadStore 屏障, 确保当时 volatile 变量的读操作不会和之后的读、写操作重排序。

根本内存屏障能够统一用 XY 来表明,其间的 X 和 Y 都能够代表 Load或许Store。

根本内存屏障是对一类指令的称号,这类指令能够了解为一堵墙。其效果是墙左边的任何 X 操作与墙右侧的任何 Y 指令之间进行重排序,然后确保墙左边的任何 X 操作先于墙右侧的任何 Y 操作被提交。(即内存操作效果到主内存或许高速缓存上)

6.5、示例:内存屏障

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;
    public void write() {
    	a = 1;//1
        flag = true;//2
    }
    public void read() {
    	if (flag) {//3
        	int i = a;//4
            ...
        }
    }
}

示例剖析:这儿从内存屏障的刺进这个方面来进行剖析。

  1. 过程2是对一个 volatile 变量进行写操作:
  • 在这个volatile 写操作之前会刺进StoreStore屏障
  • 制止前面的一般写操作和该volatile写操作重排序,将过程2之前的一般同享变量写操作刷入主内存对其他处理器可见
    • 假如都换成一般写操作,则过程1和2或许重排序,导致多线程常见下的可见性问题
  • 在这个 volatile 写操作之后会刺进StoreLoad屏障
  • 制止当时这个 volatile 写操作和接下来或许存在的其他 volatile 读/写操作重排序。
  1. 过程3是对volatile变量进行读操作:
  • 在这个 volatile 读操作之后连续刺进两个屏障,先是LoadLoad屏障,接着是LoadStore屏障
  • LoadLoad屏障制止后边一切一般读操作和该volatile读操作重排序
  • LoadStore屏障制止后边一切一般写操作和该volatile读操作重排序

CPU 仅仅供给了内存屏障这个才能,但它自己没办法自己判别应在何时何地增加内存屏障,所以 CPU 把决定权交给了软件运用层面。各种处理器架构是有差异的,所以 JVM 在此基础上笼统出了 Java 内存模型 JMM 来屏蔽这些底层硬件层面的差异。

问题:到了 JVM 软件层,是怎么打通和硬件层的联系呢?也便是软件层是怎么调用底层处理器的内存屏障指令来完成可见性、有序性的?

答:在Java 中是经过对同享变量增加 volatile 修饰符来刺进内存屏障的:有了这个关键字,JVM 会在汇编层面会生层增加一个 lock 前缀指令来调用 CPU 层的内存屏障指令。

七、lock 前缀指令

lock 指令前缀有两大效果:

  1. 开启总线锁或许缓存锁:经过在总线上发送 #LOCK 信号
  2. 与被 lock 修饰的汇编指令一同供给内存屏障的效果

就这样在 CPU 层面确保了多核 CPU 缓存的共同性。

上面这些都是由 JMM 来进行标准和控制的。

八、Java 内存模型 JMM

缓存共同性协议是在硬件层面就现已存在的问题,JVM 标准为了屏蔽掉各种硬件和操作系统的内存拜访差异,提出了 JMM,Java Memory Model 虚拟机内存模型。

下面为 Java 内存模型图:

volatile底层原理:从CPU架构到内存屏障之旅

JMM 界说了线程主内存之间的联系:线程间同享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存存储了同享变量的副本,以供该线程进行读/写操作。

在这个Java 内存模型中,线程对同享变量的操作有必要在自己的本地内存中,不能直接操作主内存的同享变量。

线程间同享变量值的传递是经过主内存来完成的,这也是线程间的通讯机制:同享主内存来完成线程间通讯。

8.1、Java 内存模型 vs 多核CPU缓存架构

volatile底层原理:从CPU架构到内存屏障之旅

从比照图能够看出,软件笼统层面的JMM 和 硬件层面的 CPU 缓存架构的每一层都是存在对应联系的。

8.2、JMM处理了什么问题?

JMM 处理了原子性、可见性、有序性这三方面的问题。

原子性、可见性、有序性也称为 JMM 的三大特性。

  • 原子性

JMM 经过关键字 synchronized 完成原子性。底层经过 monitorenter/monitorexit 指令来完成

  • 可见性

同享变量的修正对其他线程可见。JMM 经过 volatile 关键字确保对同享变量的修正能够马上改写到主内存,且其他线程操作同享变量有必要从主内存获取最新值。

  • 有序性

制止重排序,确保可见性,底层是内存屏障。

总结:从CPU缓存架构到内存屏障发展过程

  1. 多个处理共用一个主内存,主内存拜访速度太慢,所以引进高速缓存,以缓存行为单位。
  1. 因为处理器在各自的高速缓存保存了一份同享变量的副本,产生了缓存共同性问题,所以在处理器层面引进了缓存共同性协议(Cache Coherence Protocol)。
  1. MESI 协议处理了缓存共同性问题,但本身存在功用缺点:堵塞。
  • 例如,假如 CPU0 产生 LW 时,首要会发 Invalidate 音讯给到其他缓存了该数据的 CPU1,而且要等候 CPU1 的承认回执。CPU0 在这段时间内都会处于堵塞状况。
  • 为了处理该问题,硬件设计者引进了写缓冲区Store Buffer 和失效行列 Invalidate Queue:
    • 写缓冲区 Store Buffer:每个处理器都有各自的写缓冲区。不用等候其他 CPU 的 Invalidate 承认回执,直接写入 Store Buffer,防止写堵塞。
    • 失效行列 Invalidate Queue:处理器收到 Invalidate 音讯之后,并不当即 Invalidate Cache Line(这部操作相对较耗时),而是马大将音讯入队,一同发送承认回执,之后再处理这些音讯,和 Store Buffer 结合经过异步的方法减少了写操作的等候时间,处理了 CPU 堵塞的功用问题。
  1. 写缓冲区和无效行列的引进又会带来新问题:内存重排序和可见性问题。
  • 内存重排序:写缓冲区和失效行列都或许导致内存重排序。
    • 多线程场景下,内存重排序或许导致线程安全问题,造成线程间可见性问题。
  1. 为了处理内存重排序和可见性问题,处理器引进内存屏障机制,供给内存屏障指令给软件层去调用

  2. 以上是CPU 硬件层面,JVM 软件层面则是经过 lock 前缀指令来调用硬件层内存屏障指令

  1. 在 Java 中能够经过对同享变量增加 volatile 修饰符来完成
  • JVM 会对 volatile 同享变量的读写操作增加 lock 前缀指令

以上便是从硬件到软件的一个闭环。

JVM对 synchronized、volatile 和 final 关键字的语义的完成便是凭借内存屏障的。