java中volatile声明变量,有两个作用
- 保证变量对所有线程的可见性
- 禁止指令重排
保证可见性
多线程访问共享变量时,声明volatile可以保证了共享变量可见性。可见性,即当一个线程修改共享变量时,另一个线程能读到变量修改后的值。
没有了解过计算机系统低层实现的同学,可能会好奇,为什么会发生一个线程更新一个变量,另一线程读到的是是更新前的值的情况?
计算机系统,为了提高处理速度,CPU其实并不直接与内存进行通信,而是将内存中的数据读取到CPU高速缓存(L1,L2,L3)中,操作完后也并不总是会马上写回内存。只有当缓存被置换出去的时候,才会去写内存。
多处理器环境下,保证缓存一致性,有两种方式:总线锁和MESI缓存一致性协议。
总线锁(bus locking):
当一个CPU对缓存中的数据进行操作的时候,会往总线中发送一个Lock信号。这个时候,所有CPU收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的CPU就去内存中获取最新数据更新。
总线锁带来的性能成本高昂,更多的计算机采用的是MESI缓存一致性协议。
MESI协议, 会在缓存中存储一个标志位。这个标志位有以下四种状态:
- M: Modify,修改缓存,当前CPU的缓存已经被修改了,即与内存中数据已经不一致了
- E: Exclusive,独占缓存,当前CPU的缓存和内存中数据保持一致,而且其他处理器并没有可使用的缓存数据
- S: Share,共享缓存,和内存保持一致的一份拷贝,多组缓存可以同时拥有针对同一内存地址的共享缓存段
- I: Invalid,失效缓存,这个说明CPU中的缓存已经不能使用了
每个CPU都会监听其他CPU对缓存的读写操作。
- 当监听到其他CPU有读取操作,而自己的缓存处于M或E时,就将自己的缓存写入内存,并将自己状态修改为S。 - 当监听到其他CPU有写入内存操作,且缓存为S状态,则将状态职位I。
- 当CPU的缓存状态是I,CPU将从内存中读取。
使用volatile声明的变量,JVM会发送lock指令,将缓存写回内存。由于缓存一致性协议,写回缓存的时候,其他处理器的缓存将失效。从而保证了该变量值得一致性。
禁止指令重排
编译器和处理器为了提升程序性能,会按照一定的规则进行对指令进行重排顺序。在单线程下,这样的重排并不会带来问题。因为重排会遵守as-if-serial语义.
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。例如:
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
C 依赖于A 和B,因此重排时C不会被重排到A和B之前。而A和B没有依赖,重排也复合as-if-serial语义,编译器和处理器可以对其进行重排。
经常提到的使用双检查锁来创建singleton对象的问题,正是由于指令重排导致的。
public class Singleton {
private static Singleton instance = null;
private Singleton() { }
public static Singleton getInstance() {
if(instance == null) {
synchronzied(Singleton.class) {
if(instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
new Singleton()并非是一个原子操作,它有多条指令组成:
memory = allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址
但是经过重排序后:
memory = allocate(); //1:分配对象的内存空间
instance = memory; //3:设置instance指向刚分配的内存地址,此时对象还没被初始化
ctorInstance(memory); //2:初始化对象
指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面,在线程A初始化完成这段内存之前,线程B虽然进不去同步代码块,但是在同步代码块之前的判断就会发现instance不为空,此时线程B获得instance对象进行使用就可能发生错误。
使用volatile会插入内存屏障指令,防止编译和运行时指令被重排。