面试官考我,单例类中为什么要使用volatile关键字?我表示...😏
环境说明:Windows10 + Idea2021.3.2 + Jdk1.8 + SpringBoot 2.3.1.RELEASE
一、前言
有一期《【java笔试题】如何手写一个单例类?》一文中我们提到,由于volatile关键字它可以禁止指令重排序来保证一定的有序性,故而解决了多线程情况下双重检查模式单例空指针问题。
那你们知道为何多线程下双重检查模式会导致空指针异常吗?一开始我也没多想,但是这样的学习模式是不对的,道听途说不如自己亲手试验,检验出真知,于是我花了半个小时,终于搞清楚了!
二、双重检查模式
给大家再回顾一下懒汉单例之双重检查模式是如何手撕的,代码仅供参考。大家请看:
public class DoubleLazySingLeton {
private static DoubleLazySingLeton instance;
// 私有构造方法
private DoubleLazySingLeton() {
System.out.println("生成DoubleLazySingLeton实例一次!");
}
// 对外提供静态方法获取该对象
public static DoubleLazySingLeton getInstance() {
// 第一次判断,如果instance不为null,不进入抢锁阶段,直接返回实例.
if (instance == null) {
//instance未实例化的时候才加锁
synchronized (DoubleLazySingLeton.class) {
// 抢到锁之后再次判断是否为null
if (instance == null) {
instance = new DoubleLazySingLeton();
}
}
}
return instance;
}
}
看完如上单例代码实现,大佬都陷入了深思,比如我(大佬骂骂咧咧的说道:谁写的?打发要饭呢!写的啥玩意,重写)。
为什么反响会如此剧烈,有同学可能会说写的不是挺合理的嘛? 不着急,接着往下看,我会告诉你为什么。
三、案例分析
我们在学习volatile关键字的时候,发现它可以禁止指令重排序从而保证执行有序性,等价于使用它就可以保证new DoubleLazySingLeton()创建对象实例化过程时的顺序不变。
具体volatile是如何保证的呢?这就得从volatile关键字的源码下手了,这节我们先不深究,重点是要理解new DoubleLazySingLeton()为何会出现不按顺序实例化的问题?而且为何要保证实例化的顺序性呢?这才是我们本文的重中之重,带着这两个问题我们接着往下看。
首先,我们都知道创建一个对象可以分为5步,对吧。
那5步呢?大家请看我画的示意图:
所以,你们可以思考一件事了,如下实例化有何问题?
instance = new DoubleLazySingLeton();
从表面上看,没有任何问题,但是结合双重检测模式来看,那就非常有问题了。
虽然单线程下的双重检测模式非常完美,但是在并发环境中就是bug般的存在。因为new DoubleLazySingLeton()实例化它并不是一个原子操作,我们看创建对象过程也可以得知。因此我们可以把这个实例化抽象成三条jvm指令:如下:
memory = allocate(); //1:分配对象的内存空间
initInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址
上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以jvm可以以“优化”为目的对它们进行重排序,经过重排序后顺序假设如下:
memory = allocate(); //1:分配对象的内存空间
instance = memory; //3:设置instance指向刚分配的内存地址(此时对象还未初始化)
ctorInstance(memory); //2:初始化对象
可以看到指令重排之后,操作 3 排在了操作 2 之前,即引用instance指向内存memory时,这段内存还没被初始化。 所以,你们发现什么问题了么?如果还没发现不着急,请接着往下看。
四、场景模拟
现在我们再来模拟个场景:存在两个线程:线程A与线程B,它两同时调用getSingleton()获取instance对象。
然后按如下时间段进行执行,请问大家,但线程执行完会发什么?
很明显,线程B会访问到一个还未完成初始化的"半"个instance对象。为什么?当线程A执行到T2时间后已经将instance指向了一块内存空间,此刻线程B调用getInstance(),执行到 if ( instance == null ) 语句时,instance == null结果肯定为false,因为instance已经被指定内存了不为null,然后直接执行return instance 语句结束,结果返回了一个没有完成初始化的“半个”单例。
也就是我一开始给大家说的指令重排之后,先执行了A3,再执行A2,这样本身没有问题,但如果遇到有其他线程碰巧在你指令重排后没实例化完成前调用getInstance()获取instance对象,这肯定会抛异常(如上示例)。最终的结果肯定是送你NullPointerException大礼包。
五、总结
所以对于双重检查模式下的单例而言,就会存在线程安全问题,怎么解决该线程安全,那就可以用volatile来保证。
顾解决线程安全的核心就是要保证instance对象顺序实例化,而volatile可以禁止指令重排序,这样尽管是多线程环境下,也不用担心instance实例化所带来的线程安全问题啦,这么讲,大家可得明白没有。
... ...
ok,以上就是我这期的全部内容啦,如果还想学习更多,你可以看看我的往期热文推荐哦,每天积累一个奇淫小知识,日积月累下去,你一定能成为令人敬仰的大佬。
「赠人玫瑰,手留余香」,咱们下期拜拜~~
文末💭
我是bug菌,一名想走👣出大山改变命运的程序猿。接下来的路还很长,都等待着我们去突破、去挑战。来吧,小伙伴们,我们一起加油!未来皆可期,fighting!

感谢认真读完我博客的铁子萌,在这里呢送给大家一句话,不管你是在职还是在读,绝对终身受用。 时刻警醒自己:抱怨没有用,一切靠自己
想要过更好的生活,那就要逼着自己变的更强,生活加油。
转载自:https://juejin.cn/post/7210220134600818746