volatile,还可以有这么硬的理解
volatile 关键字作为 Java 虚拟机提供的轻量级同步机制,在 Java 并发编程中占据着重要的地位,但是深入理解 volatile 可不是一件简单的事,了解 volatile 的同学都知道,volatile 变量保证了可见性,而可见性又与 Java 内存模型息息相关,所以本文先简单介绍内存模型相关概念,然后再从 Java 虚拟机层面剖析分析 volatile 变量,接着从硬件层面出发,带你层层深入了解 volatile 及其背后的故事。
1、计算机内存模型与 Java 内存模型的关系
由于现代计算机处理器与存储设备的运算速度存在几个数量级的差异,所以现代计算机都会在处理器与主内存之间加上高速缓存作为缓冲:将处理器计算所需数据复制到高速缓存,处理器直接从高速缓存中获取数据计算,同时处理器将计算结果放入缓存,再由缓存同步至主内存。
Java 虚拟机为了达到“一次编译,到处运行”的目的,也有自己的内存模型,即 Java 内存模型(JMM)。Java 内存模型作为一种规范,屏蔽了各种操作系统和硬件的内存访问规则,是计算机内存模型的一种逻辑抽象。它规定所有的变量都必须存在主内存中,每个 Java 线程都有自己的工作内存,工作内存中存放了所需变量的副本,Java 线程对变量的操作必须在工作内存中,而不能直接操作主内存。
image
如上图所示,虽然这两种内存模型都能够解决运算速度不匹配的问题,但随之而来就是缓存不一致问题:多个处理器都有自己的高速缓存,但他们又共享同一主内存,从而造成了变量修改不可见问题。为了解决缓存不一致问题,需要处理器在处理缓存时满足缓存一致性协议,例如 MESI 协议。既然有缓存一致性协议的存在,为什么还需要 volatile 关键字来保证变量的可见性呢?
2、volatile 变量特征
首先我们来说一下 volatile 变量具备以下特征:
可见性 ,对于 volatile 变量的读,线程总是能读到当前最新的 volatile 值,也就是任一线程对 volatile 变量的写入对其余线程都是立即可见;
有序性,禁止编译器和处理器为了提高性能而进行指令重排序;
基本不保证原子性,由于存在 long/double 非原子性协议,long/double 在 32 位 x86 的 hotspot 虚拟机下允许没有被 volatile 修饰的变量读写操作划分为两次进行。但是从 JDK9 开始,hotspot 也明确约束所有数据类型访问保持原子性,所以 volatile 变量保证原子性可以基本忽略。
那么,volatile 变量是怎么保证变量的可见性和有序性的?
3、深入剖析 volatile 变量
从 Java 内存模型层面来说: Java 内存模型保证了 volatile 变量的可见性,也就是说 JMM 保证新值能马上同步到主内存,同时把其他线程的工作内存中对应的变量副本置为无效,以及每次使用前立即从主内存读取共享变量,那 JMM 又是如何达到这个目的呢?
有序性,编译器和处理器为了提高运算性能都会对不存在数据依赖的操作进行指令重排优化,在 Java 内存模型中,通过 as-if-serial 和 happens-before(先行先发生) 来保证从重排的正确性,同时对于 volatile 变量有特殊的规则:对一个变量的写操作先行发生于后面对这个变量的读操作,那么 Java 内存模型底层是如何实现这一特殊规则的呢?答案就是内存屏障(Memory Barrier)。在 Java 内存模型中,主要有以下 4 种类型的内存屏障:
LoadLoad 屏障: 对于 Load1,LoadLoad,Load2 这样的语句,在 Load2 及后续读取操作前要保证 Load1 要读取的数据读取完毕;
LoadStore 屏障: 对于 Load1,LoadStore,Store2 这样的语句,在 Store2 及后续写入操作前要保证 Load1 要读取的数据读取完毕;
StoreStore 屏障: 对于 Store1,StoreStore,Store2 这样的语句,在 Store2 及后续写入操作前要保证 Store1 的写入操作对其他处理器可见;
StoreLoad 屏障: 对于 Store1,StoreLoad,Load2 这样的语句,在 Load2 及后续读取操作前,Store1 的写入对所有处理器可见。
image-20210109195819646
到这里是不是可以发现:JMM 对于 volatile 变量的可见性及有序性都是通过内存屏障来实现的。
接着,深入分析 volatile 底层原理,从机器码的层面看看,对于 volatile 变量的特性是怎么实现的,首先我们先看一段代码如下:
上述程序用 20 个线程对 volatile 变量 race 进行累加,每个线程累加 10000 次,如果能正确的并发执行的话应该是 200000 才对,最后多次运行结果都是一个小于 200000 的数字
image-20210108152530761
从这里也能看出,volatile 变量并不能保证原子性,将上面的代码经过 JITWatch 工具得到汇编语句如下:
image-20210108194550071
通过汇编指令可以看出,被 volatile 修饰有一个 lock 指令前缀,lock 指令的作用是将本地处理器的缓存写入内存,同时将其他处理器的缓存失效,这样其他处理需要数据计算时,必须重新读取主内存的数据,从而达到了变量的可见性的目的;对于禁止指令重排序,同样也是通过整条 lock 指令(lock add1$0x0, (%rsp))形成一条内存屏障,来禁止指令重排。
到此,我们已经分析了 volatile 变量具有的特性,以及 JMM 是怎么来实现 volatile 变量的特性。但是对于文章开头提出的,既然有缓存一致性协议来保证缓存的一致性,为什么还需要由 volatile 来保证变量的可见性这个问题好像还是没有答案。接下来将是本文的重点,从硬件层面出发,带你了解高速缓存、MESI 协议等原理,层层深入,看完以后一定会对 volatile 变量有更加深入的理解。
4、高速缓存结构与 MESI 协议分析
首先高速缓存的内部结构如下所示:
image-cache-struct
高速缓存内部是一个拉链散列表,是不是很眼熟,是的,和 HashMap 的内部结构十分相似,高速缓存中分为很多桶,每个桶里用链表的结构连接了很多 cache entry,在每一个 cache entry 内部主要由三部分内容组成:
tag: 指向了这个缓存数据在主内存中的数据的地址
cache line: 存放多个变量数据
flag: 缓存行状态
由此引出了 MESI 缓存一致性协议,MESI 协议对所有处理器有如下约定:
各个处理器在操作内存数据时,都会往总线发送消息,各个处理器还会不停的从总线嗅探消息,通过这个消息来保证各个处理器的协作。
同时 MESI 中有以下两个操作:
flush 操作: 强制处理器在更新完数据后,将更新的数据(可能写缓冲器、寄存器中)刷到高速缓存或者主内存(不同的硬件实现 MESI 协议的方式不一样),同时向总线发出信息说明自己修改了某一数据
refresh 操作: 从总线嗅探到某一数据失效后,将该数据在自己的缓存中失效,然后从更新后的处理器高速缓存或主内存中加载数据到自己的高速缓存中
接下来我们来说明在两个处理器情况下,其中一个处理器(处理器 0)要修改数据的整个过程。假定数据所在 cache line 在两个高速缓存中都处于 S(Shared)状态。
cpu_process
1、处理器 0 发送 invalidate 消息到总线;
2、处理器 1 在总线上进行嗅探,嗅探到 invalidate 消息后,通过地址解析定位到对应的 cache line,发现此时 cache line 的状态为 S,则将 cache line 的状态改为 I,同时返回 invalidate ack 消息到总线;
3、处理器 0 在总线在嗅探到所有(例子中只有处理器 1)的 invalidate ack 后,将要修改的 cache line 状态置为 E(Exclusive),表示要进行独占修改,修改完以后将 cache line 状态置为 M(Modified),同时可能将数据刷回主内存。
在这个过程中,如有其他处理器要修改处理器 0 中的 cache line 状态将会被阻塞。
同时,假如此时处理器 1 要读取相应的 cache line 数据,则会发现状态为 I(Invalid)。于是处理器 1 向总线中发出 read 消息,处理器 0 嗅探到 read 消息后,将会从自己的高速缓存或者主内存中将数据发送到总线,并将自身对应的 cache line 状态置为 S(Shared),处理器 1 从总线中接收到 read 消息后,将最新的数据写入到对应的 cache line,并将状态置为 S(Shared)。由此处理 0 与处理器 1 中对应的 cache line 状态又都变成了 S(Shared)。
更新和读取数据的过程如下所示:
image-20210109211606795
image-20210109211645122
MESI 协议能保证各个处理器间的高速缓存数据一致性,但是同样带来两个严重的效率问题:
当处理器 0 向总线发送 invalidate 消息后,要等到所有其他拥有相同缓存的处理器返回 invalidate ack 消息才能将对应的 cache line 状态置为 E 并进行修改,但是在这过程中它一直是处于阻塞状态,这将严重影响处理器的性能
当处理 1 嗅探到 invalidate 消息后,会先去将对应的 cache line 状态置为 I,然后才会返回 invalidate ack 消息到总线,这个过程也是影响性能的。 基于以上两个问题,设计者又引入了写缓冲器和无效队列。 在上面的场景中,处理器 0,先将要修改的数据放入写缓冲器,再向总线发出 invalidate 消息来通知其他有相同缓存的处理器缓存失效,处理器 0 就可以继续执行其他指令,当接收到其他所有处理器的 invalidate ack 后,再将处理器 0 中的 cache line 置为 E,并将写缓冲器中的数据写入高速缓存。处理器 1 从总线嗅探到 invalidate 消息后,先将消息放入到无效队列,接着立刻返回 invalidate ack 消息。这样来提高处理的速度,达到提高性能的目的。
image-20210110143559471
写缓冲器和无效队列带来的问题:
写缓冲器和无效队列提高 MESI 协议下处理器性能,但同时也带来了新的可见性与有序性问题如下:
image-20210110150401017
如上图所示:假设最初共享变量 x=0 同时存在于处理 0 和处理 1 的高速缓存中,且对应状态为 S(Shared),此时处理 0 要将 x 的值改变成 1,先将值写到写缓冲器里,然后向总线发送 invalidate 消息,同时处理器 1 希望将 x 的值加 1 赋给 y,此时处理器 1 发现自身缓存中 x=0 状态为 S,则直接用 x=0 进行参与计算,从而发生了错误,显然这个错误由写缓冲器和无效队列导致的,因为 x 的新值还在写缓冲器中,无效消息在处理 1 的无效队列中。
为了解决这个问题出现了写屏障(Store Barrier)和读屏障(Load Barrier)两种内存屏障。
写屏障:强制将写缓冲器中的内容写入到高速缓存中,或者将屏障之后的指令全部写到写缓冲器直到之前写缓冲器中的内容全部被刷回缓存中,也就是处理 0 必须等到所有的 invalidate ack 消息后,才能执行后续的操作,相当于 flush 操作;
读屏障:处理器在读取数据前,必须强制检查无效队列中是否有 invalidate 消息,如果有必须先处理完无效队列汇总的无效消息,再进行数据读取,相当于 refresh 操作。
通过加入读写屏障保证了可见性与有序性。之所以说保证了有序性,是因为指令乱序现象就是写缓冲器异步接收到其他处理器中的 invalidate ack 消息后,再执行写缓冲器中的内容,导致本应该执行的指令顺序发生错乱。通过加入写屏障后保证了异步操作之后才能执行后续的指令,保证了原来的指令顺序。
在分析 JMM 保证 volatile 变量的有序性和可见性问题时,同样我们也说到是通过四种内存屏障的来实现的,那么上面的读/写屏障和 JMM 中四种内存屏障有什么关联呢?
写屏障与(StoreStore、StoreLoad)屏障的关系:在 volatile 变量写之前加入 StoreSore 屏障保证了 volatile 写之前,写缓冲器中的内容已全部刷回告诉缓存,防止前面的写操作和 volatile 写操作之间发生指令重排,在 volatile 写之后加入 StoreLoad 屏障,保证了后面的读/写操作与 volatile 写操作发生指令重排,所以写屏障同时具有 StoreStore 与 StoreLoad 的功能
读屏障与(LoadLoad、LoadStore)屏障的关系:在 volatile 变量读之后加入 LoadLoad 屏障保证了后面其他读操作的无效队列中无效消息已经被刷回到了高速缓存,在 volatile 变量读操作后加入 LoadStore 屏障,保证了后面其他写操作的无效队列中无效消息已经被刷回高速缓存。读屏障同时具有了 LoadLoad,LoadStore 的功能。
到这里,对于文章开头提出:既然存在 MESI 缓存一致性协议为什么还要 volatile 关键字来保证可见性和有序性的问题是不是就很清楚了呢?
作者:肖说一下
链接:https://juejin.cn/post/6919432286232379400
评论