Volatile与Java内存模型
# Volatile 与 Java 内存模型
# 被 volatile 修改的变量有 2 大特点
- 可见性
- 有序性(排序要求)
volatile 的内存语义,当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。当读一个 volatile 变量时,JMM 会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量。所以 volatile 的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。
# 内存屏障
内存屏障(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是 CPU 或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种 JVM 指令,Java 内存模型的重排规则会要求 Java 编译器在生成 JVM 指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile 实现了 Java 内存模型中的可见性和有序性,但 volatile 无法保证原子性。
内存屏障之前的所有写操作都要回写到主内存, 内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果 (实现了可见性)。

因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。 一句话:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读,也叫写后读。
# JVM 中提供了四类内存屏障指令
C++ 源码分析,IDEA 工具里面找 Unsafe.class


# 四大屏障分别是什么意思
| 屏障类型 | 指令示例 | 说明 |
|---|---|---|
| LoadLoad | Load1;LoadLoad;Load2 | 保证 load1 的读取操作在 load2 及后续读取操作之前执行 |
| StoreStore | Store1;StoreStore;Store2 | 在 store2 及其后的写操作执行前,保证 store1 的写操作已刷新到主内存 |
| LoadStore | Load1;LoadStore;Store2 | 在 stroe2 及其后的写操作执行前,保证 load1 的读操作已读取结束 |
| StoreLoad | Store1;StoreLoad;Load2 | 保证 store1 的写操作已刷新到主内存之后,Ioad2 及其后的读操作才能执行 |
orderAccess_linux_x86.inline.hpp

# happens-before 之 volatile 变量规则
| 第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile 读 | 第二个操作:volatile 写 |
|---|---|---|---|
| 普通读写 | 可以重排 | 可以重排 | 不可以重排 |
| volatile 读 | 不可以重排 | 不可以重排 | 不可以重排 |
| volatile 写 | 可以重排 | 不可以重排 | 不可以重排 |
当第一个操作为 volatile 读时,不论第二个操作是什么,都不能重排序。这个操作保证了 volatile 读之后的操作不会被重排到 volatile 读之前。
当第二个操作为 volatile 写时,不论第一个操作是什么,都不能重排序。这个操作保证了 volatile 写之前的操作不会被重排到 volatile 写之后。
当第一个操作为 volatile 写时,第二个操作为 volatile 读时,不能重排。
# 内存屏障插入策略分为 4 种
| 屏障类型 | 指令示例 | 说明 |
|---|---|---|
| LoadLoad | Load1;LoadLoad;Load2 | 保证 load1 的读取操作在 load2 及后续读取操作之前执行 |
| StoreStore | Store1;StoreStore;Store2 | 在 store2 及其后的写操作执行前,保证 store1 的写操作已刷新到主内存 |
| LoadStore | Load1;LoadStore;Store2 | 在 stroe2 及其后的写操作执行前,保证 load1 的读操作已读取结束 |
| StoreLoad | Store1;StoreLoad;Load2 | 保证 store1 的写操作已刷新到主内存之后,Ioad2 及其后的读操作才能执行 |
# 写
- 在每个 volatile 写操作的前面插入⼀个 StoreStore 屏障
- 在每个 volatile 写操作的后面插入⼀个 StoreLoad 屏障

# 读
- 在每个 volatile 读操作的后面插入⼀个 LoadLoad 屏障
- 在每个 volatile 读操作的后面插入⼀个 LoadStore 屏障

# volatile 特性
# 保证可见性
保证不同线程对这个变量进行操作时的可见性,即变量一旦改变所有线程立即可见
package com.zzyy.study.juc;
import java.util.concurrent.TimeUnit;
/**
* @auther zzyy
* @create 2020-06-30 11:29
*/
public class VolatileSeeDemo
{
static boolean flag = true; //不加volatile,没有可见性
//static volatile boolean flag = true; //加了volatile,保证可见性
public static void main(String[] args)
{
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"\t come in");
while (flag)
{
}
System.out.println(Thread.currentThread().getName()+"\t flag被修改为false,退出.....");
},"t1").start();
//暂停2秒钟后让main线程修改flag值
try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); }
flag = false;
System.out.println("main线程修改完成");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
不加 volatile,没有可见性,程序无法停止,加了 volatile,保证可见性,程序可以停止。
线程 t1 中为何看不到被主线程 main 修改为 false 的 flag 的值?
问题可能:
- 主线程修改了 flag 之后没有将其刷新到主内存,所以 t1 线程看不到。
- 主线程将 flag 刷新到了主内存,但是 t1 一直读取的是自己工作内存中 flag 的值,没有去主内存中更新获取 flag 最新的值。
我们的诉求:
- 线程中修改了工作内存中的副本之后,立即将其刷新到主内存;
- 工作内存中每次读取共享变量时,都去主内存中重新读取,然后拷贝到工作内存。
解决: 使用 volatile 修饰共享变量,就可以达到上面的效果,被 volatile 修改的变量有以下特点:
- 线程中读取的时候,每次读取都会去主内存中读取共享变量最新的值,然后将其复制到工作内存
- 线程中修改了工作内存中变量的副本,修改之后会立即刷新到主内存
# volatile 变量的读写过程
Java 内存模型中定义的 8 种工作内存与主内存之间的原子操作
read (读取)→load (加载)→use (使用)→assign (赋值)→store (存储)→write (写入)→lock (锁定)→unlock (解锁)

