我在并发编程过程中经常会看到一个全局变量前面用volatile关键字进行修饰,于是去百度了一下这个关键字的用处,于是出现了:
锁???真的是这样吗?
那么我们今天就来深度看看volatile到底是什么。
CPU缓存模型
简单画了个图。
我们的变量都是存储在内存中的,而cpu用于执行代码。在早期的计算机里当cpu需要去读取和修改某一个变量或数据时每次都需要去请求内存,这样就导致cpu的执行效率相对不高,于是引进了cpu缓存的概念。也就是在cpu中开辟一个cpu本地缓存,当cpu需要操作某个数据时如果是第一次读取那么就会从主内存中读取数据,然后保存到cpu本地缓存中,后续的查询和修改都将从cpu本地缓存中读取而不是主内存,这样就大大提高了cpu的执行效率。
但这样又会引发另一个问题。
图中cpu1将flag从主内存中读取,此时flag=0,读取到cpu本地缓存之后对数据进行+1的操作,然后将flag=1的结果赋值给cpu1的本地缓存中。cpu2与cpu1进行同样的操作(读取主内存->加载到cpu2本地缓存->flag+1->更新cpu2本地缓存的值),主内存中的flag还是=0,即使其他cpu也重复进行+1操作,flag始终还是等于0。
问题也不难看出,cpu1和cpu2只是修改了他们自身的本地缓存,并没有将结果同步到主内存中,最终导致其他的线程去读取flag时都是flag的初始值。
CPU总线加锁和MESI协议
CPU总线加锁
CPU总线加锁机制是很早之前为解决上面出现的问题的一种方式,其原理是如果某个cpu要读取一个数据,那么会通过一个总线对这个数据进行加锁,导致只有这个cpu先操作完之后其他的cpu才能对这个数据进行读取。
这样做的缺点就是串行化了,导致cpu需要进行排队等待才能对数据进行操作,最终结果就是效率低下。
目前cpu总线加速已经没什么人用了。
MESI协议
MESI协议,缓存一致性协议
MESI协议解决上面出现的问题的方式是:如果cpu1对flag进行了修改,那么会将flag的修改记录刷新到主内存中,其他cpu会判断他们自身的本地缓存中是否拥有这个flag,如果拥有且被其他cpu进行了修改,那么他们自身的flag将失效,导致他们必须去主内存再拿一份最新的flag(cpu嗅探机制)。
这样做基本解决了本地缓存和主内存参数不一致的问题,也避免了串行化的效率低下问题。
JAVA内存模型
java内存模型相比cpu缓存模型还是有很多相似处的。
各阶段描述
read:将数据从主内存中读取
load:将数据加载到工作内存中
use:从工作内存中读取并使用
assign:使用完后将参数赋值到工作内存
store:将参数从工作内存中取出并带到主内存
write:将参数赋值给主内存中的参数
如果flag被volatile修饰,那么在线程1assign将flag的值赋值给工作内存之后会立即store,将flag的数据刷入主内存,如果不被volatile修饰则不会进行store操作。
原子性、有序性、可见性
原子性:是指一个线程的操作是不能被其他线程打断,同一时间只有一个线程对一个变量进行操作。在多线程情况下,每个线程的执行结果不受其他线程的干扰。
基本上一个共享变量在多线程并发环境下基本是无法保证原子性的(除了上锁和原子类修饰)。
我们常说volatile无法保证原子性,那么到底为什么无法保证原子性,我简单描述一下。这其实与java内存模型有关。
假如线程1和线程2同时将flag=0加载到工作内存中
(线程1)先对flag进行++操作,操作完之后再将flag=1的结果assign给工作内存
(线程2)也开始对flag进行++操作,线程2将工作内存中的flag进行use并在线程中完成++操作
(线程1)由于flag被volatile修饰所以工作内存中的flag被迅速的store给主内存进行write
(线程2)由于线程2嗅探到flag的值已经被修复,工作内存中的flag=0已经失效,但由于线程2之前已经将flag=0读取到线程中进行操作,所以无法及时将之前的flag更改为最新的值,导致线程2计算完之后将flag=1再次刷入线程2的工作内存中。
最终导致被volatile修饰的flag并不能完成原子性操作。
可见性:某个线程修改了某一个共享变量的值,而其他线程是否可以看见该共享变量修改后的值。
对于可见性我们上面已经做了描述,通过MESI协议让各个cpu进行嗅探,如果发现变量被修改了则他们本地缓存的数据立即失效。
有序性:对于代码,同时还有一个问题是指令重排序,编译器和指令器,有的时候为了提高代码执行效率,会将指令重排序。
flag = false;
//线程1:
prepare(); // 准备资源
flag = true;
//线程2:
while(!flag){
Thread.sleep(1000);
}
execute(); // 基于准备好的资源执行操作
重排序之后,让flag = true先执行了,会导致线程2直接跳过while等待,执行某段代码,结果prepare()方法还没执行,资源还没准备好呢,此时就会导致代码逻辑出现异常。
内存屏障
可以说volatile关键字之所以能保证有序性,与内存屏障有很大的关系。
LoadLoad屏障:Load1;LoadLoad;Load2,确保Load1数据的装载先于Load2后所有装载指令,他的意思,Load1对应的代码和Load2对应的代码,是不能指令重排的
Load1:
int localVar = this.variable
Load2:
int localVar = this.variable2
StoreStore屏障:Store1;StoreStore;Store2,确保Store1的数据一定刷回主存,对其他cpu可见,先于Store2以及后续指令
Store1:
this.variable = 1
Store2:
this.variable2 = 2
LoadStore屏障:Load1;LoadStore;Store2,确保Load1指令的数据装载,先于Store2以及后续指令
StoreLoad屏障:Store1;StoreLoad;Load2,确保Store1指令的数据一定刷回主存,对其他cpu可见,先于Load2以及后续指令的数据装载
对于volatile修改变量的读写操作,都会加入内存屏障
每个volatile写操作前面,加StoreStore屏障,禁止上面的普通写和他重排;每个volatile写操作后面,加StoreLoad屏障,禁止跟下面的volatile读/写重排
每个volatile读操作后面,加LoadLoad屏障,禁止下面的普通读和voaltile读重排;每个volatile读操作后面,加LoadStore屏障,禁止下面的普通写和volatile读重排
总结
综上可知,volatile并不是什么所谓的锁,而是一种实现了MESI协议的一种缓存一致性方案,通过cpu嗅探机制保证各个线程之间工作内存中共享变量的可见性。由于volatile的内存内存屏障也保证了volatile修饰的变量在读写过程防止指令重排序。