volatile 如果解决内存可见性和重排序? https://www.jianshu.com/p/64240319ed60
一文解决内存屏障
内存屏障是硬件之上、操作系统或JVM之下,对并发作出的最后一层支持。再向下是是硬件提供的支持;
向上是操作系统或JVM对内存屏障作出的各种封装。内存屏障是一种标准,
本文仅为了帮助理解JVM提供的并发机制。首先,从volatile的语义引出可见性与重排序问题;接下来,阐述问题的产生原理,
了解为什么需要内存屏障;然后,浅谈内存屏障的标准、厂商对内存屏障的支持,并以volatile为例讨论内存屏障如何解决这些问题;
最后,补充介绍JVM在内存屏障之上作出的几个封装。为了帮助理解,会简要讨论硬件架构层面的一些基本原理
(特别是CPU架内存屏障的实现涉及大量硬件架构层面的知识,又需要操作系统或JVM的配合才能发挥威力,单纯从任何一个层面都无法理解。本文整合了这三个层面的大量知识,篇幅较长,希望能在一篇文章内,把内存屏障的基本问题讲述清楚。
如有疏漏,还望指正!
volatile变量规则
一个用于引出内存屏障的好例子是volatile变量规则。
volatile关键字可参考猴子刚开博客时的文章volatile关键字的作用、原理。
volatile变量规则描述了volatile变量的偏序语义;这里从volatile变量规则的角度来讲解,顺便做个复习。
定义
volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行。
volatile变量规则只是一种标准,要求JVM实现保证volatile变量的偏序语义。结合程序顺序规则、传递性,该偏序语义通常表现为两个作用:
保持可见性
禁用重排序(读操作禁止重排序之后的操作,写操作禁止重排序之前的操作)
补充:
程序顺序规则:如果程序中操作A在操作B之前,那么在线程中操作A将在操作B之前执行。
传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。
后文,如果仅涉及可见性,则指明“可见性”;如果二者均涉及,则以“偏序”代称。重排序一定会带来可见性问题,
因此,不会出现单独讨论重排序的场景
正确姿势
之前的文章多次涉及volatile变量规则的用法。
简单的仅利用volatile变量规则对volatile变量本身的可见性保证:
面试中单例模式有几种写法?:“饱汉 - 变种 3”在DCL的基础上,使用volatile修饰单例,以保证单例的可见性。
复杂的利用volatile变量规则(结合了程序顺序规则、传递性)保证变量本身及周围其他变量的偏序:
源码|并发一枝花之ReentrantLock与AQS(1):lock、unlock:exclusiveOwnerThread借助于volatile变量state保证其相对于state的偏序。
源码|并发一枝花之CopyOnWriteArrayList:CopyOnWriteArrayList借助于volatile变量array,对外提供偏序语义。
可见性与重排序
前文多次提到可见性与重排序的问题,内存屏障的存在就是为了解决这些问题。到底什么是可见性?什么是重排序?为什么会有这些问题?
可见性
定义
可见性的定义常见于各种并发场景中,以多线程为例:当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。
从性能角度考虑,没有必要在修改后就立即同步修改的值——如果多次修改后才使用,那么只需要最后一次同步即可,
在这之前的同步都是性能浪费。因此,实际的可见性定义要弱一些,只需要保证:
当一个线程修改了线程共享变量的值,其它线程在使用前,能够得到最新的修改值。
可见性可以认为是最弱的“一致性”(弱一致),只保证用户见到的数据是一致的,
但不保证任意时刻,存储的数据都是一致的(强一致)。下文会讨论“缓存可见性”问题,部分文章也会称为“缓存一致性”问题。
问题来源
一个最简单的可见性问题来自计算机内部的缓存架构:
缓存大大缩小了高速CPU与低速内存之间的差距。以三层缓存架构为例:
L1 Cache最接近CPU, 容量最小(如32K、64K等)、速度最高,每个核上都有一个L1 Cache。
L2 Cache容量更大(如256K)、速度更低, 一般情况下,每个核上都有一个独立的L2 Cache。
L3 Cache最接近内存,容量最大(如12MB),速度最低,在同一个CPU插槽之间的核共享一个L3 Cache。
准确地说,每个核上有两个L1 Cache, 一个存数据 L1d Cache, 一个存指令 L1i Cache。
单核时代的一切都是那么完美。然而,多核时代出现了可见性问题。一个badcase如下:
Core0与Core1命中了内存中的同一个地址,那么各自的L1 Cache会缓存同一份数据的副本。
最开始,Core0与Core1都在友善的读取这份数据。
突然,Core0要使坏了,它修改了这份数据,使得两份缓存中的数据不同了,更确切的说,Core1 L1 Cache中的数据失效了。
单核时代只有Core0,Core0修改Core0读,没什么问题;但是,现在Core0修改后,Core1并不知道数据已经失效,继续傻傻的使用,
轻则数据计算错误,重则导致死循环、程序崩溃等。
实际的可见性问题还要扩展到两个方向:
除三级缓存外,各厂商实现的硬件架构中还存在多种多样的缓存,都存在类似的可见性问题。例如,寄存器就相当于CPU与L1 Cache之间的缓存。
各种高级语言(包括Java)的多线程内存模型中,在线程栈内自己维护一份缓存是常见的优化措施,但显然在CPU级别的缓存可见性问题面前,一切都失效了。
以上只是最简单的可见性问题,不涉及重排序等。
重排序也会导致可见性问题;同时,缓存上的可见性也会引起一些看似重排序导致的问题。
重排序
定义
重排序并没有严格的定义。整体上可以分为两种:
真·重排序:编译器、底层硬件(CPU等)出于“优化”的目的,按照某种规则将指令重新排序(尽管有时候看起来像乱序)。
伪·重排序:由于缓存同步顺序等问题,看起来指令被重排序了。
重排序也是单核时代非常优秀的优化手段,有足够多的措施保证其在单核下的正确性。
在多核时代,如果工作线程之间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。
然而,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,会导致工作线程似乎表现出了随机行为。
第一次接触重排序的概念一定很迷糊,耐心,耐心。
问题来源
重排序问题无时无刻不在发生,源自三种场景:
编译器编译时的优化
处理器执行时的乱序优化
缓存同步顺序(导致可见性问题)
场景1、2属于真·重排序;场景3属于伪·重排序。场景3也属于可见性问题,为保持连贯性,我们先讨论场景3。
可见性导致的伪·重排序
缓存同步顺序本质上是可见性问题。
假设程序顺序(program order)中先更新变量v1、再更新变量v2,不考虑真·重排序:
Core0先更新缓存中的v1,再更新缓存中的v2(位于两个缓存行,这样淘汰缓存行时不会一起写回内存)。
Core0读取v1(假设使用LRU协议淘汰缓存)。
Core0的缓存满,将最远使用的v2写回内存。
Core1的缓存中本来存有v1,现在将v2加载入缓存。
重排序是针对程序顺序而言的,如果指令执行顺序与程序顺序不同,就说明这段指令被重排序了。
此时,尽管“更新v1”的事件早于“更新v2”发生,但Core1只看到了v2的最新值,却看不到v1的最新值。这属于可见性导致的伪·重排序:虽然没有实际上没有重排序,但看起来发生了重排序。
可以看到,缓存可见性不仅仅导致可见性问题,还会导致伪·重排序。因此,只要解决了缓存上的可见性问题,也就解决了伪·重排序。
MESI协议
回到可见性问题中的例子和可见性的定义。要解决这个问题很简单,套用可见性的定义,只需要:在Core0修改了数据v后,让Core1在使用v前,能得到v最新的修改值。
这个要求很弱,既可以在每次修改v后,都同步修改值到其他缓存了v的Cache中;又可以只同步使用前的最后一次修改值。后者性能上更优,如何实现呢:
Core0修改v后,发送一个信号,将Core1缓存的v标记为失效,并将修改值写回内存。
Core0可能会多次修改v,每次修改都只发送一个信号(发信号时会锁住缓存间的总线),Core1缓存的v保持着失效标记。
Core1使用v前,发现缓存中的v已经失效了,得知v已经被修改了,于是重新从其他缓存或内存中加载v。
以上即是MESI(Modified Exclusive Shared Or Invalid,缓存的四种状态)协议的基本原理,不算严谨,但对于理解缓存可见性(更常见的称呼是“缓存一致性”)已经足够。
MESI协议解决了CPU缓存层面的可见性问题。
以下是MESI协议的缓存状态机,简单看看即可:
内存屏障相当于消除编译器对于变量存入寄存器的优化
image.png
状态:
M(修改, Modified): 本地处理器已经修改缓存行, 即是脏行, 它的内容与内存中的内容不一样. 并且此cache只有本地一个拷贝(专有)。
E(专有, Exclusive): 缓存行内容和内存中的一样, 而且其它处理器都没有这行数据。
S(共享, Shared): 缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝。
I(无效, Invalid): 缓存行失效, 不能使用。
==================================================== volatile ==========================================================
volatile 写 - 读建立的 happens before 关系
上面讲的是 volatile 变量自身的特性,对程序员来说,volatile 对线程的内存可见性的影响比 volatile 自身的特性更为重要,
也更需要我们去关注。
从 JSR-133 开始,volatile 变量的写 - 读可以实现线程之间的通信。
从内存语义的角度来说,volatile 与监视器锁有相同的效果:
volatile 写和监视器的释放有相同的内存语义;
volatile 读与监视器的获取有相同的内存语义。
class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
int i = a; //4
……
}
}
}
volatile 写 - 读的内存语义
volatile 写的内存语义如下:
当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存。
以上面示例程序 VolatileExample 为例,假设线程 A 首先执行 writer() 方法,随后线程 B 执行 reader() 方法,
初始时两个线程的本地内存中的 flag 和 a 都是初始状态。下图是线程 A 执行 volatile 写后,共享变量的状态示意图:
如果我们把 volatile 写和 volatile 读这两个步骤综合起来看的话,在读线程 B 读一个 volatile 变量后,
写线程 A 在写这个 volatile 变量之前所有可见的共享变量的值都将立即变得对读线程 B 可见。
下面对 volatile 写和 volatile 读的内存语义做个总结:
线程 A 写一个 volatile 变量,实质上是线程 A 向接下来将要读这个 volatile 变量的某个线程发出了(其对共享变量所在修改的)消息。
线程 B 读一个 volatile 变量,实质上是线程 B 接收了之前某个线程发出的(在写这个 volatile 变量之前对共享变量所做修改的)消息。
线程 A 写一个 volatile 变量,随后线程 B 读这个 volatile 变量,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。
volatile 内存语义的实现
下面,让我们来看看 JMM 如何实现 volatile 写 / 读的内存语义。
前文我们提到过重排序分为编译器重排序和处理器重排序。为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型。
下面是 JMM 针对编译器制定的 volatile 重排序规则表:
是否能重排序 第二个操作 第一个操作 普通读 / 写 volatile 读 volatile 写 普通读 / 写 NO volatile 读 NO NO NO volatile 写 NO NO 举例来说,
第三行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为 volatile 写,则编译器不能重排序这两个操作。
上图中的 StoreStore 屏障可以保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见了。
这是因为 StoreStore 屏障将保障上面所有的普通写在 volatile 写之前刷新到主内存。
这里比较有意思的是 volatile 写后面的 StoreLoad 屏障。这个屏障的作用是避免 volatile 写与后面可能有的 volatile 读 / 写操作重排序。
因为编译器常常无法准确判断在一个 volatile 写的后面,是否需要插入一个 StoreLoad 屏障(比如,一个 volatile 写之后方法立即 return)。
为了保证能正确实现 volatile 的内存语义,
JMM 在这里采取了保守策略:在每个 volatile 写的后面或在每个 volatile 读的前面插入一个 StoreLoad 屏障。
从整体执行效率的角度考虑,JMM 选择了在每个 volatile 写的后面插入一个 StoreLoad 屏障。因为 volatile 写 - 读内存语义的常见使用模式是:
一个写线程写 volatile 变量,多个读线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,
选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升。从这里我们可以看到 JMM 在实现上的一个特点:
首先确保正确性,然后再去追求执行效率。
锁是 java 并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
下面是锁释放 - 获取的示例代码:
class MonitorExample {
int a = 0;
public synchronized void writer() { //1
a++; //2
} //3
public synchronized void reader() { //4
int i = a; //5
……
} //6
}
假设线程 A 执行 writer() 方法,随后线程 B 执行 reader() 方法。根据 happens before 规则,这个过程包含的 happens before 关系可以分为两类:
根据程序次序规则,1 happens before 2, 2 happens before 3; 4 happens before 5, 5 happens before 6。
根据监视器锁规则,3 happens before 4。
根据 happens before 的传递性,2 happens before 5。
上述 happens before 关系的图形化表现形式如下:
public class FinalExample {
int i; // 普通变量
final int j; //final 变量
static FinalExample obj;
public void FinalExample () { // 构造函数
i = 1; // 写普通域
j = 2; // 写 final 域
}
public static void writer () { // 写线程 A 执行
obj = new FinalExample ();
}
public static void reader () { // 读线程 B 执行
FinalExample object = obj; // 读对象引用
int a = object.i; // 读普通域
int b = object.j; // 读 final 域
}
}
写 final 域的重排序规则
写 final 域的重排序规则禁止把 final 域的写重排序到构造函数之外。这个规则的实现包含下面 2 个方面:
JMM 禁止编译器把 final 域的写重排序到构造函数之外。
编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。
现在让我们分析 writer () 方法。writer () 方法只包含一行代码:finalExample = new FinalExample ()。这行代码包含两个步骤:
构造一个 FinalExample 类型的对象;
把这个对象的引用赋值给引用变量 obj。
JMM 向程序员提供的 happens- before 规则能满足程序员的需求。JMM 的 happens- before 规则不但简单易懂,
而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的 A happens- before B)。
JMM 对编译器和处理器的束缚已经尽可能的少。从上面的分析我们可以看出,JMM 其实是在遵循一个基本原则:
只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。
比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再比如,
如果编译器经过细致的分析后,认定一个 volatile 变量仅仅只会被单个线程访问,那么编译器可以把这个 volatile 变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。
Volatile的重排序
1、当第二个操作为volatile写操做时,不管第一个操作是什么(普通读写或者volatile读写),都不能进行重排序。
这个规则确保volatile写之前的所有操作都不会被重排序到volatile之后;
2、当第一个操作为volatile读操作时,不管第二个操作是什么,都不能进行重排序。这个规则确保volatile读之后的所有操作都不会被重排序到volatile之前;
3、当第一个操作是volatile写操作时,第二个操作是volatile读操作,不能进行重排序。
这个规则和前面两个规则一起构成了:两个volatile变量操作不能够进行重排序;
除以上三种情况以外可以进行重排序。
比如:
1、第一个操作是普通变量读/写,第二个是volatile变量的读;
2、第一个操作是volatile变量的写,第二个是普通变量的读/写;
————————————————
版权声明:本文为CSDN博主「qinjianhuang」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/sinat_35512245/article/details/60325685
volatile总结
监视器锁的 happens-before 规则保证释放监视器和获取监视器的两个线程之间的内存可见性,这意味着对一个 volatile 变量的读,
总是能看到(任意线程)对这个 volatile 变量最后的写入。
volatile 变量自身具有下列特性:
可见性。对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。
原子性:对任意单个 volatile 变量的读 / 写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。
编译器重排序 -- https://blog.csdn.net/CringKong/article/details/99759216
java内存模型 -- https://www.infoq.cn/news/java-memory-model-4
内存屏障 -- https://www.jianshu.com/p/64240319ed60
Volatile的重排序 -- https://blog.csdn.net/sinat_35512245/article/details/60325685
https://blog.csdn.net/ityouknow/article/details/88840168
java interview -- https://blog.csdn.net/sufu1065/article/details/88051083