在之前的篇幅里,我们初步介绍了Java内存模型,以及一些概念、原则、规则等,其中里面有个可见性的保证关键字-volatile。本篇将从用法,原理等方面来追本溯源下。
从上篇文章我们知道了,volatile具备这样的作用:当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
volatile语义
我们先直奔主题,volatile具备两层语义,一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义原理:
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 禁止进行指令重排序。
volatile可见性原理
我们先来看看volatile第一个语义(可见性),Java中是如何定义volatile的可见性原理的。
在之前讲计算机硬件基础的时候,我们就说过多个CPU会出现缓存不一致的情况,当时有两种解决方案:
- 总线加锁
- 缓存一致性协议(MESI) 同理,既然volatile修饰的变量能具有“可见性”,那么volatile内部肯定是走的底层,同时也肯定满足缓存一致性原则。因为涉及到底层汇编,这里我们不要去了解汇编语言,我们只要知道当用volatile修饰变量时,生成的汇编指令会比普通的变量声明会多一个Lock指令。那么Lock指令会在多核处理器下会做两件事情。
- 将当前处理器缓存行的数据直接写会到系统内存中(从Java内存模型来理解,就是将线程中的工作内存的数据直接写入到主内存中)
- 这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效(从Java内存模型理解,当线程A将工作内存的数据修改后(新值),同步到主内存中,那么线程B从主内存中初始的值(旧值)就无效了) 我们下面的两段伪代码,来从代码的角度分析下volatile的实现效果,下面用来优雅停止线程的代码,他们的区别就是flag标记有没有用volatile修饰:
class Demo {
public static boolean flag = true;
public static void main(String[] args) throws Exception{
Thread threadA = new Thread(){
public void run(){
while(flag){
System.out.printf("运行中..");
Thread.sleep(1000);
}
}
};
th.start();
Thread.sleep(10000);
}
//线程B执行
public static void stopThread(){
flag = false;
}
}
当threadA执行后,有另一个线程B执行stopThread用来中断threadA的执行,也许在大多数时候,线程B能够将threadA中断,但是也有的时候中断不了,因为是并发发生的,线程B将flag置为false,但是此时只是把线程B的本地内存的缓存更新为false,还没刷新到主内存,所以threadA是不知道的,所以此时有可能中断不了,再来看看下面的代码
class Demo {
public static volatite boolean flag = true;
public static void main(String[] args) throws Exception{
Thread th = new Thread(){
public void run(){
while(flag){
System.out.printf("运行中..");
Thread.sleep(1000);
}
}
};
th.start();
Thread.sleep(10000);
//也算是优雅停止线程的例子了
stopThread();
}
public static void stopThread(){
flag = false;
}
}
flag用volatite修饰之后呢,根据volatite的语义和作用来分析:
- 使用volatile关键字会强制将修改的值立即写入主存;
- 使用volatile关键字的话,当线程B进行修改时,会导致线程A的工作内存中缓存变量flag的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
- 由于线程A的工作内存中缓存变量stop的缓存行无效,所以线程A再次读取变量stop的值时会去主存读取。 这时,线程A会毫无意外的终止线程了。
volatile禁止重排序
在前面的篇幅中我们说过,为了性能优化,Java内存模型在保证不改变语义的情况下,会允许编译器和处理器对指令进行重排序。那我们上面说了,volatile可以禁止指令重排序,它是怎么如何做到的呢?
先来看看JMM针对volatile禁止重排序的定义:Java内存模型规定了使用volatile来修饰相应变量时,可以防止CPU(处理器)在处理指令的时候禁止重排序。
在JMM中,提供了一种叫做内存屏障的东西,这些内存屏障就可以用来阻止指令重排序。
内存屏障
JMM中,内存屏障分为以下四类
java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
当我们的变量使用了volatile来修饰后,编译器在生成字节码的时候,会在指令序列中插入对应的内存屏障来禁止特定类型的处理器重排序问题。
volatile防止重排序规则
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序,这个规则确保voatile写之前的操作不会被编译器排序到volatile之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作如果是volatile读时,不能进行重排序。
阻止重排序原理
编译器在指令序列插入的内存屏障保守插入策略如下:
- 在每个volatile写操作的前面插入一个storestore屏障。
- 在每个volatile写操作的后面插入一个storeload屏障。
- 在每个volatile读操作的后面插入一个loadload屏障。
- 在每个volatile读操作的后面插入一个loadstore屏障。
volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。
volatile写内存屏障
storestore屏障:对于这样的语句store1; storestore; store2,在store2及后续写入操作执行前,保证store1的写入操作对其它处理器可见。(也就是说如果出现storestore屏障,那么store1指令一定会在store2之前执行,CPU不会store1与store2进行重排序)
storeload屏障:对于这样的语句store1; storeload; load2,在load2及后续所有读取操作执行前,保证store1的写入对所有处理器可见。(也就是说如果出现storeload屏障,那么store1指令一定会在load2之前执行,CPU不会对store1与load2进行重排序)
volatile读内存屏障
loadload屏障:对于这样的语句load1; loadload; load2,在load2及后续读取操作要读取的数据被访问前,保证load1要读取的数据被读取完毕。(也就是说,如果出现loadload屏障,那么load1指令一定会在load2之前执行,CPU不会对load1与load2进行重排序)
loadstore屏障:对于这样的语句load1; loadstore; store2,在store2及后续写入操作被刷出前,保证load1要读取的数据被读取完毕。(也就是说,如果出现loadstore屏障,那么load1指令一定会在store2之前执行,CPU不会对load1与store2进行重排序)
总结
- volatile具有可见性不具有原子性,同时能防止指令重排序。
- volatile之所以具有可见性,是因为底层中的Lock指令,该指令会将当前处理器缓存行的数据直接写会到系统内存中,且这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效。
- volatile之所以能防止指令重排序,是因为Java编译器对于volatile修饰的变量,会插入内存屏障。内存屏障会防止CPU处理指令的时候重排序的问题
参考文献 《java并发编程的艺术》