引言
设计模式,这是编程中的灵魂,用好不同的设计模式,能使你的代码更优雅/健壮、维护性更强、灵活性更高,而众多设计模式中最出名、最广为人知的就是Singleton Pattern
单例模式。单例模式是一种创建型设计模式,它确保一个类只会有一个实例,并提供一个全局共用的访问点来获取这个实例。
在很多场景下,比如配置管理类、线程池、各种Dao/Service
对象、数据库连接池等,我们其实只需要一个实例就够了,毕竟这些实例都可以共用,每次使用时创建一个实例,反而会带来初始化损坏与额外的内存资源浪费。通过单例模式,我们就可以避免由于多个实例的创建和销毁带来的额外开销,本文就来一起聊聊单例模式。
一、全解单例模式
单例模式精髓可以用一句话概述:私有化构造函数,公开化获取实例方法,因为将构造函数私有化了,意味着外部就无法通过new
关键字,来创建对应的对象实例,这时只能通过提供的getInstance()
方法获得对象实例。
单例单例,这就说明全局只有一个对象实例,任意时刻、任意位置调用getInstance()
方法,拿到的都是同一个对象,这就叫做单例模式。而getInstance()
方法获得的对象什么时候被创建出来呢?根据创建时机的不同,就能分为不同的单例模式,如著名的饿汉式、懒汉式,下面一起来展开聊聊。
1.1、饿汉式单例
饿汉式单例,特性如同其名称,就跟一个从未吃饱饭的饿汉子一样,见到饭的第一眼就是干饱为止。将这个思想放到单例模式里,饿汉式单例是指:类加载的时候就立即初始化单例对象,带来的优势很明显,因为在类加载的时候就创建好了对象,这个时候还没有用户线程出现,所以这个单例对象绝对是线程安全的。
可缺点同样很明显,因为类加载时就创建了对象,如果后续很长时间没有线程来获取该对象,就会导致这个对象一直占用着内存,造成一定程度上的内存浪费,这也可以理解成一种另类的内存泄露场景。好了,那饿汉式单例怎么实现呢?代码如下:
public class HungerSingleton {
// 静态的实例对象,类加载阶段时就会被初始化
private static final HungerSingleton instance = new HungerSingleton();
/*
* 私有化构造函数
* */
private HungerSingleton() {
}
/*
* 公开化的获取实例方法
* */
public static HungerSingleton getInstance() {
return instance;
}
}
正如上述代码所示,因为构造函数变为了private
关键字修饰,代表外部不可能再通过new
创建出实例,只能通过getInstance()
方法获取,而学习过之前《JVM类加载机制》的小伙伴应该明白,被static
关键字修饰的instance
成员,就会在该阶段进行初始化。
当然,饿汉式除开上述这种写法外,还有另一种写法:
public class HungerSingleton {
private static final HungerSingleton instance;
// 类加载阶段时会初始化静态代码块创建单例对象
static {
instance = new HungerSingleton();
}
private HungerSingleton() {
}
public static HungerSingleton getInstance() {
return instance;
}
}
这种方式的原理也一样,静态代码块和静态变量的初始化时机相同,都会在类加载的时候触发,所以这里不过多废话。
1.2、 懒汉式单例
为了解决饿汉式单例在类加载阶段被提前创建导致的内存浪费问题,就产生了与之相反的懒汉式单例,而懒汉式单例则是懒加载思想的落地,懒加载思想的核心是:只有真正用的时候才会创建对象,对应的写法如下:
public class LazySingleton {
// 静态的实例对象
private static LazySingleton instance;
/*
* 私有化构造函数
* */
private LazySingleton() {
}
/*
* 公开化的获取实例方法
* */
public static LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
这种写法是经典的懒加载思想,最开始先声明一个对象,但并不会立马将其初始化,只有真正有线程来调用getInstance()
方法获取实例时,再判断一下instance
是否为空,如果为空才创建一个对象返回,如果不为空则获取已创建好的实例返回。
这种模式对内存最友好,只有真正需要用到时才会在内存创建对象,而不会提前创建占用内存。可是,懒汉式同样有着致命缺点,即在多线程环境下,会存在线程安全问题,为啥呢?我们来分析一下。
如果此时出现T1、T2
两条线程同时调用getInstance()
方法,那就有可能同时执行if (instance == null)
这行判空的代码,因为两条线程在并行执行,那么T1、T2
看到的instance
变量都为null
,这时T1、T2
就会各自new
一个实例对象,此时就出现了两个对象,从而违反了单例模式的特性。
Java
中如何保证线程安全?经验老道的小伙伴,下意识就会回答出synchronized
,所以想要解决懒汉式加载带来的线程安全问题,只需要在getInstance()
方法上加个关键字即可:
public static synchronized LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
通过synchronized
可以有效保证多线程环境下的安全性,可这种方式显然会影响性能,因为将synchronized
关键字加在方法上,会导致锁的颗粒度变大,每次线程调用getInstance()
方法前,都需要先获取到锁才能执行,代表所有线程都会被串行化,性能将会变得尤为低效。
1.3、双重锁单例(DCL)
如果直接将synchronized
关键字加在方法上,会导致getInstance()
方法性能特别差,怎么办?
大家先想想,我们为什么要加synchronized
关键字?为了解决首次初始化单例对象时,多条线程同时调用、并行执行判空代码,从而创建出多个对象实例的问题,正因如此,业界提出了一种名为双重锁单例的实现方式,如下:
public class DCLSingleton {
// 静态的实例对象
private static volatile DCLSingleton instance;
/*
* 私有化构造函数
* */
private DCLSingleton() {
}
/*
* 公开化的获取实例方法
* */
public static DCLSingleton getInstance() {
if (instance == null) {
synchronized (DCLSingleton.class) {
if (instance == null) {
instance = new DCLSingleton();
}
}
}
return instance;
}
}
因为最初的懒汉式单例写法,只有在首次初始化单例对象时才需要做空乏控制,也只有这个时候才存在线程安全问题。为此,上述的双重锁单例模式中,如果是第一次调用getInstance()
方法,这时第一个if
判空会成立,然后才会获取锁去创建单例对象。
而后续调用getInstance()
方法的线程,因为instance
已经不为空了,所以就不需要再获取锁创建对象,第一个判断不会再成立,那么会直接获取已有对象返回,这样既能保证线程安全,又能保证性能可观。
大家会看到,instance
成员还会被volatile
关键字修饰,同时synchronized
关键字里面还有个if
判断,这对许多未学习过并发编程的小伙伴来说有点不理解,怎么办?我们先来看下双重锁单例在多线程环境下的执行流程,如下(该图是以前的流程图):
1.3.1、为什么需要第二个if判空存在?
大家可以简单看下前面图中标注的流程,先来解释下为什么synchronized
里还要加个if
判断?因为synchronized
是阻塞式锁。还是用之前的例子来说明,T1、T2
两条线程一起调用getInstance()
方法,那么会同时执行第一个if (instance == null)
的判空代码,这时T1、T2
都会满足条件,然后执行if
里面的代码。
执行if
里面的代码时,因为是synchronized
关键字保护的临界资源,在执行前则需要获取到指定的class
类锁,假设T1
竞争锁成功,那么T2
会陷入阻塞状态;T1
继续往下执行,继续执行第二个if (instance == null)
判空动作,因为T1
最先来,所以条件肯定成立,所以T1
会执行new
指令创建一个对象并返回。
当T1
执行完成后会释放对应的锁资源,这时阻塞的T2
线程会被唤醒,注意看,之前T2
被阻塞在synchronized
这处代码,因为T1
已经释放锁了,所以T2
肯定能拿到锁,继续往下执行,如果里面没有第二个if
判空动作,那么T2
必定会再创建出一个对象,从而打破单例模式的特性,而第二个if
的作用就是这个:为了防止抢锁失败陷入阻塞的线程,被再次唤醒后再次创建一个对象实例。
1.3.2、为什么需要加volatile关键字?
好,接着来看第二个疑惑,为什么instance
需要用volatile
关键字修饰呢?想要说清楚原因,这就牵扯到了之前聊过的《Java内存模型-JMM机制》,首先明确一点,Java
中执行new
关键字创建一个对象时,并非一步到位的,而是会分成三步执行:
// 1.分配对象内存空间
memory = allocate();
// 2.初始化对象
instance(memory);
// 3.设置instance指向刚分配的内存地址,此时instance != null
instance = memory;
上面是new
指令执行时对应的伪代码,这段代码放在单线程环境下执行没有任何问题,可放在多线程环境里,就会存在一定隐患,为什么呢?因为CPU
也好,操作系统也罢,都是从单线程发展而来,早期为了尽可能的保证CPU
指令按流水线形式执行,编译器、处理器通常会在不破坏单线程语义的前提下,对指令进行重排优化。
经过指令重排优化后,上述伪代码就有可能从原本的1-2-3
这个顺序,变为1-3-2
这个顺序,毕竟步骤2
、步骤3
之间不存在数据依赖关系,无论重排前还是重排后,在单线程的语义中并没有改变,即在单线程环境下的执行结果都是一致的,为此,这种重排优化也是允许的。
也正是这种重排优化,在多线程环境会引发意料之外的问题,毕竟指令重排只会保证串行语义的执行一致性,并不关心多线程间的语义一致性。因此,当T1
线程执行完instance = new DCLSingleton()
代码后就会释放锁,而这时这行代码对应的指令,已经被重排成了1-3-2
顺序,就有可能出现“instance
已经指向某个内存地址,但对应内存处还未初始化对象”,接着T1
就释放了锁。
于是T2
被唤醒后,拿到锁发现instance
已经指向某个地址,这时会直接拿着instance
去进行操作,但实际上instance
未必完成了初始化,所以T2
拿着instance
去操作时就会出错,如空指针异常。
综上所述,为了解决指令重排带来的问题,我们在这里使用volatile
修饰instance
变量,从而禁止new
这行代码对应的指令与其他指令发生重排序。好了,到这里也讲明白了DCL
双重锁单例,这种单例写法在诸多框架的源码中都可以看见,例如大名鼎鼎的Spring
框架。
同时,双重锁单例也是理解并发编程、JVM路上必经的一道坎,因为它涵盖了JMM、JVM、synchronized
等多方面的知识,理解透了这个案例,能让你对并发、虚拟机的认知进一步加深。不过话说回来,虽然这种方式能提升性能,但对并发编程接触较少的小伙伴不太友好,因为很难理解为什么要这么写~
1.4、枚举式单例
除开前面通过类实现单例模式外,如果不考虑实用性,其实枚举才是最适合单例的模式,因为它足够简单,既不会有性能问题,也不用考虑线程安全、内存浪费、单例被破坏(后面细说)等问题:
public enum EnumSingleton {
/*
* 定义枚举1
* */
INSTANCE;
/*
* 获取单例对象
* */
public EnumSingleton getInstance() {
return INSTANCE;
}
}
其实枚举的底层还是一个class
类,感兴趣的小伙伴通过javap
反编译看下,不过这种单例的意义不大,毕竟实际开发场景中,做成单例模式的对象,一定具备业务或技术价值,而单纯的枚举类,只能用来表示某个常量,不具备实际的价值。因此,尽管枚举能很轻易的实现一个完美的单例对象,可它中看不中用~
1.5、容器式单例
好了,下面介绍一种另类的单例模式,也是大家日常开发接触最多的一种,即容器单例模式,是不是有点耳熟?没错,Spring
的IOC
容器,其中存放的每个Bean
默认就是单例的,而IOC
本质上就是一个大的容器,实现如下:
@Slf4j
public class ContainerSingleton {
// 单例容器
private static Map<String, Object> singletonMap = new HashMap<>();
/*
* 私有化构造函数
* */
private ContainerSingleton() {
}
/*
* 公开化的获取实例方法
* */
public static synchronized Object getInstance(String className) {
Object instance = null;
// 如果容器里没有对应的单例对象,则初始化放进去一个
if (!singletonMap.containsKey(className)) {
try {
instance = Class.forName(className).newInstance();
singletonMap.put(className, instance);
return instance;
} catch (InstantiationException | IllegalAccessException | ClassNotFoundException e) {
log.error("Load Singleton Object Failed : {}", className);
}
}
// 如果容器中有,则直接从容器中获取并返回
return singletonMap.get(className);
}
}
这其实就是一个单例对象管理容器,首先定义一个大的Map
保存所有单例对象,当线程试图获取某个单例对象时,需要传入目标对象的全限定名,而后会在容器里匹配,如果容器里没有对应的单例对象,就会通过反射机制创建一个实例放进去,接着返回创建好的单例对象。反之,如果容器中已经存在对应的单例对象,则直接返回即可。
当然,上面为了线程安全,又直接在
getInstance()
方法上加了synchronized
关键字,而这个方法还是一个static
静态方法,为此,这个锁的颗粒度比较粗,并发冲突特别高,能否优化呢?答案是当然可以,大家可以自行尝试一下,如何降低锁的颗粒度,或者通过其他手段来避免并发冲突。
1.6、静态内部类
前面的双重锁单例模式,已经竭尽所能将锁的颗粒度压缩至最小了,可是不管怎么说,终归还是用到了锁机制,而用到锁就会产生线程竞争与性能问题,那能不能不用锁呢?答案是可以,一开始的饿汉式加载,显然就没有用到锁,只不过带来了内存资源浪费罢了。
既然如此,有没有不用锁,同时不会浪费内存的方式?没错,就是鱼和熊掌都想要!答案还是有,那就是通过静态内部类的方式:
public class StaticInnerClassSingleton {
/*
* 私有化构造函数
* */
private StaticInnerClassSingleton() {
}
/*
* 公开化的获取实例方法
* */
public static StaticInnerClassSingleton getInstance() {
return LazyHandler.INSTANCE;
}
/*
* 静态内部类
* */
private static class LazyHandler {
private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
}
}
静态内部类其实利用了Java
类加载的一种特性,静态内部类在主类加载时并不会被加载,只有当调用getInstance()
方法用到内部类时,才会被加载进行初始化。
1.7、CAS自旋单例
到目前为止,前面已经叙述了六种不同的单例实现方式,可上面的方式,多少都有直接或间接的了synchronized
的保障线程安全,为什么这么说呢?比如前面的饿汉式、枚举、静态内部类,本质都是借助类加载的时候来初始化单例对象,即依赖于ClassLoader
的线程安全机制。
如果对类加载机制比较熟悉的小伙伴应该知道,ClassLoader
的线程安全机制依赖于synchronized
实现,如下:
在类加载的源码中,第一行代码就是synchronized
关键字,因此,除非被重写loadClass()
方法,否则默认在类加载过程中都会依赖synchronized
保障线程安全。那么如果不依赖于synchronized
,又该如何实现一个线程安全的单例呢?
其实在《并发编程专栏》中提到过一种保证线程安全的无锁机制,即CAS
结合自旋的乐观锁策略。
这种无锁机制,当出现多条线程同时更新同一个变量时,也能保证只会有一个线程能成功更新变量的值。至于更新失败的线程也不会被挂起,而是被告知这次更新失败,可以再次尝试更新,对应实现的单例代码如下:
public class CasSingleton {
/*
* 原子引用类型
* */
private static final AtomicReference<CasSingleton> INSTANCE =
new AtomicReference<CasSingleton>();
/*
* 私有化构造函数
* */
private CasSingleton() {
}
/*
* 公开化获取单例方法
* */
public static CasSingleton getInstance() {
// 开启自旋
for (; ;) {
// 获取原子引用中的单例对象
CasSingleton singleton = INSTANCE.get();
// 如果单例对象已经被创建,则直接返回获取到的单例对象
if (null != singleton) {
return singleton;
}
// 如果原子引用中的单例对象还未被初始化,则创建一个对象放进去
singleton = new CasSingleton();
if (INSTANCE.compareAndSet(null, singleton)) {
return singleton;
}
}
}
}
这里用到了JUC
包中提供的原子引用类,想要理解这种单例模式,最主要的是要理解getInstance()
方法开头的自旋逻辑,来看T1、T2
线程同时调用该方法的执行逻辑:
- ①
T1、T2
线程同时调用getInstance()
方法,并执行内部逻辑; - ②
T1、T2
线程同时开启死循环,都先从原子引用器中读取单例对象; - ③
T1、T2
都会发现单例对象未初始化,于是各自new
个对象并尝试CAS
更新; - ④假设
T1
的CAS
先成功,那么T1
会返回true
,然后进入if
并返回创建的单例对象; - ⑤
T2
线程CAS
失败,得到的结果为false
,本次循环结束,继续下次循环; - ⑥
T2
线程再次会回到循环的一开始,这时从原子引用器里拿到的对象不为空,则直接返回。
通过这种CAS
+自旋机制,尽管这里没有用到锁,但也依旧能够保证线程安全,所以这是一种完全不依赖于锁机制实现的单例模式,而INSTANCE.compareAndSet(null,singleton)
这一步操作是如何保证原子性的?大家对底层原理感兴趣可参考之前的《JUC-原子引用器实现原理》。
二、破坏单例模式的手段
至此,我们已经讲述了七种实现单例的方式,经过不断推敲与演进,既考虑到了内存资源,又考虑到了线程安全,还考虑到了性能问题,终于逐步找到了最完美的单例实现,可静态内部类这种方式真的完美吗?实则不然,这种方式,包括前面的多种方式,其实都存在安全隐患,如果不把安全性考虑进去,那么设计出的单例模式将有可能被破坏!
2.1、通过反射机制破坏单例
在前面给出的大多数单例写法中,都是将构造函数加上private
关键字修饰,从而避免外部直接new
的方式来创建对象,但这只能防住正规手段的创建方案,而Java
中还有一种暴力的途径,即强大的反射机制!如下:
public static void main(String[] args) {
try {
Class<?> clazz = StaticInnerClassSingleton.class;
Constructor<?> constructor = clazz.getDeclaredConstructor(null);
constructor.setAccessible(true);
Object instance1 = constructor.newInstance();
Object instance2 = constructor.newInstance();
System.out.println(instance1 == instance2);
} catch (Exception e) {
e.printStackTrace();
}
}
// 输出结果:false
这是基于之前的静态内部类的单例写法,使用反射技术暴力破除单例的例子,通过反射机制提供的setAccessible(true)
方法,可以强制越过访问修饰符的访问权限控制,尽管使用了private
修饰构造函数,反射机制也能直接将权限置为true
,从而能正常调用构造函数,并且能通过constructor.newInstance()
多次调用了构造函数创建不同的实例对象。
因为调用了两次constructor.newInstance()
构造函数,所以拿到了instance1、instance2
两个实例,从上述代码的输出结果来看,==
比较的是引用地址,false
表示这两并非同一个对象,这就从侧面印证了反射机制的确能成功破坏前面的单例写法。
那么,我们又该如何防止反射呢?很简单,在构造方法中加一个判断即可:
/*
* 私有化构造函数
* */
private StaticInnerClassSingleton() {
if (LazyHandler.INSTANCE != null) {
throw new RuntimeException("请不要使用非法手段挑战我的底线……");
}
}
因为构造函数里主动引用了LazyHandler.INSTANCE
,所以会被动触发静态内部类的加载,此时就会先初始化一个对象实例。这时,当通过反射机制调用构造函数时,就会发现单例对象已经不为空了,最终抛出异常阻止反射机制继续创建对象。
但对于懒加载式的单例写法而言,因为单例对象并不会随着类加载时初始化,构造函数里又该怎么写呢?以双重锁单例来说明:
private DCLSingleton() {
if (instance != null) {
throw new RuntimeException("请不要使用非法手段挑战我的底线……");
}
instance = this;
}
毕竟反射最终都会调用到构造函数,所以这里先判断单例对象是否为空,如果不为空,继续抛出异常阻止创建对象。如果为空,说明单例对象还未初始化,那么则将创建的当前对象this
,赋值给单例对象,这样也能保证实例的全局唯一性,只不过这里要考虑到反射与getInstance()
方法一起调用的并发冲突问题。
2.2、通过反序列化机制破坏单例
前面通过在私有化构造函数里加判断,解决了反射机制破坏单例模式,可除开反射机制外,还有另一种不走寻常路的手段,即序列化与反序列化机制。
首先我们在静态内部类单例上实现Serializable
接口,然后把这个先获取一个单例对象,接着先序列化,再将其反序列化出来,最后对比一下:
public class StaticInnerClassSingleton implements Serializable {
private static final long serialVersionUID = 1L;
/*
* 私有化构造函数
* */
private StaticInnerClassSingleton() {
if (LazyHandler.INSTANCE != null) {
throw new RuntimeException("请不要使用非法手段挑战我的底线……");
}
}
/*
* 公开化的获取实例方法
* */
public static StaticInnerClassSingleton getInstance() {
return LazyHandler.INSTANCE;
}
/*
* 静态内部类
* */
private static class LazyHandler {
private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
}
public static void main(String[] args) {
StaticInnerClassSingleton instance1 = StaticInnerClassSingleton.getInstance();
StaticInnerClassSingleton instance2 = null;
FileOutputStream fos = null;
try {
fos = new FileOutputStream("SerializeSingleton.obj");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(instance1);
oos.flush();
oos.close();
FileInputStream fis = new FileInputStream("SerializeSingleton.obj");
ObjectInputStream ois = new ObjectInputStream(fis);
instance2 = (StaticInnerClassSingleton) ois.readObject();
ois.close();
System.out.println(instance2);
System.out.println(instance1);
System.out.println(instance2 == instance1);
} catch (Exception e) {
e.printStackTrace();
}
}
}
重点看代码里的main()
方法即可,里面的逻辑就是我们说的,先对得到的instance1
单例对象进行序列化,接着对序列化后得到的文件进行反序列化,从而得到另一个实例instance2
,这时来看运行结果:
输出结果:
com.zhuzi.demo.StaticInnerClassSingleton@3f91beef
com.zhuzi.demo.StaticInnerClassSingleton@38af3868
false
结果很感人,完全两个不同的地址,并且==
对比的结果也是false
,这意味啥?意味着单例模式又被序列化机制打破了,这又该怎么整?其实在之前关于序列化的文章里提到过:Serializable序列化会打破单例模式,解决的方案也很简单,手动重写一下readResolve()
方法即可:
/*
* 手动重写readResolve()方法,防止反序列化时打破单例模式
* */
private Object readResolve() {
return LazyHandler.INSTANCE;
}
这时再次运行前面的main()
方法,就会发现反序列化出来的对象也是同一个啦:
com.zhuzi.demo.StaticInnerClassSingleton@38af3868
com.zhuzi.demo.StaticInnerClassSingleton@38af3868
true
关于具体原因,在前面给出的序列化文章的链接里面有提到,这里就不再反复说明了。
三、单例模式总结
叨叨絮絮,前面讲述了多种单例模式的实现方式,也提到了两种常见的破坏单例模式的技术,以及如何防止单例模式被打破的手段,本文的话题虽然很古老,但相信诸位认真看下来也一定会有许多收获。
想要彻底搞懂本文中提到的多种单例写法,这需要你具备扎实的基础功底,知识面覆盖了设计模式、并发编程、Java虚拟机、反射机制、网络传输等等,尽管这么多种单例写法看来有点华而不实,日常工作中也不一定会用到,但抱着学习心态去阅读,也能帮诸位将多方面的零散性知识串联起来,从而进一步巩固自己的知识体系。
最后,这种以点串线、以线连面的学习思想,各位一定也要将其掌握,很多知识从初学者角度看,或者从使用者角度去看,会发现都是零零散散分布的,可是当你真正掌握后,就能用一个点串起一条线,从而带出整个面。这样搭建出的知识体系,既不容易忘,而且有助于在面试过程中做到侃侃而谈~
所有文章已开始陆续同步至公众号:竹子爱熊猫,想在微信上便捷阅读的小伙伴可搜索关注~