前言
不管是在面试还是实际开发中 volatile
都是一个应该掌握的技能。
缓存可见性 , 指令有序性, 不保证原子性
内存可见性
由于 Java
内存模型(JMM
)规定,所有的变量都存放在主内存中,而每个线程都有着自己的工作内存(高速缓存)。
线程在工作时,需要将主内存中的数据拷贝到工作内存中。这样对数据的任何操作都是基于工作内存(效率提高),并且不能直接操作主内存以及其他线程工作内存中的数据,之后再将更新之后的数据刷新到主内存中。
这里所提到的主内存可以简单认为是堆内存,而工作内存则可以认为是栈内存。
所以在并发运行时可能会出现线程 B 所读取到的数据是线程 A 更新之前的数据。显然这肯定是会出问题的,因此 volatile
的作用出现了:
当一个变量被
volatile
修饰时,任何线程对它的写操作都会立即刷新到主内存中,并且会强制让缓存了该变量的线程中的数据清空,必须从主内存重新读取最新数据。
volatile
修饰之后并不是让线程直接从主内存中获取数据,依然需要将变量拷贝到工作内存中。
1. 可见性问题来源
package com.spring.master.demo.volatiles;
/**
* @author Huan Lee
* @version 1.0
* @date 2020-10-10 15:48
* @describtion 业精于勤,荒于嬉;行成于思,毁于随。
*/
public class VolatileTest {
static boolean initFlag = false;
public static void main(String[] args) {
Thread t1 = new Thread( ()->{
while(!initFlag){
}
System.out.println("end");
});
Thread t2 = new Thread( ()->{
//这里是为了先让线程t1先执行
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
initFlag = true;
System.out.println("initFlag = " + initFlag);
});
t1.start();
t2.start();
}
}
输出:
initFlag = true
说明:上诉代码按照咱们的理解, t2线程启动后, 将initFlag设置为true, t1线程就会结束while循环, 并打印end; 但结果并不是这样。
2. MESI缓存一致性协议原理
MESI 是指4种状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:
缓存行(Cache line):缓存存储数据的单元。
状态 | 描述 | 监听任务 |
---|---|---|
M 修改 (Modified) | 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 | 缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。 |
E 独享、互斥 (Exclusive) | 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 | 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。 |
S 共享 (Shared) | 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。 | 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。 |
I 无效 (Invalid) | 该Cache line无效。 | 无 |
3. volatile的底层 - 汇编
其实在变量前面添加volatile
关键字, 在汇编语言层次上看, 也就是在未添加关键字的指令上多了一个lock
> //添加volatile
> lock add dword ptr [rsp]....
> //未添加
> add dword ptr [rsp]....
>
lock指令的作用:
锁定当前缓存行区域, 并写回主内存 ( 防止其他线程操作改变量 )
这些写回主存的操作会引起其他CPU中的工作内存的该变量失效( MESI协议 )
4. 如何保证原子性
结合synchronized
, 尽量原子操作类, juc下的atomic包下的类
指令重排
内存可见性只是 volatile
的其中一个语义,它还可以防止 JVM
进行指令重排优化。
int a=10 ;//1
int b=20 ;//2
int c= a+b ;//3
一段特别简单的代码,理想情况下它的执行顺序是:1>2>3。但有可能经过 JVM 优化之后的执行顺序变为了 2>1>3。
可以发现不管 JVM 怎么优化,前提都是保证单线程中最终结果不变的情况下进行的。可能这里还看不出有什么问题,那看下一段伪代码:
private static Map<String,String> value ;
private static volatile boolean flag = fasle ;
//以下方法发生在线程 A 中 初始化 Map
public void initMap(){
//耗时操作
value = getMapValue() ;//1
flag = true ;//2
}
//发生在线程 B中 等到 Map 初始化成功进行其他操作
public void doSomeThing(){
while(!flag){
sleep() ;
}
//dosomething
doSomeThing(value);
}
这里就能看出问题了,当 flag
没有被 volatile
修饰时,JVM
对 1 和 2 进行重排,导致 value
都还没有被初始化就有可能被线程 B 使用了。所以加上 volatile
之后可以防止这样的重排优化,保证业务的正确性。
1. 指令重排的的应用
一个经典的使用场景就是双重懒加载的单例模式了:
public class Singleton {
private static volatile Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
//防止指令重排
singleton = new Singleton();
}
}
}
return singleton;
}
}
这里的 volatile 关键字主要是为了防止指令重排。
如果不用 ,singleton = new Singleton();
,这段代码其实是分为三步:
- 分配内存空间。(1)
- 初始化对象。(2)
- 将
singleton
对象指向分配的内存地址。(3)
加上 volatile
是为了让以上的三步操作顺序执行,反之有可能第二步在第三步之前被执行就有可能某个线程拿到的单例对象是还没有初始化的,以致于报错。
假如一个volatile的integer自增(i++),其实要分成3步:
- 读取主内存中volatile变量值到工作内存;
- 在工作内存中增加变量的值;
- 把工作内存的值写主内存。
来源:oschina
链接:https://my.oschina.net/u/3727895/blog/4275114