面试中的volatile关键字
在Java
的面试当中,面试官最爱问的就是volatile
关键字相关的内容。经过多次面试之后,你是否思考过,为什么他们那么爱问volatile
关键字相关的问题?而对于你,如果作为面试官,是否也会考虑采用volatile
关键字作为切入点呢?
为什么爱问volatile关键字
爱问volatile
关键字的面试官,大多数情况都是有一定功底的,因为volatile
作为切入点,往底层走可以切入Java
内存模型(JMM
),往并发方向走又可切入Java
并发编程。当然,如果再深入追究,JVM
的底层操作、字节码的操作、单例都可以牵扯出来。
所以说懂的人提问都是有门道的。那么,先整体来看看volatile
关键字都涉及到哪些点:内存可见性(JMM
特性)、原子性(JMM
特性)、禁止指令重排、线程并发、与synchronized
的区别.....再往深层挖,可能涉及到字节码和JVM等。
面试官:说说volatile关键字的特性
被volatile
修饰的共享变量,就具有了以下两点特性:
- 保证了不同线程对该变量操作的内存可见性
- 禁止指令重排序
基本上大家看过面试题都可以回答出这两点,点出了volatile
关键字两大特性。针对这两大特性继续深入。
面试官:什么是内存可见性?能否举例说明?
该问题涉及到Java
内存模型(JVM
)和它的内存可见性。
内存模型:Java虚拟机规范试图定义一种Java
内存模型(JMM
),来屏蔽掉各种硬件和操作系统的内存访问差距,让Java程序在各种平台上都能达到一致的内存访问效果。
Java
内存模型是通过变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值,将主内存作为传递媒介。可以举例说明内存可见性的过程。
本地内存A
和B
有主内存中共享变量x
的副本,初始值都为0。线程A
执行之后把x
更新为1
,存放在本地内存中A
中。当线程A
和线程B
需要通信时,线程A
首先会把本地内存中x=1
值刷新到主内存中,主内存的值变为1
。随后,线程B
到主内存中去读取更新后的x
值,线程B
的本地内存的x
值也变为了1
。
最后再说可见性:可见性是指一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
无论普通变量还是volatile
变量都是如此,只不过volatile
变量保证新值能够立马同步到主内存,使用时也立即从主内存中刷新,保证了多线程操作时变量的可见性。而普通变量不能够保证。
面试官:提到JMM和可见性,能说说JMM的其他特性吗?
我们知道JMM
除了可见性,还有原子性和有序性。
原子性即一个操作或一系列操作是不可中断的。即使是在多线程的情况下,操作一旦开始,就不会被其他线程干扰。
比如,对于一个静态变量int x
两条线程同时对其赋值,线程A
赋值为1
,而线程B
赋值为2
,不管线程如何运行,最终值要么为1
,要么是2
,线程A
和线程B
间的操作是没有干扰的,这就是原子性操作,是不可被中断的。
在Java
内存模型中有序性可归纳为这样一句话:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另外一个线程,所有操作都是无序的。
有序性是指对于单线程的执行代码,执行是按顺序依次进行的。但在多线程环境中,则可能出现乱序现象,因为在编译过程中会出现“指令重排”,重排后的指令与原指令的顺序未必一致。
因此,上面归纳的前半句指的是线程内保证串行语义执行,后半句则指“指令重排”现象和“工作内存与主内存同步延迟”现象。
面试官:你多次提到指令重排,能举例说明吗?
CPU
和编译器为了提高程序执行的效率,会按照一定的规则允许进行指令优化。但代码逻辑之间是存在一定的先后顺序,并发执行时按照不同的执行逻辑会得到不同的结果。
举例说明多线程中可能出现的重排现象:
public class ReOrderDemo{
int a = 0;
boolean flag = false;
public void write(){
a = 1; //1
flag = true; //2
}
public void read(){
if (flag){ //3
int i = a * a; //4
}
}
}
在上面的代码中,单线程执行时,read
方法能够获取flag
的值进行判断,获得预期的结果。但在多线程的情况下就可能出现不同的结果。比如,当线程A
进行write
操作时,由于指令重排,write
中的代码执行顺序可能会变成下面这样:
a = 1; //1
flag = true; //2
也就是说可能会先对flag
赋值,然后再对a
赋值。这在单线程并不影响最终输出的结果。
但如果与此同时,B
线程在调用read
方法,那么就有可能出现flag
为true
但a
还是0
,这时进入第4
步操作的结果就为0
,而不是预期的1
了。
而volatile
关键字修饰的变量,会禁止指令重排的操作,从而在一定程度上避免了多线程中的问题。
面试官:volatile能保证原子性吗?
volatile
保证了可见性和有序性(禁止指令重排),那么能否保证原子性呢?
volatile
不能保证原子性,它只是对单个volatile
变量的读/写具有原子性,但是对于类似i++
的复合操作就无法保证了。
如下代码,从直观上来讲,感觉输出结果为100
,但实际上并不能保证,就是因为inc++
操作属于复合操作。
public class Test {
public volatile int inc = 0;
public void increase(){
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 10; j++) {
test.increase();
}
}).start();
}
//保证前面的进程都执行完
while (Thread.activeCount() > 2){
Thread.yield();
}
System.out.println(test.inc);
}
}
假设线程A
,读取了inc
的值为10
,然后被阻塞, 因未对变量进行修改,未触发volatile
规则。线程B
此时也读取inc
的值,主存里的值依旧是10
,做自增,然后立刻写会主存,值为11
。此时线程A
执行,由于工作内存里保存的是10
,所以继续做自增,再写回主存,11
此时又被写了一遍。所以虽然两个线程执行了两次increase()
,结果却只加了一次。
有人说,volatile
不是会使缓存行无效的吗?但是这里线程A
读取之后并没有修改inc
值,线程B
读取时依旧会是10
。又有人说,线程B
将11
写会内存,不会把线程A
的缓存行设为无效吗?只有在做读取操作时,发现自己缓存行无效,才会去读主存的值,而线程A
的读取操作在线程B
写入之前已经做过了,所以这里线程A
只能继续做自增了。
针对这种情况,只能使用synchronized
、Lock
或并发包下的atomic
的原子操作类。
面试官:刚提到synchronized,能说说他们之间的区别吗?
volatile
本质是在告诉JVM
当前变量寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized
则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。volatile
仅能使用在变量级别;synchronized
则可以使用在变量、方法和类级别上volatile
仅能实现变量的修改可见性,不能保证原子性;而synchronized
则可以保证变量的修改可见性和原子性volatile
不会造成线程的阻塞;synchronized
可能会造成线程的阻塞volatile
标记的变量不会被编译器优化;synchronized
标记的变量可以被编译器优化
面试官:还能举出其他例子说明volatile的作用吗?
单例模式的实现,典型的双重检查锁定(DCL
):
class Singleton{
private volatile static Singleton instance = null;
private Singleton(){
}
public static Singleton getInstance(){
if (instance == null){ //1
synchronized (Singleton.class){
if (instance == null){
instance = new Singleton(); //2
}
}
}
return instance;
}
}
这是一种懒汉的单例模型,使用时才创建对象,而且为了避免初始化操作的指令重排序,给instance
加上了volatile
。
为什么用了synchronized
还要用volatile
?具体来说就是synchronized
虽然保证了原子性,但却没保证指令重排序的正确性,会出现A线程执行初始化,但可能因为构造函数里面的操作太多了,所以A
线程的instance
还没有造出来,但已经被赋值了(即代码中2
操作,先分配内存空间后构建对象)。
而B
线程这时过来了(代码1
操作,发现instance
不为null
),错以为instance
已经被实例化出来,一用才发现instance
尚未被初始化。要知道我们的线程虽然可以保证原子性,但程序可能是在多核CPU
上执行。
总结
当然,针对volatile
关键字还有其他方面的拓展,比如讲到JMM
时可拓展到JMM
与Java
内存模型的区别,讲到原子性时可拓展到如何如何查看class
字节码,讲到并发可拓展到线程并发。
其实,不仅面试如此,在学习知识时也可以参考这种面试思维,多问几个为什么。将一个点,通过为什么展成面,这样就可以形成自己的知识网络。