🥰🥰🥰来都来了,不妨点个关注叭!
👉博客主页:欢迎各位大佬!👈
文章目录
1. 单例模式的初识
何为单例模式呢~
【单例模式】
单例模式是一种经典的设计模式,经常考的设计模式之一,所以是十分重要的
这里又有一个新的概念,设计模式是什么?
【设计模式】
相当于软件开发中的棋谱,一些大佬们针对一些常见的场景,总结出来的代码编写套路,按照套路来写,不能说代码可以编写得多么好,但是至少不会很差,这类似于在下棋中,通过前人总结出来的一些固定下棋套路,按照这些棋谱来下,不能说下得多么漂亮,但是至少不会很差,这两者是一个道理,这种设计模式相当于兜底,给我们了一种模板
其中,设计模式有很多种,之前有个大佬写了本书,流传很广,讨论23种设计模式,很多人就认为设计模式只有23种,并不是的,只是在这本书中讨论了这23种设计模式,其实设计模式还有很多种!!!
在我们这个阶段,主要考察两个设计模式:
1)单例模式
2)工厂模式
设计模式需要我们有一定的开发经验的积累,才好理解,尽管我们现在还没有积累一定的经验,我们还是可以尝试去理解它,在实际应用开发中,我们将会有一个更深刻的了解,下面我们一起来看看单例模式!
2. 单例模式的含义
从字面理解,单例就是单个实例(instance),即在一个程序中,某一个类,只能创建出一个实例(一个对象),不能创建多个对象! (回顾JavaSE中所学的知识,类的实例就是对象~)
这里不是说多 new 几次,就可以创建多个对象,在语法上有办法禁止多 new,在Java中的单例模式,借助Java语法,保证某个类只能创建出一个实例,而不能new多次,不能创建多个对象
【单例模式】能保证某个类在程序中只存在唯一一个实例,而不会创建出多个实例
【场景需要】有些场景就需要某个概念是单例的,比如在生活中,一夫一妻制,再比如,JDBC中的 DataSource 实例就只需要一个
3. 单例模式实现的两种方式
在Java语法中,实现单例模式有很多种写法,本文主要介绍以下两种实现方式:
1)饿汉模式(急迫版)
2)懒汉模式(从容版)
3.1 饿汉模式
在生活中,吃完饭后去洗碗,饿汉模式 —— 吃完之后立刻去洗碗,超急迫的~
对应计算机中的栗子,打开一个硬盘上的文件,读取文件内容,并显示出来,饿汉模式 —— 把文件所有内容都读到内存中并显示出来
但是假设文件非常大,比如10G,饿汉模式文件打开可能都要半天,内存够不够我们都不清楚~
饿汉模式 —— 类加载的同时创建实例,代码如下:
// 把这个类设置为单例 class Singleton { private static Singleton instance = new Singleton(); //获取到实例的方法 public static Singleton getInstance() { return instance; } //禁止new 将构造方法设置为private private Singleton() { }; } public class ThreadDemo14 { public static void main(String[] args) { Singleton s1 = Singleton.getInstance(); //此时s1和s2是同一个对象 //Singleton s2 = Singleton.getInstance(); //此时不能再进行new了,外部无法创建实例 //Singleton s3 = new Singleton(); } }
结果分析:
1)s1 和 s2 获取到的其实是同一个对象
2)运行 s3,将会报错,因为外部无法再 new 一个对象,已禁止该操作了
具体实现过程如下:
以上就是饿汉模式代码,通过Java语法来限制类实例的多次创建保证单例的特性:
- staic 修饰 instance,保存单例对象的唯一实例
- 并用 private 修饰 instance,将该实例进行封装
- 如果要获取该实例,通过调用 getInstance() 方法获取这个实例
- 将构造方法用 private 修饰,可禁止外部 new 实例操作,即不可多次 new 对象
对于 private 修饰的方法,我们会有一个疑问:反射不是可以获取到私有方法吗?
1)反射本身就是一个非常规的手段,反射本身就是不安全的(能不用就不用)
2)单例模式有一种实现方式可以保证反射下得安全,通过枚举即使使用反射也可以保证单例(这里不作过多介绍)
但是饿汉模式存在一个问题,那就是实例的创建时机过早了,可以看到,实例在类内部就创建好了,只有类一加载,就会创建出这个实例,如果后面并没有用到这个实例,其实会有点浪费的意思,更好的实现方式是懒汉模式,即用的时候再创建,下面介绍懒汉模式
3.2 懒汉模式
在计算机中,谈到"懒",一般其实是褒义词,想想为什么我们的科技能够进步,社会能够发展,其本质动力,都是为了更便捷,源动力全靠"懒"~
继续洗碗的栗子,懒汉模式 —— 吃完饭后,先把碗放着,等到下一顿吃饭时,需要用到碗时再去洗,超从容!
继续打开硬盘文件的栗子,饿汉模式 —— 只把文件读一小部分,把当前屏幕填充上,如果用户翻页了,再读其它文件内容,如果不翻页,就不需要再去读
如果文件非常大,懒汉模式就可以快速打开,毕竟不用一次都打开完,等需要某部分就打开某部分
(尽管懒汉模式会增加硬盘的读取次数,但是和饿汉模式情况相比,其实是不值得一提的~)
通常认为,懒汉模式更好,效率更高,核心思想:非必要,不创建,即非必要不去做某事,等到要去做某事的时候再去做
3.2.1 懒汉模式(单线程版)
懒汉模式(单线程版) —— 类加载的时候不创建实例,第一次使用的时候才创建实例,即需要使用这个实例的时候才创建它 ,代码如下:
//懒汉模式实现单例模式 //懒汉模式实现单例模式 class SingletonLazy { private static SingletonLazy instance = null; //先置为空 public static SingletonLazy getInstance() { //只有调用这个才会创建对象 if (instance == null) { instance = new SingletonLazy(); } return instance; } private SingletonLazy() { } } public class ThreadDemo15 { public static void main(String[] args) { SingletonLazy s1 = SingletonLazy.getInstance(); SingletonLazy s2 = SingletonLazy.getInstance(); System.out.println(s1 == s2); } }
运行结果如下:s1 和 s2 获取到的是同一个对象,所以结果返回 true
具体实现过程如下:
- 先将 instance 设置为 null
- 当需要使用 instance 的时候,调用getInstance()方法,如果 instance 为null,则需 new 一个,不为空,则说明已经有一个实例,不需要new,直接使用该实例(单例模式就是一个类只有一个实例)
- 使用单例,调用getInstance()方法
以上就是懒汉模式代码,与饿汉模式代码的实现方式类似,最大的区别在于懒汉模式只有在需要使用实例时才会创建,所以要将创建实例写在getInstance()方法里面,懒汉模式通过Java语法来限制类实例的多次创建保证单例的特性与饿汉模式一致,这里就不再赘述啦~
3.2.2 懒汉模式(多线程版)
1)多线程情况下为什么只讨论懒汉模式而不讨论饿汉模式呢?
上述的两个代码,是否线程安全呢?即多个线程下调用 getInstance()方法,是否会出现问题?
【结论】
饿汉模式天然线程就是安全的,因为只是读数据
懒汉模式是线程不安全的,因为有读有写
所以,为什么讨论多线程下懒汉模式,是因为懒汉模式在多线程下,可能无法保证创建对象的唯一性,会出现问题,我们需要一定的措施去解决这个问题以保证它是线程安全的,而饿汉模式本身则是线程安全的~
2)懒汉模式线程不安全的原因
回顾线程不安全的原因:线程不安全原因
- 抢占式执行
- 修改共享数据
- 修改操作不是原子的
- 内存可见性
- 代码顺序性(指令重排序)
懒汉模式线程不安全的最直接原因 —— 多个线程修改同一个变量
【分析】
在饿汉模式中,getInstance()方法直接进行返回,没有涉及到改的操作
而在懒汉模式中,getInstance()方法需要先判断 instance 是否为 null,如果是的,就需要对 instance 进行修改, new 一个实例,再返回,如果不是则直接返回
通过上述分析,可以知道在懒汉模式中涉及到修改的操作,在多线程下,由于有多个线程,可能会创建出多个实例,无法保证创建对象的唯一性!下面进行进一步分析:
【严重性】
如果是N个线程一起调用 getInstance()方法,可能创建出N个对象,我们可能会想,这不就是 多 new 些对象的事情嘛,有什么大不了的嘛,其实并不是这样的,对象是有大有小的,有些对象可能会很大,管理的内存数据可能会特别多,如果这个对象管理特别多的内存数据,多 new 几次,内存根本不够呀!所以,线程不安全带来的问题是很严重的!!!
3)解决方式
回顾之前的内容,线程安全问题的措施 如下:
- 使用 synchronized 关键字进行加锁,保证操作原子性
- 使用 volatile 关键字,可保证内存可见性和禁止指令重排序
通过之前的分析可知:懒汉模式线程不安全是因为多个线程修改同一个变量!进一步分析,引起上述问题的原因是 if判定操作和修改操作是分开的,并不是原子的,显而易见,可以通过加锁来解决这个问题~
这就有一个问题了,锁要加在哪里? 这是值得我们深入思考的,要知道多线程代码是很复杂的,并不说加锁就一定可以解决问题,必须要具体问题具体分析,下面举一个错误的加锁:
将锁加在了 new 对象的操作上,以类对象为锁对象,这样的加锁方式可行吗?显然是不行的,我们加锁需要保证 if 判定操作和 new 对象操作作为一个整体的,是一个原子操作!才能解决上述问题,而仅把锁加在 new 对象的操作上,仍然不能保证原子性,所以这是错误的加锁方式!!!
1)将 if 操作也放到锁里,保证 if 判定操作和 new 对象操作是一个原子操作
public static SingletonLazy getInstance() { //只有调用这个才会创建对象 synchronized (SingletonLazy.class) { if (instance == null) { instance = new SingletonLazy(); } } return instance; }
2)或者直接将锁加在方法上,保证整个方法都是原子的
synchronized public static SingletonLazy getInstance() { //只有调用这个才会创建对象 if (instance == null) { instance = new SingletonLazy(); } return instance; }
在前面也讲过,加锁是一个比较低效的操作,因为加锁就可能涉及到阻等待,需坚持非必要,不加锁的原则,在上述加锁方式中存在一个问题:在任何时候调用getInstance()方法都会触发锁竞争!
事实上,此处的线程不安全问题只是出现在首次创建对象这里,一旦对象 new 好了,后续调用getInstance()方法时,仅仅就是读操作了,不涉及到修改,也就没有线程安全问题了,就没必要加锁了!!!
因此,需要对加锁的位置进行优化,下面具体来介绍如何进行优化的~
a. 优化一:修改锁的位置解决效率问题
【问题】到底什么时候需要加锁?
【回答】上述分析可得,在首次 new 对象时候需要加锁
【措施】需要再加一层 if 判断,用来判断需要加锁的情况
public static SingletonLazy getInstance() { //只有调用这个才会创建对象 //这个if判断是用于判断是否要加锁,如果对象已经有了,此时无需加锁,本身就是线程安全的 if(instance == null) { synchronized (SingletonLazy.class) { //这个if判断是如果为空则创建对象 if (instance == null) { instance = new SingletonLazy(); } } } return instance; }
1)在初心(目的)上,这两个 if 条件看起来是一样的,但是这两个条件的初心即目的是不同的,只是巧了,正好是一模一样的代码
第一个 if 语句目的:判断是用于判读是否要加锁,如果对象已经有了,此时无需加锁,本身就是线程安全的
第二个 if 语句目的:判断 instance 是否为空,如果为空则创建对象
2)在执行时机上,这两个 if 条件紧挨着,实际上,这两个 if 语句的执行时机有着巨大的差别!
按照我们之前的理解,在单线程代码中,如果两行代码紧挨着,在执行的时候,这两行代码会被迅速执行完,可以近似地看作这两个 if 语句是"同一时机"被执行的
但是在多线程中,上述两个 if 语句中还间隔着一个 synchronized 的情况下,就不能简单地这样理解了
因为加锁就可能导致线程阻塞,而啥时候解除阻塞,无从知晓,可能过了很久才解除阻塞,那么这两行代码虽然看起来是相邻且相同的,但如果调用的时间间隔长了,判断结果也可能会不同!
就比如在一个线程执行时,一开始 instance 为 null,第一个 if 判定成立,进入第一个 if 中,但接下来获取锁时却发现,锁已经被其它线程获取了,那么这个线程此时就只能阻塞等待,等到这个线程结束阻塞,获取到锁的时候,再继续往下执行,发现 instance 已经被别的线程创建好了,不再为 null,第二 if 判断就不成立,此时该线程不会进入到第二个 if 中去,也就不会重复再 new 一个对象
b. 优化二:使用volatile修饰解决 new 操作引发指令重排序
注意!!! 优化后的代码,仍然还存在一个很重要的问题!!! —— 指令重排序,指令重排序也可能导致线程不安全问题
这是怎么一回事呢?回顾之前指令重排序的案例(有些遗忘的,可回顾这期内容)我们一起来分析分析这个代码~
new 的操作大体包括以下3个步骤:
1)申请内存空间
2)调用构造方法,即初始化内存的数据
3)把对象引用赋值给 instance,即内存地址的赋值
这就可能存在指令重排序问题,其中在单线程下步骤2) 和 3) 可以互换顺序,但是在多线程下,如果按照1) 3) 2)的顺序,则可能会出现问题!
假如 instance 为 null,当线程 t1 执行完 1) 和 3) 这两个步骤后,被线程 t2 调度,t2 线程再进入 if 判断时,由于 t1 线程已经申请内存空间并将对象引用赋值给 instance 了,instance 已经不为 null,此时条件不成立,t2 线程中的getInstance()方法则直接返回 instance,实际上 instance 指向的对象还没调用构造方法,即 t2 拿到的是一个没装修过的毛坯房,如果 t2 线程继续往下执行,调用后续的方法,可能就都是将错就错了 !
尽管上述过程是一个极端小概率情况,但在高并发、大数据的情况下,一旦出现上述问题,后果是十分严重的,不容小视!
【解决方式】volatile 修饰 instance即可,volatile可禁止指令重排序!
最后懒汉模式整体代码如下:
class SingletonLazy { volatile private static SingletonLazy instance = null; //先置为空 public static SingletonLazy getInstance() { //只有调用这个才会创建对象 if(instance == null) { synchronized (SingletonLazy.class) { if (instance == null) { instance = new SingletonLazy(); } } } return instance; } private SingletonLazy() { } } public class ThreadDemo15 { public static void main(String[] args) { SingletonLazy s1 = SingletonLazy.getInstance(); SingletonLazy s2 = SingletonLazy.getInstance(); System.out.println(s1 == s2); } }
【Q】我们知道 volatile 关键字除了禁止指令重排序,还有保证内存可见性的效果,那么在上述代码中,有没有内存可见性问题呢?
【A】暂时保留疑问,在此不作定论
这个代码与之前内存可见性的案例代码差别很大,内存可见性问题发生在由于频繁读,编译器优化掉寄存器从内存读取数据到CPU寄存器的操作,使每一次读数据并没有真正从内存中读取,在上述代码中是否存在频繁读问题,假设 N 个线程一起调用,是否就相当于读了 N 次,触发优化到寄存器中的操作?
这其实是不一定的!!! 每个线程都有自己的一套寄存器,这会不会出现上述问题,无法确定~
4. 面试题 —— 单例模式的线程安全问题
其实就是本期后半部分内容的小结~ 知识点都讲完啦!
【饿汉模式】天然就是线程安全的,因为只是进行读操作
【懒汉模式】是线程不安全的,因为既有读操作,也有写操作
保证懒汉模式线程安全问题的措施:
- 加锁,把 if 操作 和 new 操作 变成原子操作
- 双重 if,减少不必要的加锁操作,坚持非必要,不加锁的原则
- 使用 volatile 禁止指令重排序,保证后续线程拿到的肯定是一个完整的对象
💛💛💛本期内容回顾💛💛💛
✨✨✨本期内容到此结束啦~