volatile的作用以及原理解析
前言:之前介绍了synchronized关键字,知道通过synchronized可以实现互斥来保证内存的可见性问题,但是同时也说到了,synchronized是一个非常重量级的锁,即便后面引入了锁的升级过程,但是如果是这种情况,你只想保证代码里某些变量的内存可见性,就直接对这些变量,或者使用了这些变量的方法上synchronized来实现吗?这样未免有点大炮打蚊子,大材小用了,而且对cpu资源也是一种浪费,频繁的线程上下文切换也是一种性能消耗。因此为了满足这个问题,就引入了这个关键字:volatile
volatile的内存可见性
- volatile一个最主要的作用就是:保证共享变量的内存可见性。注意是内存可见性,而不是线程安全,因为线程安全还有一个要满足的就是原子性,但是volatile不能满足原子性。
前面又说到,由于synchronized太过笨重,如果只是想保证一些共享变量的线程安全就直接用synchronized同步整个代码块或者整个方法,对性能的消耗很大,因此就引入了volatile关键字。被这个关键字标识的变量,不会被缓存到寄存器,而是直接把变化刷新到主存中,另外其他线程使用这个变量,也不会再从自己的线程本地内存取值,而是直接从主存取值,通过这种方式来解决共享变量的内存可见性问题。看下面这段代码:
public class VolatileDemo1 {
int value=0;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
我们在前面的线程安全性问题中,有提到一点关于Java内存模型的问题:
所有的线程使用变量或者对象,都是将这个变量拷贝一份到自己的线程,所有的操作都是在自己的线程本地之中那个拷贝进行的,在操作完成之后才会把值写回到主存中
- 具体的可以看我的这篇文章: 【线程安全问题】
因为这个原因,所以在上面的这段代码中,会出现一个问题,假设A线程setValue,然后此时剥夺CPU执行权,然后,此时B线程要getValue。但是此时setValue更新完值之后还没把值刷新到主存。所以这个时候就会出现getValue拿的值还是0。解决办法有两种,一种是为这两个方法都加上synchronized,针对这种,可以,但没必要。毕竟只是个变量,还有一种就是本篇文章的主角。volatile,只需要在value值前加上volatile,setValue更新值之后,会立即将变化更新到主存。getValue也不会再拿自己的线程本地的value。而是直接从主存取值。
可见性底层实现原理
volatile的可见性是通过这条指令:0x01a3de24: lock addl $0×0,(%esp); 这条指令JVM会向cpu发出一个 lock指令(lock前缀指令也相当于一个内存屏障),作用是暂时锁总线, 在add指令结束之后 ,然后会把这个变量值所在的缓存行直接写回主存,但是,其它处理器的缓存中存储的仍然是 “旧值” ,并不能保证可见性,由于cpu主存之间的数据是在数据总线上进行传输,因此,由于缓存一致性协议:每个处理器通过感知在总线上传输的数据来检查自己的缓存中的值是否过期或者被更改,当处理器发现自己缓存行对应的内存地址被修改时,就会设置当前缓存行为无效,需要对数据进行修改的时候会重新从主内存中加载。如此,便保证了可见性。
volatile的有序性
在之前的一篇文章有提到,在java内存模型中,允许对指令进行重排序来提高性能,具体的概念可以看我的这篇博客,在此就不做赘述了。
要解决指令重排带来的线程安全问题,可以通过synchronized来得到一定的解决,因为这个关键字保证了同一时刻只有一个线程进入,但是其实volatile也可以解决指令重排序的问题。那么问题来了,volatile是如何保证有序性的呢?答案是内存屏障;
内存屏障
什么是内存屏障,内存屏障就相当于一道栅栏,阻隔屏障两边的两条指令进行指令重排序 例如这样一条指令:
load1 loadload(屏障) load2 ,
对这样这条语句,保证load2 指令要的数据被加载之前,确保load1的数据加载完毕,因为屏障不允许load1和load2两条指定进行重排序。小结就是,内存屏障禁止屏障两边的指令进行重排序。内存屏障主要分以下几种。
- LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
volatile的内存屏障
volatile
的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:
在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;
由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性
volatile可以保证原子性吗
实践是检验真理的唯一标准,上代码
public class VolatileDemo2 {
public int value = 0;
public void inc(){
value++;
}
public static void main(String[] args) {
VolatileDemo2 test = new VolatileDemo2();
for (int i=0;i<10;i++){
new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
test.inc();
}
}
}).start();
}
System.out.println(test.value);
}
}
先大胆的猜测一下,这段代码的运行结果。按照正常逻辑来说,十个线程,每个递增1000次,最后的结果应当是10000的。但是事实真的是这样么?我们运行了三次。发现每次结果都不一样。分别是3744,6092,6787。为啥会出现这种情况呢?这是由于,value++操作本身就不是原子性操作,value++实际上是分为三步的,取值---递增1---更新值。这三步执行过程中中任何一步都可能被其他线程剥夺执行权。因为线程本身是抢占式的调度。而加了volatile也不管用。因为volatile只能保证每次的值都能被更新到主存,每次拿的值都是最新的,但是原子性是无法保证的。这一点我们通过上面的代码运行结果就能得出
volatile的使用场景
说了这么多volatile的特性作用以及它的局限性,那么应该啥时候来使用volatile比较合适呢
- 写入变量不依赖当前变量的值可以用volatile。这一点是基于volatile不能保证原子性的限制来考虑的。因为如果对写入变量依赖当前变量的值,就像value++,value=value+1这种,步骤就是分三步。volatile就无法发挥其功能。
- 对读写变量时没有加锁时可以用volatile,因为加锁本身就可以保证内存可见性,这个时候是没有必要用volatile,多此一举
volatile boolean inited = false;
//线程1:
context = loadContext();
inited = true;
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
总结
虽然volatile很方便,可以解决有序性问题和内存可见性问题,但是也仍然存在着一些局限性,比如说无法解决原子性问题等等,而且有特定的使用场景在适合用,准确来说,volatile只是一种弱同步,而不是一种锁,功能总体来说没有加锁强大,因为加锁即可以解决可见性问题也可以解决原子性问题。使用场景也只适用于,需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。但是优点是如果只是想解决内存可见性问题的话,用volatile的性能要优于加锁