8.1 单例模式
单个实例. 在一个 java 进程中, 要求指定的类,只能有唯–个实例。(尝试 new 多个实例的时候, 就会直接编译报错)
单例模式是校招中最常考的设计模式之⼀.
啥是设计模式?
设计模式好⽐象棋中的 “棋谱”. 红⽅当头炮, ⿊⽅⻢来跳. 针对红⽅的⼀些⾛法, ⿊⽅应招的时候有⼀些固定的套路. 按照套路来⾛局势就不会吃亏.
软件开发中也有很多常⻅的 “问题场景”. 针对这些问题场景, ⼤佬们总结出了⼀些固定的套路. 按照这个套路来实现代码, 也不会吃亏.
单例模式能保证某个类在程序中只存在唯⼀⼀份实例, ⽽不会创建出多个实例.
这⼀点在很多场景上都需要. ⽐如 JDBC 中的 DataSource 实例就只需要⼀个.
单例模式具体的实现⽅式有很多. 最常⻅的是 “饿汉” 和 “懒汉” 两种.
饿汉模式
类加载的同时, 创建实例.
class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
懒汉模式
懒汉模式-单线程版
类加载的时候不创建实例. 第⼀次使⽤的时候才创建实例.
class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
如果是首次调用 getlnstance, 那么此时 instance 引用为 null,就会进入 if 条件,从而把实例创建出来,如果是后续再次调用 getlnstance, 由于 instance 已经不再是 null,此时不会进入if, 直接返回之前创建好的引用了
这样设定,仍然可以保证,该类的实例是唯一一个。与此同时,创建实例的时机就不是程序驱动时了,而是第一次调用getlnstance的时候
这个操作的执行时机就不知道了,就看你程序的实际需求。大概率要比饿汉这种方式要晚一些,甚至有可能整个程序压根用不到这个方法,也就把创建的操作给省下了
有的程序, 可能是根据一定的条件,来决定是否要进行某个操作,进一步的来决定创建某个实例
比如,肯德基有个操作“疯狂星期四”,对于 肯德基 点餐系统来说,就可以判定今天星期几。如果是星期四,才加载 疯狂星期四 相关的逻辑和数据,如果不是星期四,就不用加载了(节省了一定的开销)
在计算机中,懒 的思想,就非常有意义
懒汉模式-多线程版
上述的代码,饿汉模式和懒汉模式,是否是线程安全的?? 如果在多个线程中, 并发的调用 getlnstance, 这两个代码是否是线程安全的呢??
饿汉: getlnstance 直接返回 Instance 实例. 这个操作本质上就是"读操作"。多个线程读取同一个变量,是线程安全的!!
懒汉: 线程不安全,在多线程环境下可能会创建出多个实例!!在懒汉模式中,代码有读也有写,如果 t1 和 t2 按照下列顺序来执行,就会出现问题!!
上⾯的懒汉模式的实现是线程不安全的.
线程安全问题发⽣在⾸次创建实例时. 如果在多个线程中同时调⽤ getInstance ⽅法, 就可能导致创建出多个实例.
⼀旦实例已经创建好了, 后⾯再多线程环境调⽤ getInstance 就不再有线程安全问题了(不再修改instance 了)
加上 synchronized 可以改善这⾥的线程安全问题.
class Singleton {
private static Singleton instance = null;
private Singleton() {}
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
懒汉模式-多线程版(改进)
多线程代码, 其实是非常复杂的,代码稍微变换一点,结论就截然不同!!
因此可千万不要以为,代码中写了 synchronized 就一定线程安全,不写 synchronized 就一定线程不安全!!!
一定要具体问题具体分析.要分析这个代码在各种调度执行顺序下可能的情况,确保每个情况都是正确的!!
此处要想让代码执行正确,其实是需要把 if 和 new 两个操作,打包成一个原子的!!
更加合理的做法,应该是把 synchronized 套到if 外头~~
但上述代码仍然存在问题~~
效率非常低!!!
如果 Instance 已经创建过了,此时后续再调用 getlnstance 就都是直接返回 Instance 实例了(此处的操作就是纯粹的读操作了,也就不会有线程安全问题了)
此时,针对这个已经没有线程安全问题的代码,仍然是每次调用都先加锁再解锁,此时,效率就非常低了!!!加锁就意味着可能会产生阻塞,一旦线程阻塞,啥时候能解除,就不知道了(你可以认为,只要一个代码里加锁了,基本就注定和“高性能"无缘)
在需要加锁的时候才加锁,不该加锁,不能随便乱加。所以除了 StringBuffer 还提供 StringBuilder, 除了 Vector 还提供 ArrayList
这个代码仍然有点问题~~
指令重排序,引起的线程安全问题
指令重排序,也是编译器优化的一种方式,调整原有代码的执行顺序,保证逻辑不变的前提下,提高程序的效率
instance = new singletonLazy();
这行代码,其实可以拆成三个大的步骤,(不是三个指令)
1.申请一段内存空间
2.在这个内存上调用构造方法,创建出这个实例
3.把这个内存地址赋值给 |nstance 引用变量
正常情况下,上述代码是按照 123 的顺序来执行的,但是编译器也可能会优化成132的顺序来执行,无论是123 还是132在单线程下都是可以的~~
1 就相当于是你买了个房子,2 就相当于给房子装修,3 就相当于你拿到房子的钥匙。123 拿到钥匙之后,就得到了装修好的房子. 称为"精装房",132你先拿钥匙,然后自己负责装修.称为"毛坏房"。如果你出去买房子,这两种情况都会存在!!!
但是, 如果是在多线程下,指令重排序,就可能引入问题了!!如果你出去买房子,这两种情况都会存在!!!
t1 按照132 的方式来执行这里的 new 操作:
上述代码中,由于 t1 线程执行完13之后,调度走,此时 instance 指向的是一个 非 null 的,但是未初始化的对象。此时 t2 线程判定 instance == null 不成立,就会直接 return.如果 t2 继续使用 instance 里面的属性或者方法,就会出现问题(此时这里的属性都是未初始化的"全 0"值). 就可能会引起代码的逻辑出现问题.
解决上述问题,核心思路, 还是 volatile
volatile 有两个功能
1.保证内存可见性,每次访问变量必须都要重新读取内存,而不会优化到寄存器/缓存中
2.禁止指令重排序.针对这个呗 volatile 修饰的变量的读写操作相关指令,是不能被重排序的!!
以下代码在加锁的基础上, 做出了进⼀步改动:
• 使⽤双重 if 判定, 降低锁竞争的频率.
• 给 instance 加上了 volatile.
class Singleton {
private static volatile Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这个代码非常重要,而且也是一个经典高频面试题,咱们同学们最近这几年秋招也会经常遇到这个问题~~
这个题并不简单。加上这三个点,怎么加,容易答上来,为啥要这么加,每个地方解决的什么问题,要想给面试官解释清楚,没那么容易的!!
(1)写博客,提前梳理好你都要说啥。
(2)给面试官讲的过程中,一定要多画图
线下面试,可以自带纸笔;线上面试,一般面试系统也会支持画图功能,可以共享屏幕。有的面试系统 牛客网面试系统,自身就支持画图,包括 腾讯会议,也支持画图
多去画!!!
目前来看线上面试越来越多,越是好的公司,越是线上面试
理解双重 if 判定 / volatile:
加锁 / 解锁是⼀件开销⽐较⾼的事情. ⽽懒汉模式的线程不安全只是发⽣在⾸次创建实例的时候. 因此后续使⽤的时候, 不必再进⾏加锁了.
外层的 if 就是判定下看当前是否已经把 instance 实例创建出来了.
同时为了避免 “内存可⻅性” 导致读取的 instance 出现偏差, 于是补充上 volatile .
当多线程⾸次调⽤ getInstance, ⼤家可能都发现 instance 为 null, 于是⼜继续往下执⾏来竞争锁, 其中竞争成功的线程, 再完成创建实例的操作.
当这个实例创建完了之后, 其他竞争到锁的线程就被⾥层 if 挡住了. 也就不会继续创建其他实例.
- 有三个线程, 开始执⾏ getInstance , 通过外层的 if (instance == null) 知道了实例还没有创建的消息. 于是开始竞争同⼀把锁.
- 其中线程1 率先获取到锁, 此时线程1 通过⾥层的 if (instance == null) 进⼀步确认实例是否已经创建. 如果没创建, 就把这个实例创建出来.
- 当线程1 释放锁之后, 线程2 和 线程3 也拿到锁, 也通过⾥层的 if (instance == null) 来确认实例是否已经创建, 发现实例已经创建出来了, 就不再创建了
- 后续的线程, 不必加锁, 直接就通过外层 if (instance == null) 就知道实例已经创建了,从⽽不再尝试获取锁了. 降低了开销.