前言
上一篇博客中我们主要讲解了单例模式中的懒汉模式和饿汉模式的代码书写,这一次我们把线程安全也考虑进内,来再次书写一下代码
1. 分析两种写法是否会构成线程安全问题
饿汉模式
class Singleton{
private static Singleton instance = new Singleton();
public static Singleton getInstance(){
return instance;
}
private Singleton(){
}
}
懒汉模式
class SingletonLazy{
private static SingletonLazy instance = null;
private SingletonLazy(){
}
public static SingletonLazy getInstance(){
if (instance == null){
instance = new SingletonLazy();
}
return instance;
}
}
首先大家需要知道会产生线程安全问题的代码在哪?两处代码都是在getInstance方法中。
饿汉模式不会涉及到线程安全问题,因为getInstance方法直接返回instance,return进行读操作,不会产生线程安全问题。
而在懒汉模式中,那就会涉及到线程安全的问题,但是大家可能会说哪里会存在呀,之前说的线程安全问题的出现是因为 ++ (count++),++操作是非原子的,所以会出现线程安全问题,
我们通过画图来演示一遍。
通过画图,大家可能还是搞不懂,哪里出现了线程安全问题,我再仔细说一下。
t1线程进行到 if判断语句的时候,instance 是 null,进行 new 操作,与此同时,t2线程也进行 if 判断语句,此时 instance 也是 null,又进行了一次 new 操作,那么 instance 又加载了一次,之前的instance 就被覆盖了。而且随着第二个线程的覆盖操作,t1线程的new 出来的对象,就会被 垃圾回收机制给释放掉了。这就会造成线程安全。
但是就算把这个机制讲清楚了,但是 一定是有人问,覆盖是线程安全问题吗?
答案是。
主要是写的这个代码比较简单,new的对象简单,但是在一个大厂中,一个企业级的项目中,new中的这个对象过程中,可能要把100G的数据从硬盘中加载到内存中,本来程序启动的时间是10分钟,但是由于上述的bug问题加载了两份,导致最终的时间远远超过了10分钟,所以按照上述的写法,是存在线程安全问题的。
那么如何解决呢?毋庸置疑,加锁。
2. 对代码进行加锁
class SingletonLazyThread{
private static SingletonLazyThread instance = null;
private static Object locker = new Object();
private SingletonLazyThread(){
}
public static SingletonLazyThread getInstance(){
if (instance == null){
synchronized (locker){
instance = new SingletonLazyThread();
}
}
return instance;
}
}
先看这样加锁是对的吗?
答案不是,因为我们希望的是 把判断和修改操作同时进行,把这两个操作同时进行打包处理,所以应该是 先加锁,然后再判断。
应该这样写
class SingletonLazyThread{
private static SingletonLazyThread instance = null;
private static Object locker = new Object();
private SingletonLazyThread(){
}
public static SingletonLazyThread getInstance(){
synchronized (locker){
if (instance == null) {
instance = new SingletonLazyThread();
}
}
return instance;
}
}
不要以为进行加锁操作之后,就万事大吉了,需要在画图来再观察一下。
因此,引入加锁之后,后执行的线程就会在加锁的位置阻塞,阻塞到前一个线程解锁,当后一个线程进入条件的时候,前一个线程就已经修改完毕了。instance不再是null,那么就不会进行后面的new 操作了。
当然上面的getInstance的加锁操作也可以换个写法。
也可以这样写
public synchronized static SingletonLazyThread getInstance(){
if (instance == null){
instance = new SingletonLazyThread();
}
return instance;
}
这样写法,相当于锁对象换成了 类对象SingletonLazyThread.class 和之前的locker相比没有什么区别。
3. 分析加锁之后带来的问题一
有人会说,进行加锁操作之后,怎么还会有问题呢?
没有办法呀,当你发现问题,引入新的事物解决了之前的问题,那谁都无法保证新事物不会带来新的问题呀。
只有当引入的新事物,可以解决所有的问题,方才会无懈可击。(其实也是相对的吧)
所以我们再来观察一下代码,结合之前的知识,想想哪里会有问题呢?
首先想会不会构成死锁?
死锁构成的普遍的场景:
- 一个线程,一个锁,连续加锁多次
- 两个线程两把锁,都同时争抢一把锁,构成阻塞
- 哲学家就餐问题
第一种情况,是可重入问题,JVM都给我们解决了,并且在这也不会涉及到。
第二和第三种情况,我们在这只有一把锁呀,怎么会构成请求和保持呢?
所以不存在死锁的这样的问题的。
实际上这次加锁带来的问题很隐晦,我们需要继续往深入探索。
我们深入探讨一下,有没有线程安全问题 或者 会出现什么别的问题?
当我们第一次把实例创建好之后,后续在调用 getInstance,此时都是直接执行 return操作,如果没有 synchronized加锁操作,而是只有if判断+return,那么只是单纯的读操作了。
但是现在呢?每次调用上述的方法,都会触发一次加锁操作,虽然不涉及到线程安全问题,但是在多线程的情况下,这里的加锁,就会相互阻塞,从而影响程序的运行效率!
因此,我们需要按需加锁,真正涉及到线程安全的时候,在加锁。
不涉及线程安全的时候,就不加锁。
4. 解决问题一
上述代码怎样进行按需加锁?
首先按需加锁,需求是什么呢?
就是:真正涉及到线程安全的时候,在加锁。不涉及线程安全的时候,就不加锁。
第一个 “真正涉及到线程安全的时候,在加锁”,这个已经成功了,就是synchronized语句。
那么第二个 “不涉及线程安全的时候,就不加锁”,这个该怎么用代码实现呢?
因为我们已经知道了一点,只要满足了线程安全,那么一定就有且只有一个instance,那么是不是就是说,咱们只要判断 instance 是否为空,那么就可以了。
所以这样写代码,
public static SingletonLazyThread getInstance(){
if (instance == null){
synchronized (locker) {
if (instance == null) {
instance = new SingletonLazyThread();
}
}
}
return instance;
}
这样写大家会不会感到奇怪,因为是一个函数中两个if判断语句的判断条件是一样的,不是多此一举吗?其实不然。
以往都是 单线程 的操作,
单线程中,连续两个相同的 if,是毫无意义的
单线程中,就只有一个,上一个 if 的判定结果和下一个 if 是一样的
但是在多线程中,两次判定之间,看存在其他线程
就把 if 中的 instance 变量就修改了,会导致这里的两次 if 结论就可能不同了。
其中最外层的 if 判断针对的是,是否需要加锁,主要用于instance已经创建好之后,才起到作用。
那么最里面的 if 判断针对的是 是否需要new 对象,主要针对的是 instance还没有创建新的对象。
而两层if语句中的 synchronized操作,就是为了防止在多线程中,instance被创建多次,从而影响效率!
5. 分析加锁之后带来的问题二
毋庸置疑,大家会说,我去,怎么还有问题呀。
现在我们需要考虑是否会构成 内存可见性 问题呢?
可能存在,主要是编译器优化这个事情,是非常复杂的。
所以,为了稳妥起见,给 instance 加上一个 volatile。
class SingletonLazyThread{
private static volatile SingletonLazyThread instance = null;
private static Object locker = new Object();
private SingletonLazyThread(){
}
public static SingletonLazyThread getInstance(){
if (instance == null){
synchronized (locker) {
if (instance == null) {
instance = new SingletonLazyThread();
}
}
}
return instance;
}
}
其实,还有一点就是之前没有讲过的一个问题,就是指令重排序,使用 volatile 可以杜绝指令重排序的问题。
那么什么是指令重排序呢?
其实它本身也是编译器优化的一种体现形式,编译器会在逻辑不变的前提下,调整代码执行的先后循序,为了达到提升性能的效果。
这个效果的提升不只是javac自己的工作,通常是 javac和JVM配合的效果 (甚至操作系统也要配合)。
用一个通俗的例子来解释一下什么是指令重排序?
现在女朋友让你去超市要买茄子,鸡蛋,西红柿和黄瓜,然后超市中这四种食材的位置分布是这样的,
最优解的顺序应该是:西红柿,黄瓜,茄子和鸡蛋。我们在满足购买这四种食材的基础上,寻找到了最合适的购买方案,这就是指令重排序。
在多线程中,指令重排序可能就会对代码出现一些问题。
那么大家就会说,哪处代码会出现指令重排序问题呢?
答案就在这:
instance = new SingletonLazyThread();
这个代码会出现问题!
Java中虽然没有谈到 “指针” 这两个字,
但是,可以把 引用 就看做是一种 “简化版” 的指针
粗略地认为,引用里面保存的就是一个对象的内存地址。
这个代码会进行三个操作,
第一个,申请内存空间
第二个,空间上构造对象
第三个,内存空间的首地址赋值给引用变量
正常来说,这三个步骤,就是按照1,2,3,这样的顺序来执行的。
但是在指令重排序下,可能 会出现 1 3 2 这样的顺序,如果在多线程的情况下,就会出现 bug。
举个场景:
这三个步骤,可以类比成买房子,房子主要就是两种
1, 精装房 2. 毛坯房
主要就是三个步骤:
a. 买房子 b. 装修 c. 拿到钥匙
对于 精装房来说,执行的顺序是:a,b,c
对于 毛坯房来说,执行的顺序是:a,c,b
那么怎么会有问题呢?
很简单。
t1线程,在 getInstance 方法中,先进行买房, 拿到钥匙 最后是装修的这一套流程。
注意在拿到钥匙的步骤中,instance已经不是 null,因为没有进行装修 这个步骤,所以他也不是 真正的 instance,要记住一点 不是null。
与此同时,t2线程发现 instance 不是 null,那么 执行下面的操作,只不过咱们写的过于简单,可能业务中存在,修改 instance 的func方法,并且需要立刻执行,一进入到这个代码中,发现 instance 这个变量该有的东西都没有,那么怎么执行下面的操作呢,就会报错!
综上,其实构成上述问题的是两个原因,一个是指令重排序
另一个是双重if判断。
因此volatile 的功能有两个:
- 确保每次读取操作,都是读取内存
- 关于该变量的读取操作和修改操作,不会触发指令重排序!
下一个博客我们讲解阻塞队列,我们不见不散!