volatile实现
volatile关键字有两方面的作用,一是保证共享变量可见性,二是禁止指令重排。
一、内存可见性
站在一个java程序员的角度,内存可见性应该从两个方面去理解,多核CPU的缓存一致性,以及JMM多线程线程栈本地内存与主存一致性。
1.1、CPU缓存一致性
现代CPU多采用多级缓存架构,
cpu缓存架构
缓存大大缩小了高速CPU与低速内存之间的差距。以三层缓存架构为例:
L1 Cache最接近CPU, 容量最小(如32K、64K等)、速度最高,每个核上都有一个L1 Cache。
L2 Cache容量更大(如256K)、速度更低, 一般情况下,每个核上都有一个独立的L2 Cache。
L3 Cache最接近内存,容量最大(如12MB),速度最低,在同一个CPU插槽之间的核共享一个L3 Cache。
在这种架构下,可能会存在下面的问题:
1、Core0与Core1命中了内存中的同一个地址,那么各自的L1 Cache会缓存同一份数据的副本。
2、最开始,Core0与Core1都在友善的读取这份数据。
3、突然,Core0要使坏了,它修改了这份数据,因为缓存的存在,这个修改并不会马上同步到主存,二十仅仅修改了Core 0 自己的L1 Cache中的值,此时Core1如果还继续以自己L1 Cache中的数据为准,必然导致错误的结果。
如何解决这个问题呢?缓存一致性协议MESI。
关于缓存一致性协议,可参考下面这篇博客,写得非常好。
https://www.cnblogs.com/yanlong300/p/8986041.html
简单来讲,就是当某一核改变了共享变量的值,cpu会发出一个指令,让其他核心L1 Cache中保存的值变成失效状态,当其他核心需要读取这个值时,需要到主存中重新加载。
1.2、工作内存与主存的一致性
Java工作内存与主存.pngjava每个线程的工作内存都会有一个共享变量的备份,若一个线程中的值改变了而未同步到主存,另一个线程可能会读到脏数据。
若共享变量被定义未volatile,则:
写一个volatile变量时,JMM会把线程对应的工作内存中的变量值刷新到主存。
读一个volatile变量时,JMM会把线程对应的本地内存置为无效,然后从主存中读取该变量。
二、指令重排
指令重排分为编译器重排与处理器重排,JMM制定了如下的volatile重排序规则:
是否允许重排序 | 第二个操作 | 第二个操作 | 第二个操作 |
---|---|---|---|
第一个操作 | 普通读/写 | volatile读 | volatile写 |
普通读写 | 是 | 是 | 否 |
volatile读 | 否 | 否 | 否 |
volatile写 | 是 | 否 | 否 |
可以看出:
1、当第二个操作是volatile写时,不论第一个操作是什么,都不允许重排。
2、当第一个操作是volatile读时,不论第二个操作是什么,都不允许重排。
3、当第一个操作是volatile写,第二个操作是volatile读时,不允许重排。
Java编译器通过插入内存屏障来实现以上规则。JMM内存屏障分为四种:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保load1先于load2 |
StoreStore Barriers | Store1; StoreStore; Store2 | 确保store1先于store2 |
LoadStore Barriers | Load1; LoadStore; Store2 | 确保load1先于Store2 |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 确保store1先于load2 |
采用以下策略进行内存屏障插入:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的前面插入一个Load Load屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
参考
https://www.jianshu.com/p/64240319ed60
https://www.cnblogs.com/yanlong300/p/8986041.html
《Java并发编程的艺术》