read: 作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存
load: 作用于工作内存,将 read 从主内存传输的变量值放入工作内存变量副本中,即数据加载
use: 作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当 JVM 遇到需要该变量的字节码指令时会执行该操作
assign: 作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当 JVM 遇到一个给变量赋值字节码指令时会执行该操作
store: 作用于工作内存,将赋值完毕的工作变量的值写回给主内存
write: 作用于主内存,将 store 传输过来的变量值赋值给主内存中的变量
由于上述只能保证单条指令的原子性,针对多条指令的组合性原子保证,没有大面积加锁,所以,JVM 提供了另外两个原子指令:
lock: 作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程。
unlock: 作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用
# 没有原子性
volatile 变量的复合操作 (如 i++) 不具有原子性
package com.zzyy.study.juc;
import java.util.concurrent.TimeUnit;
class MyNumber
{
volatile int number = 0;
public void addPlusPlus()
{
number++;
}
}
public class VolatileNoAtomicDemo
{
public static void main(String[] args) throws InterruptedException
{
MyNumber myNumber = new MyNumber();
for (int i = 1; i <=10; i++) {
new Thread(() -> {
for (int j = 1; j <= 1000; j++) {
myNumber.addPlusPlus();
}
},String.valueOf(i)).start();
}
//暂停几秒钟线程
try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println(Thread.currentThread().getName() + "\t" + myNumber.number);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
从 i++ 的字节码角度说明

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
public void add(){
i++; //不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分3步完成
}
2
3
如果第二个线程在第一个线程读取旧值和写回新值期间读取 i 的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加 1 操作,这也就造成了线程安全失败,因此对于 add 方法必须使用 synchronized 修饰,以便保证线程安全。

多线程环境下,"数据计算" 和 "数据赋值" 操作可能多次出现,即操作非原子。若数据在加载之后,若主内存 count 变量发生修改之后,由于线程工作内存中的值在此前已经加载,从而不会对变更操作做出相应变化,即私有内存和公共内存中变量不同步,进而导致数据不一致
对于 volatile 变量,JVM 只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的。 由此可见 volatile 解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改共享变量的场景必须使用加锁同步。
读取赋值一个普通变量的情况:
当线程 1 对主内存对象发起 read 操作到 write 操作第一套流程的时间里,线程 2 随时都有可能对这个主内存对象发起第二套操作

# 既然一修改就是可见,为什么还不能保证原子性?
要 use (使用) 一个变量的时候必需 load (载入),要载入的时候必需从主内存 read (读取)这样就解决了读的可见性。
写操作是把 assign 和 store 做了关联 (在 assign (赋值) 后必需 store (存储))。store (存储) 后 write (写入)。 也就是做到了给一个变量赋值的时候一串关联指令直接把变量值写到主内存。
就这样通过用的时候直接从主内存取,在赋值到直接写回主内存做到了内存可见性,蓝色框部分

# 读取赋值一个 volatile 变量的情况

read-load-use 和 assign-store-write 成为了两个不可分割的原子操作,但是在 use 和 assign 之间依然有极小的一段真空期,有可能变量会被其他线程读取,导致写丢失一次
但是无论在哪一个时间点主内存的变量和任一工作内存的变量的值都是相等的。这个特性就导致了 volatile 变量不适合参与到依赖当前值的运算,如 i = i + 1; i++; 之类的那么依靠可见性的特点 volatile 可以用在哪些地方呢? 通常 volatile 用做保存某个状态的 boolean 值 or int 值。
《深入理解 Java 虚拟机》提到:

JVM 的字节码,i++ 分成三步,间隙期不同步非原子操作 (i++)

# 指令禁重排
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序不存在数据依赖关系,可以重排序;存在数据依赖关系,禁止重排序,但重排后的指令绝对不能改变原有的串行语义!这点在并发设计中必须要重点考虑!
重排序的分类和执行流程

- 编译器优化的重排序: 编译器在不改变单线程串行语义的前提下,可以重新调整指令的执行顺序
- 指令级并行的重排序: 处理器使用指令级并行技术来讲多条指令重叠执行,若不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
- 内存系统的重排序: 由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是乱序执行
数据依赖性:若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性。
案例 : 不存在数据依赖关系,可以重排序 ===> 重排序 OK 。
重排前
int a = 1; //1
int b = 20; //2
int c = a + b; //3
2
3
重排后
int b = 20; //1
int a = 1; //2
int c = a + b; //3
2
3
结论:编译器调整了语句的顺序,但是不影响程序的最终结果。重排序 OK
存在数据依赖关系,禁止重排序 ===> 重排序发生,会导致程序运行结果不同。
编译器和处理器在重排序时,会遵守数据依赖性,不会改变存在依赖关系的两个操作的执行,但不同处理器和不同线程之间的数据性不会被编译器和处理器考虑,其只会作用于单处理器和单线程环境,下面三种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
| 名称 | 代码示例 | 说明 |
|---|---|---|
| 写后读 | a=1: b=a; | 写一个变量之后,再读这个位置 |
| 写后写 | a=1; a=2; | 写一个变量之后,再写这个变量 |
| 读后写 | a=b; b=1; | 读一个变量之后,再写这个变量 |
# volatile 有关的禁止指令重排的行为
volatile 的底层实现是通过内存屏障
| 第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile 读 | 第二个操作:volatile 写 |
|---|---|---|---|
| 普通读写 | 可以重排 | 可以重排 | 不可以重排 |
| volatile 读 | 不可以重排 | 不可以重排 | 不可以重排 |
| volatile 写 | 可以重排 | 不可以重排 | 不可以重排 |
当第一个操作为 volatile 读时,不论第二个操作是什么,都不能重排序。这个操作保证了 volatile 读之后的操作不会被重排到 volatile 读之前。
当第二个操作为 volatile 写时,不论第一个操作是什么,都不能重排序。这个操作保证了 volatile 写之前的操作不会被重排到 volatile 写之后。
当第一个操作为 volatile 写时,第二个操作为 volatile 读时,不能重排。
# 四大屏障的插入情况
- 在每一个 volatile 写操作前面插入一个 StoreStore 屏障:StoreStore 屏障可以保证在 volatile 写之前,其前面的所有普通写操作都已经刷新到主内存中。
- 在每一个 volatile 写操作后面插入一个 StoreLoad 屏障:StoreLoad 屏障的作用是避免 volatile 写与后面可能有的 volatile 读 / 写操作重排序
- 在每一个 volatile 读操作后面插入一个 LoadLoad 屏障:LoadLoad 屏障用来禁止处理器把上面的 volatile 读与下面的普通读重排序。
- 在每一个 volatile 读操作后面插入一个 LoadStore 屏障:LoadStore 屏障用来禁止处理器把上面的 volatile 读与下面的普通写重排序。
//模拟一个单线程,什么顺序读?什么顺序写?
public class VolatileTest {
int i = 0;
volatile boolean flag = false;
public void write(){
i = 2;
flag = true;
}
public void read(){
if(flag){
System.out.println("---i = " + i);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14

# 如何正确使用 volatile
单一赋值可以,but 含复合运算赋值不可以 (i++ 之类)
volatile int a = 10;
volatile boolean flag = false;
2
# 状态标志
状态标志,判断业务是否结束
package com.atguigu.juc.prepare;
import java.util.concurrent.TimeUnit;
/**
* @auther zzyy
* @create 2020-04-14 18:11
*
* 使用:作为一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或任务结束
* 理由:状态标志并不依赖于程序内任何其他状态,且通常只有一种状态转换
* 例子:判断业务是否结束
*/
public class UseVolatileDemo
{
private volatile static boolean flag = true;
public static void main(String[] args)
{
new Thread(() -> {
while(flag) {
//do something......
}
},"t1").start();
//暂停几秒钟线程
try { TimeUnit.SECONDS.sleep(2L); } catch (InterruptedException e) { e.printStackTrace(); }
new Thread(() -> {
flag = false;
},"t2").start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 开销较低的读,写锁策略
开销较低的读,写锁策略
public class UseVolatileDemo
{
/**
* 使用:当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销
* 理由:利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性
*/
public class Counter
{
private volatile int value;
public int getValue()
{
return value; //利用volatile保证读取操作的可见性
}
public synchronized int increment()
{
return value++; //利用synchronized保证复合操作的原子性
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# DCL 双端锁的发布
package com.atguigu.itdachang;
/**
* @auther zzyy
* @create 2020-07-13 15:14
*/
public class SafeDoubleCheckSingleton
{
private static SafeDoubleCheckSingleton singleton;
//私有化构造方法
private SafeDoubleCheckSingleton(){
}
//双重锁设计
public static SafeDoubleCheckSingleton getInstance(){
if (singleton == null){
//1.多线程并发创建对象时,会通过加锁保证只有一个线程能创建对象
synchronized (SafeDoubleCheckSingleton.class){
if (singleton == null){
//隐患:多线程环境下,由于重排序,该对象可能还未完成初始化就被其他线程读取
singleton = new SafeDoubleCheckSingleton();
}
}
}
//2.对象创建完毕,执行getInstance()将不需要获取锁,直接返回创建对象
return singleton;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
单线程环境下 (或者说正常情况下),在 "问题代码处",会执行如下操作,保证能获取到已完成初始化的实例

但由于存在指令重排序......
隐患:多线程环境下,在 "问题代码处",会执行如下操作,由于重排序导致 2,3 乱序,后果就是其他线程得到的是 null 而不是完成初始化的对象
正确情况

出现问题情况

其实解决方法也很简单加 volatile 修饰
package com.atguigu.itdachang;
/**
* @auther zzyy
* @create 2020-07-13 15:14
*/
public class SafeDoubleCheckSingleton
{
//通过volatile声明,实现线程安全的延迟初始化。
private volatile static SafeDoubleCheckSingleton singleton;
//私有化构造方法
private SafeDoubleCheckSingleton(){
}
//双重锁设计
public static SafeDoubleCheckSingleton getInstance(){
if (singleton == null){
//1.多线程并发创建对象时,会通过加锁保证只有一个线程能创建对象
synchronized (SafeDoubleCheckSingleton.class){
if (singleton == null){
//隐患:多线程环境下,由于重排序,该对象可能还未完成初始化就被其他线程读取
//原理:利用volatile,禁止 "初始化对象"(2) 和 "设置singleton指向内存空间"(3) 的重排序
singleton = new SafeDoubleCheckSingleton();
}
}
}
//2.对象创建完毕,执行getInstance()将不需要获取锁,直接返回创建对象
return singleton;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
当然也有不加 volatile 修饰的解决办法,采用静态内部类的方式实现
//现在比较好的做法就是采用静态内部内的方式实现
public class SingletonDemo
{
private SingletonDemo() { }
private static class SingletonDemoHandler
{
private static SingletonDemo instance = new SingletonDemo();
}
public static SingletonDemo getInstance()
{
return SingletonDemoHandler.instance;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 总结
# 内存屏障是什么
内存屏障:是一种屏障指令,它使得 CPU 或编译器对屏障指令的前和后所发出的内存操作执行一个排序的约束。也叫内存栅栏或栅栏指令。
# 内存屏障能干嘛
- 阻止屏障两边的指令重排序
- 写数据时加入屏障,强制将线程私有工作内存的数据刷回主物理内存
- 读数据时加入屏障,线程私有工作内存的数据失效,重新到主物理内存中获取最新数据
# 内存屏障四大指令
# 写操作前
在每一个 volatile 写操作前面插入一个 StoreStore 屏障
StoreStore Barriers: Store1;StoreStore;Store2 禁止重排序:一定是 Store1 的数据写出到主内存完成后,才能让 Store2 及其之后的写出操作的数据,被其它线程看到。保证 Stor1 指令写出去的数据,会强制被刷新回到主内存中
# 写操作前
在每一个 volatile 写操作后面插入一个 StoreLoad 屏障
StoreLoad Barriers:Store1;StoreLoad;Load2 禁止重排序:一定是 Store1 的数据写出到主内存完成后,才能让 Load2 来读取数据 同时保证:强制把写缓冲区的数据刷回到主内存中让工作内存 CPU 高速缓存当中缓存的数据失效,重新到主内存中获取新的数据
# 读操作后
在每一个 volatile 读操作后面插入一个 LoadLoad 屏障
LoadLoad Barriers: Load1;LoadLoad;Load2 禁止重排序:访问 Load2 的读取操作一定不会重排到 Load1 之前 保证 Load2 在读取的时候,自己缓存内到相应数据失效,Load2 会去主内存中获取最新的数据
# 写操作后
在每一个 volatile 读操作后面插入一个 LoadStore 屏障
LoadStore Barriers: Load1;LoadStore;Store2 禁止重排序:一定是 Load1 读取数据完成后,才能让 Store2 及其之后的写出操作的数据,被其它线程看到。
# volatile 关键字系统底层如何加入内存屏障
字节码层面

关键字 ACC VOLATILE
它影响的是 Class 内的 Field 的 flags: 添加了一个 ACC VOLATILE,JVM 在把字节码生成为机器码的时候,发现操作是 volatile 的变量的话,就会根据 JMM 要求,在相应的位置去插入内存屏障指令。
# volatile 可见性
volatile 关键字保证可见性,意味着:
- 对一个 volatile 修饰的变量进行读操作的话,总是能够读到这个变量的最新的值,也就是这个变量最后被修改的值
- 一个线程修改了 volatile 修饰的变量的值的时候,那么这个变量的新的值,会立即刷新回到主内存中
- 一个线程去读取 volatile 修饰的变量的值的时候,该变量在工作内存中的数据无效,需要重新到主内存去读取最新的数据
# volatile 禁重排
# 写指令

# 读指令

# 对比 Lock 来理解
对比 java.util.concurrent.locks.Lock 来理解
cpu 执行机器码指令的时候,是使用 Iock 前缀指令来实现 volatile 的功能的。 LOck 指令,相当于内存屏障,功能也类似内存屏障的功能:
首先对总线 / 缓存加锁,然后去执行后面的指令,最后,释放锁,同时把高速缓存的数据刷新回到主内存
在 Iock 锁住总线 / 缓存的时候,其它 cpu 的读写请求就会被阻塞,直到锁:释放。Lock 过后的写操作,会让其它 cpu 的高速缓存中相应的数据失效,这样后续这些 cpu 在读取数据的时候,就会从主内存去加载最新的数据
加了 Lock 指令过后的具体表现,就跟 MM 添加内存屏障后一样。
一句话总结
- volatile 写之前的操作,都禁止重排序到 volatile 之后
- volatile 读之后的操作,都禁止重排序到 volatile 之前
- volatile 写之后 volatile 读,禁止重排序的