无论从事哪门语言的开发,归根结底还是要熟悉语言背后的底层原理知识,那样才能把程序中出现的各种异常问题看得更透彻;否则在bug面前会显得束手无策,陷入进退两难的境地。
Volatile关键字的作用是什么?或者DCL是否需要加Volatile关键字?当面试管提出这类问题时,该如何解答,如果只是泛泛而谈,往往给面试官的印象也不会太好。归根结底,Volatile关键字的作用有两个。
1、禁止底层cpu进行指令重排。
2、保证线程的可见性。
为了解释这两个作用,以Java中单例为模型进行讲解,DCL单例代码如下:
public class Singleton
{
private static volatile Singleton INSTANCE;
private Singleton(){}
public static Singleton getInstance(){
//Double Check Lock,双重检查
if(INSTANCE == null)
{
synchronized(Singleton.class){
//Double Check Lock
if(INSTANCE == null)
{
try{
Thread.sleep(1000);
}catch(InterruptedException e)
{
e.printStackTrace();
}
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
现在逐步讲解Volatile关键字的两个作用。CPU的执行速度远大于读取磁盘的IO速度,假如程序的某条指令需要读取磁盘,CPU不可能一直在等待读取磁盘的响应;为了效率,编译器会优先执行耗时较低的指令,只要程序最终执行的结果一致就行,因此在一些QPS较高或者并发量比较大的场景下,编译器会对指令进行重排,已达到最佳的执行效果。
以上述单例程序中的 ***INSTANCE = new Singleton()***为例,该条语句的执行过程可分为三步:
1、分配对象内存空间。
2、初始化对象。
3、将INSTANCE指向刚分配的内存地址。
因为2和3的先后执行并不影响程序的最终结果,因此编译器有可能是按照1、3、2的顺序执行完上述的指令,当有多个线程调用getInstance()方法时,就会出现如下的状况:
线程1调用getInstance()方法创建一个单例对象,当执行到第二个if语句里面时候;完成对象内存空间的分配并将INSTANCE指向刚分配的内存地址,此时INSTANCE对象处于半初始化状态(值为0,但不为空),正好此时线程1的CPU时间片被用完。那么线程2调用getInstance()方法时,执行到第一个if语句时,判断结果为false,直接返回了一个未初始化完全的INSTANCE对象,那么程序肯定会出现异常。
解释完Volatile关键字的第一个作用,现在着手分析第二个作用。何为线程可见性?在了解这个概念前,还需要粗略地介绍一下缓存一致性的概念,因为CPU执行速度远超过访问内存的速度;假如我们程序中有一个变量var被存储在内存中,当CPU A把内存中var变量读取进来并执行相应的操作,改变var的值;为了执行效率CPU A会保留一份var的副本到缓存行(Cache Line );在将var变量写回内存前,CPU A中的Cache Line 处于M(MODIFIED)状态,如果CPU A将var写回内存,Cache Line 会变成E(Exclusive)状态。当CPU B去读取内存中的var变量并缓存了Cache Line,那么Cache Line 会变成S(Shared)状态;当CPU A再次对var执行操作时,会发出信号通知CPU B中Cache Line变成INVALID状态,如果此时CPU B去读取var变量的值时,只能从内存中去读取,而非缓存行。
那么基于上述的描述思考一下,当线程1创建了一个INSTANCE对象,此时线程2应该应该会得到“通知”,从内存中去读取INSTANCE的值,而不是从缓存中去读取,这便是线程的可见性原理。