前言
在java多线程中,volatile
是一个不得不提的关键字。
volatile,主要应用于定义变量的时候,本身的含义是“可变的,易变的”。
顾名思义,volatile的作用,就是标识一个变量的是可变的。
volatile的作用,是能保证线程之间的可见性,以及防止指令的重排序。
本文将从volatile这两个作用开始,探讨一些相关的底层问题,以及volatile是如何解决这两个问题的。
volatile的作用
保证线程的可见性
要讨论volatile
在线程可见性中起到的重要作用,首先要明白什么是线程可见性。
线程可见性,就是指一个线程对某个变量的改变能不能被另外的线程看到。
下面举一个例子:
public class VolatileDemo {
boolean flag = true;
public void method1() {
System.out.println("print start");
while(flag) {
}
System.out.println("print end");
}
public static void main(String[] args) throws InterruptedException {
VolatileDemo v = new VolatileDemo();
(new Thread(new Runnable() {
@Override
public void run() {
v.method1();
}
})).start();
Thread.sleep(1000);
(new Thread(new Runnable() {
@Override
public void run() {
v.flag = false;
}
})).start();
}
}
上面这段代码,有一个变量flag
用来标识线程thread1
中的while循环,只要flag
是true,那么thread1
会一直运行。
然后又起了一个线程thread2
来改变flag
的值。
那么按照常理来说,只要thread2
完成了对flag
的值的改变,thread1
就会完成操作并退出。
但是最后的结果是thread1
一直在运行。
也就是说,thread2对变量的改变,并没有反应在thread1中。 这就是线程间的不可见。
那么是什么原因导致了线程之间的不可见呢?
我们从线程的内存开始说起。
如下图所示:
线程之间是共享堆空间的(heap),这里也是初始时的flag
所在的空间。
然后当线程需要用到某个变量的时候,就会在自己的线程空间里,拷贝一份原始的变量。
当这个线程完成了对变量的修改后,会回写到原始空间。但是注意:
什么时候回写新值是不确定的
这就导致了,虽然变量可能已经被某个线程改成了新值,但由于新值还没有被回写到共享空间,导致其他线程读到的还是旧值。
那么volatile的作用就呼之欲出了:
volatile能保证变量发生改变后能及时回写到共享空间中。保证所有线程读到的都是最新的值。
禁止指令的重排序
volatile另一个作用是禁止指令的重排序。
指令重排序是发生在CPU级别的操作。
为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响,虚拟机会按照自己的一些规则将程序编写顺序打乱——即写在后面的代码在时间顺序上可能会先执行,而写在前面的代码会后执行——以尽可能充分地利用CPU。
换句话说,在CPU上执行的指令的顺序,可能跟我们编写代码的顺序并不一致。
我们用Double-Checked Lock版的单例模式来举一个极端的例子。
这个版本的单例模式的具体信息在这篇文章,这里单独把代码挑出来:
public static LazyLoading_DCL getInstance() {
private static LazyLoading_DCL INSTANCE;
if (INSTANCE == null) {
synchronized(LazyLoading_DCL.class) {
if (INSTANCE == null) {
INSTANCE = new LazyLoading_DCL();
}
}
}
return INSTANCE;
}
这里先说一个结论:
在JVM中,创建一个新对象会被编译成三条指令,并从上往下依次执行:
- 在内存中开辟一块区域,所有的成员变量是初始值
- 将成员变量的值改成我们设置的值
- 将这个对象的引用指向该内存空间
由于指令重排序的存在,上面的指令可能并不会从上往下依次执行
那么我们假设发生的指令顺序是 1 - 3 - 2。 即先将引用指向开辟的空间,然后在把成员变量设置成正常值。
然后我们把这个假设带入到上面的代码中。
在超级超级,巨大巨大的巧合下,一个线程正在初始化INSTANCE
并且按照指令重排序的顺序,把半初始化的对象赋值给了INSTANCE
变量。
那么另外一个线程申请得到这个单例的时候,INSTANCE
不等于null
,然后直接返回了半成品的INSTANCE
, 这就会造成难以预计的错误。
那么我们就要给INSTANCE加上volatile,从而保证在初始化这个对象的时候,不会发生指令重排序。从而100%保证这个变量的可靠性。
volatile与内存屏障
其实volatile的底层实现涉及到内存屏障,有了内存屏障的存在,就能保证屏障前后的操作不发生重排序。
JVM中有四种屏障,分别是
- Load_Load屏障:该屏障前后的读操作不会发生重排序。
- Load_Store屏障:该屏障前面的读操作与屏障后面的写操作不会发生重排序。
- Store_Load屏障:该屏障前的写操作与后面的读操作不会发生重排序。
- Store_Store屏障:该屏障前后的写操作不会发生重排序。
volatile就是基于上面几种屏障来实现的。
对于volatile的读写操作,JVM分别在前后加了如下的屏障,来保障volatile不会发生重排序。
读操作:
-----volatile读操作------
----Load_Load屏障----
----Load_Store屏障----
写操作:
----Store_Store屏障----
-----volatile写操作------
----Store_Load屏障----
通过两个屏障的共同作用,保证了临界区中的任何读写操作不可能被重排序到临界区之外。
总结
本文主要讲了volatile
在保证线程可见性和指令顺序执行方面的作用,并且从底层分析了一下这个关键字是如何解决了这两个问题。
但是我们也需要注意,volatile只能保证操作的可见性,但不能保证操作的原子性。保证操作的原子性,还是需要使用synchronized
关键字。
volatile
和synchronized
并不是相互替代的关系,而是互为补充。