Java中的volatile到底是什么来路
今天来介绍下Java中的volatile关键字,volatile也是Java并发编程中不得不看的一个部分,在之前的文章中说到的双重检测单例模式其实也跟volatile也有不解之缘,另外volatile跟计算机体系架构(CPU)也是有着千丝万缕的关系。
所以引入volatile之前,还是先来理解下背景:
由于计算的cpu、内存、IO设备的速度不均衡,cpu的速度最快,内存其次,IO设备速度最慢。因此在数据传输的过程中,就会出现cpu等待内存数据,内存等待IO数据的情况,整体上就会导致CPU不能高效利用,浪费资源。
于是科学家们为了提高CPU的利用率,不让CPU闲置,从操作系统层面和软件层面进行优化,比如之前同步?阻塞?到底什么是I/O模型这篇文章中提到的各种I/O模型,其目的就是为了越来越高效地利用CPU;此外,在CPU中,除了存在寄存器之外,还设置了一级、二级、三级...缓存,其目的也是为了缓解CPU和内存速度的差异,CPU可以优先从缓存中进行获取数据,这样不必每次都要去访问内存,也是一种优化方式。
但是事情往往总是两面性的,在优化性能和速度的同时,又引入了新问题,在一台主机具备多CPU多核的时代,计算机上的程序往往都是多用户多进程多线程式地去跑,在一台具有多CPU多核的机器上,程序是可以达到真正地并行计算的,而问题也就随之而来.......
下面是一段经典的多线程使用共享变量的例子:
int a = 0;
// 线程A运行
a += 1
// 线程B运行
a += 1
这里的共享变量a被线程A和线程B共享,可以试想下,最后a的结果是多少呢?
如果是单线程运行,毫无疑问,a的结果肯定是2,而在多线程的情况下,结果是多样的,有可能会出现a=1的情况,因为线程A和线程B是运行在一台2个CPU的机器上,线程A和线程B各占一个CPU,那也就是说线程A和线程B会各自享有自己占据的那个CPU上的寄存器和缓存,CPU在计算的时候会把要计算的数值取到寄存器中,计算完,会刷新到缓存或者主存(主存即内存)中,而当两个CPU各自把a这个变量加载进寄存器中后,会进行各自的计算,只要没有把CPU中寄存器中的值同步进内存中,那么两个线程中缓存中的a的值对双方来说都是不可见的,因此就如下图所示情况:
当线程A做完第1步操作:a=a+1. 这时候a的值还没有写到内存,而线程B也开始了a=a+1的操作,然后线程A将a的值写入内存,最后线程B也将a的值写入内存,所以a的值最终还是为1。
所以看到这里,可以看出来多线程情况下出现的问题,其实也是因为一系列的优化问题引起的,而在Java中要解决这个问题,Java也提供了相应的措施给开发者,比如今天要提到的volatile,还有诸如原子操作,锁操作等等。关于上面的那个例子,不得不再多一句最,即使用volatile来修饰变量a,其实也不能完全保证a可以得到正确的值,因为volatile仅仅保证的是内存可见性,而不是原子性,关于volatile保证内存可见性的问题下面会进行介绍。
今天主要介绍volatile这个关键字,通过对它做了一些解读和研究,主要分为这几点:
-
指令集重排序优化
-
volatile保证内存可见性
-
JMM(Java Memory Model模型),happens-before 规则
这几个点其实也是比较绕口的专业术语,也涉及到了JMM(Java Memory Model)的知识点,下面还是看例子,比较有清晰的认识:
以单例模式这个例子说起,单例模式中有一种写法是懒汉模式,并且为了多线程安全以及性能优化的考虑,用了双重检测的机制,在之前的文章单例模式(三)和单例模式(四)也讲过,看下面这段代码:
public class Singleton {
private static Singleton singleton;
private Singleton(){
}
public static Singleton getInstance(){
if (singleton == null){
synchronized (Singleton.class){
if(singleton == null)
singleton = new Singleton();
}
}
return singleton;
}
}
针对这种双重检测,更加严谨的做法就是在静态成员变量singleton 的前面加上volatile这个关键字,因为如果不加这个volatile可能会到导致多线程访问单例对象singleton的时候会发生报错,因为从高级语言层面来理解代码的执行是很难去想通这个逻辑的,因为Java会被编译为Class文件,就是字节码文件,字节码文件中包含的是一条条的指令,指令是由java虚拟机去解释执行的,所以这里的singleton = new Singleton();这句初始化语句其实被分解为下面几条语句:
new #3 // class cs/designpattern/singleton/bean/Singleton2
...
invokespecial #4 // Method "<init>":()V
putstatic #2 // Field singleton:Lcs/designpattern/singleton/bean/Singleton2;
可以看到主要的指令执行就是:
-
new对象
-
调用构造函数
-
引用赋值
而这三句指令目前的执行顺序是没有问题的,但是问题在于指令执行过程中,根据不同的计算机体系架构,CPU会对其进行指令集优化,当CPU优化指令集的时候,会对上面的指令集进行一种重排序的操作,而这种重排序的操作就会导致上面的指令顺序可能会变为:
-
new对象
-
引用赋值
-
调用构造函数
这时候,会发生原本在第3步的引用赋值先于第2步的构造函数的调用,这时候就有可能发生这样的错误:假设这时候A、B两个线程来共同执行这端逻辑,A已经获取了锁,进入了锁里面的代码段,并且执行了1、2这两句指令操作,然后当B线程过来的时候,发现instance不为空了,所以就直接返回,而这时候A其实还没有执行第3个指令,所以B获取的instance其实就是一个未进行初始化(没有调用构造函数)的对象,这时候,如果使用这个对象就会报错。而引入volatile就是为了解决这个问题,代码如下:
public class Singleton {
private static volatile Singleton singleton;
private Singleton(){
}
public Singleton getInstance(){
if (singleton == null){
synchronized (Singleton.class){
if(singleton == null)
singleton = new Singleton();
}
}
return singleton;
}
}
volatile在这里的语义就是防止指令重排序,这样在初始化对象的时候,就能强制保证如下的顺序,保证了引用赋值不会先于构造函数的调用:
-
new对象
-
调用构造函数
-
引用赋值
所以B线程就不会再获取一个未初始化过的对象了。
关于volatile防止指令重排序,其实也是JMM规定的happens-before规则之一, 在happens-before的规范中,volatile的一种语义就是防止指令重排序。
看完指令重排序后,来看volatile的另外一种语义:内存可见性
仍然来看一个小例子:
volatile boolean stop = false;
// 线程A执行
void shutdown(){
stop = true
}
// 其他线程执行
while(stop){
stopCurrentThread();
}
这里我们可以看到将stop这个多线程共享变量设置了volatile属性,为的就是可以立即让stop这个变量的值刷新到内存中,从而让其他线程执行的时候可以立即感知到stop这个变量的值发生了变,所以这里volatile的可见性语义其实就是体现在可以及时地将CPU寄存器或者CPU缓存中的计算结果及时地刷新到内存,起到多线程可以感知的效果,这也是JVM内存模型中happens-before定义的一个规则。
总结一下,今天主要记录了Java中volatile的两种语义:
-
防止指令重排序
-
内存可见性
关于JMM中的happens-before的规则,只是略微提了下,后面有时间也会做相应地记录和分析。