一、概述
volatile用来修饰变量,可以理解为轻量化的synchronized,其作用是保证变量的
- 可见性
- 顺序一致性
但其无法保证原子性
二、Java内存模型(JMM)和可见性
2.1 JMM
在Java虚拟机(JVM)中定义了内存模型(JMM),屏蔽了不同CPU架构和操作系统之间对内存的访问的差异,简单的以下图的方式定义了Java线程和主内存之间的关系
从抽象的角度看,JMM确定了线程和主内存之间的通信方式,线程之间的共享变量(比如:static修饰的变量)存储在主内存,每个线程都有独属于自己的部分空间称之为本地内存,线程访问存储在主内存的共享变量时,会从主内存中复制一份到本地内存中,对其进行读/写操作时,也仅仅是对本地内存的变量副本进行操作,最终需要将在本地内存的修改同步到主内存才算是完成一次真正有效的修改变量的操作
一个合理的多线程读/写共享变量X的操作应该如下:
- 线程A从主内存拷贝X到本地内存
- 线程A修改本地内存中的X
- 线程A将本地内存中拷贝回主内存
- 线程B访问主内存X变量拷贝一份回线程B本地内存进行运算
*注:本地内存仅仅是逻辑上抽象的概念,可以理解为一种Cache,但是其并不是真实存在的,本地内存是Cache,写缓冲区,寄存器等一系列缓冲技术的集合体,其作用是提高CPU的利用率
2.2 可见性
既然JMM规定线程修改共享变量是在本地内存中进行修改,那就存在一个问题,线程A修改了共享变量X后,线程B访问X时,获取到的X值是不是最新的值?
在高并发的情况下如果没有其它保障手段,其实是无法保证线程B获取到的X值是最新值。
因为实际执行代码时上文所述正常步骤中3有可能发生在步骤4之后。
如果线程A无论何时修改了变量X,线程B在其修改后获取X的值都是A修改之后的值,那么就称线程A对X变量的操作对线程B可见
可见性描述的是不同线程之间的并发问题
2.3 顺序一致性问题
指令重排序
我们所写的代码不一定会按照我们所写的顺序执行,编译器最终编译形成的运行的代码顺序上也许会有所偏差
如下所示的代码:
Java在执行时会先翻译成字节码指令,字节码指令种 a = 100; 和 a = a + 10都可以简单理解为
- 取出a的值
- 修改a的值
- 把a保存
如果按照上面的顺序执行,会发现Load a 和 Store a重复执行了一次,如果改成如下的顺序对结果不会有影响,但指令条数变少
这种顺序上的改动明显使得指令条数变少,加快了执行效率,这种改变顺序的行为就是编译器默认会做的,这种行为就叫做指令重排序
和可见性不同,重排序是一种单线程执行过程中发生的问题
指令重排序引发的顺序一致性问题
指令重排序时,编译器会保证对单个线程的整个代码块运行结果没有影响,但是对于多线程情况下则也许会对结果产生影响
例如:
指令重排前:
public class OrderTest {
static int x = 0;
static boolean flag = true;
public void write() {
...
...
x = 1;
flag = true;
}
// read()方法在write()完成对x的写操作后才可以执行读操作, 通过flag进行信息传递
public void read() {
if(flag) {
int a = x + 1; //读x
}
}
}
通过上文所述,可以理解编译器完全有可能进行如下的指令重排
指令重排后
public class OrderTest {
static int x = 0;
static boolean flag = true;
public void write() {
...
...
flag = true; // 此处重排
x = 1;
}
// read()方法在write()完成对x的写操作后才可以执行读操作, 通过flag进行信息传递
public void read() {
if(flag) {
int a = x + 1; //读x
}
}
}
重排后,若线程A执行write()到flag = true后 ,线程B获取CPU资源运行read(),此时flag为true,可以执行读操作,最终a的值为 0 + 1 = 1,这个结果显然和预期结果不一致
三、volatile原理
3.1 volatile解决可见性
volatile解决可见性这一问题翻译成内存语义如下:
volatile写的内存语义:写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主存(共享内存中)
volatile读的内存语义:读一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值置为无效,线程接下来将从主存(共享内存)中去读取共享变量
所有上层高级语言在执行时会先翻译成汇编语言,Java也是如此,为了实现volatile的读和写内存语义,对volatile关键字修饰的变量X进行读/写操作的代码在翻译成汇编语言时,会在其前添加一条lock指令(汇编指令),添加lock指令的代码在执行时处理器会做如下两件事:
- 处理器缓存写回到内存
- 处理器缓存写回到内存后,其它处理器对应的缓存行失效
如此也就报保障了变量X的可见性,无论哪个线程修改了变量X,其它线程均可以"感知"到这种改动
3.2 volatile解决顺序一致问题的原理
通过2.3 分析,可知解决顺序一致问题的方法很简单暴力 — 禁止指令重排
上文示例中,将flag用volatile修饰则完全可以避免顺序一致性问题,但是指令重排对提高运行效率有很重要的意义,因此禁止指令重排只针对 对volatile修饰变量的读/写指令
volatile解决顺序一致问题的原理是使用内存屏障,具体操作是
- 在对每个volatile变量写操作之前,插入一个StoreStore屏障
- 在对每个volatile变量写操作之后,插入一个StoreLoad屏障
- 在对每个volatile变量读操作之后,插入一个LoadLoad屏障
- 在对每个volatile变量读操作之后,插入一个LoadStore屏障
在上文示例中将 flag设置为volatile变量,以StoreStore屏障插入到写操作之前为例:
这样就会使得volatile写和普通写不会重排序
其中,StoreStore指的是禁止该屏障上方的写操作和下方的volatile变量的写操作进行指令重排,这样保证在修改volatile变量前其它普通写可以刷新到主存中,其它三种类型的屏障都见名知义,不再详细叙述
看到这里,再思考一下volatile对保障可见性的方法,是不是发现和内存屏障很相似?
是的,volatile对保障可见性本质也是通过内存屏障,lock指令的使用就是内存屏障具体实现方法,因此volatile保障可见性的原理也可以这样描述
- 在所有写操作后加入写屏障,保证对volatile变量的写立刻同步到主存中
- 在所有的读操作前加入读屏障,保证读取到的volatile变量都是从主存获取的最新值
而内存屏障都是一组原子操作,因此保证了可见性和顺序一致性
四、volatile弊端
volatile可以保证在可见性与一致性,但是 volatile无法保证原子性,无法解决多线程情况下的指令交错问题
在指令重排的实例中
public void write() {
...
...
x = 1;
flag = true;
}
线程A和线程B有可能会先后修改x的值,导致其最终的值为线程B修改的值,这样线程A执行到read()时,获取到的x值依旧是错误的
这也是为什么双重检查锁单例模式中,除了用volatile修饰单例对象的引用禁止重排序的同时,仍旧需要synchronized来保证线程之间的同步