自己在平时的业务开发中单例模式运用较多,感觉没有啥技术含量,直到今天和一个同事聊起不同单例模式的底层实现原理时,才发现自己愚昧,里面蕴藏知识点还真有点多,于是就做了这个笔记。
单例设计模式大的方面可以分为两种:对象不延迟加载、对象延迟加载设计。在对象延迟加载设计中又有懒汉模式、双重检查锁(Double-Checked Locking)、IoDH 。接下来我们就一步一步分析每种设计方案的优缺点对比。
1.俄汉模式
单例模式中俄汉模式是最暴力的,也是实现起来最简单的。代码实现如下:
class EagerSingleton {
private static EagerSingleton instance = new EagerSingleton();
private EagerSingleton() { }
public static EagerSingleton getInstance() {
return instance;
}
}
这个模式最大的bug就是不能实现对象延迟加载,不管对象以后用不用得上都会加载到内存里占据内存空间。所有在设计的时候就会想到:能不能在用的时候才初始对象呢?相应的懒汉设计模式就出来了。
2.懒汉模式
懒汉模式能解决饿汉模式的延迟加载问题,但在写代码的时候需要注意多线程安全问题。先来看一个有问题的设计,代码实现如下:
//非线程安全实现对象延迟初始化
public class InstanceFactory {
private InstanceFactory(){}
private static Instance instance; //1
public static Instance getInstance(){ //2
if(instance == null){ //3
instance = new Instance(); //4: A线程、B线程
}
return instance;
}
}
存在问题:代码4处会出现线程A和线程B同时执行的情况,不能保证instance线程安全的延迟初始化,可能会生成多个实例对象。线程不安全,怎么解决?那么加锁的思想立马涌现出来。
//线程安全实现对象延迟初始化:加锁
public class InstanceFactory {
private static Instance instance;
private InstanceFactory(){}
public synchronized static Instance getInstance(){
if(instance == null){
instance = new Instance();
}
return instance;
}
}
存在问题:synchronized将导致锁的获取和释放性能开销,如果getInstance()方法被多个线程频繁的调用,将会导致程序执行性能的下降。存在一个最大的问题就是:不管instance是否初始化,都会进行获取锁和释放锁操作。但实际上在我们第一次初始化对象后,是不需要在获取锁和释放锁操作的;于是双重检查锁定模式就诞生了。
3.双重检查锁定(Double-Check-Locking)
double-check能避免第一次初始化对象后,后面的线程对锁的获取和释放操作,可以大幅降低synchronized带来的性能开销,大大的提高了程序执行的性能。我们先来看看一段实现的代码
/*
*如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。
*/
public class DoubleCheckedLocking {
private DoubleCheckedLocking (){}// 1
private static Instance instance; // 2
public static Instance getInstance() { // 3
if (instance == null) { // 4:第一次检查
synchronized (DoubleCheckedLocking.class) { // 5:加锁
if (instance == null) // 6:第二次检查
instance = new Instance(); // 7:实例化对象(这儿会出现问题,想一下为什么?)
}
}
return instance;
}
}
乍一看上面这种Double-Checked很完美没有什么问题,但这是一个错误的优化!在线程执行到4处时,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。问题的根源是7处的代码创建对象时,JVM内部是分三步实现的。这一行创建对象的代码在JVM中可以分解为如下的3行伪代码。
//jvm初始化对象三步实现
memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址
在分析创建对象这行代码指令重排序时,需要明白一个概念:临界区。在synchronized同步的代码里,为什么编译器或处理器能进行重排序优化,都与这个临界区有关。先来看看概念
临界区:指一个用以访问共享资源的代码块,这个代码块在同一时间内只能允许一个线程访问。
在synchronized的同步代码中JVM会在 MonitorEnter 对应的机器码指令之后临界区开始之前的地方插入一个获取屏障,并在临界区结束之后 MonitorExit 对应的机器码指令之前的地方插入一个释放屏障。这样临界区中的代码(指令序列)就成了三层夹心饼,如图所示:
synchronized代码块内,临界区里指令是允许编译器或处理器优化的。所有当JVM编译器优化指令时,出现2和3之间重排序之后的处理器命令执行时序如下。
memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置instance指向刚分配的内存地址
// 注意,此时对象还没有被初始化,只有执行了下一条命令才会初始化
ctorInstance(memory); // 2:初始化对象
当JVM指令被编译器或处理器重排序后,在多线程并发执行上面的Double-Checked代码就会出问题,如下图:
当线程A执行完内存地址分配后,还没初始化instance对象,此时B线程就读取instance对象,读取到的对象有地址存在但没初始化。所以为了解决上面的指令重排序问题volatile就登场了,禁止指令重排序。 正确的Double-Checked是volatile和synchronized 一起配合使用。
public class SafeDoubleCheckedLocking {
private SafeDoubleCheckedLocking (){}
//将instance加上volatile修饰
private volatile static Instance instance;
public static Instance getInstance() {
if (instance == null) {
synchronized (SafeDoubleCheckedLocking.class) {
if (instance == null)
instance = new Instance(); // instance为volatile完美解决指令重排序问题
}
}
return instance;
}
}
虽然Double-Checked Locking(volatile和synchronized配合使用)解决延迟加载 和 多线程问题,但是屏蔽掉了编译器和处理器的指令优化重排序,性能有所影响,为了解决这个问题IoDH方案就登场了。
4.IoDH策略
IoDH单例优化方案,既能保证线程安全,又能实现对象延迟初始化,还能放开处理器和编译器的命令优化堪称完美。代码实现如下
public class InstanceFactory {
private InstanceFactory (){}
private static class InstanceHolder {
public static Instance instance = new Instance();
}
public static Instance getInstance() {
return InstanceHolder.instance ; // 这里将导致InstanceHolder类被初始化
}
}
这种方式为什么是线程安全的呢?这涉及到一个知识点:类初始化。JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
根据Java语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化。
//接口或类T发生初始化的条件
(1)T是一个类,而且一个T类型的实例被创建。
(2)T是一个类,且T中声明的一个静态方法被调用。
(3)T中声明的一个静态字段被赋值。
(4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
(5)T是一个顶级类(Top Level Class,见Java语言规范的§7.6),而且一个断言语句嵌套在T内部被执行。
//IoDH单例模式中就是满足了(1)、(3)两个条件导致初始化。
在Java规范里,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在初始化InstanceHolder类期间会获取这个初始化锁,同一时间只能一个线程持有。IoDH单例模式中InstanceHolder类初始化过程如下图:
所以IoDH单例模式靠类初始化时,用class对象的初始化锁来保证多线程安全;new instance()虽然发生了指令重排序,但不存在多线程问题。
到此单例模式的四种实现优缺点就一一分析完,涉及到的知识点还是挺多的,包含了synchronized、volatile、临界区、对象初始化、对象初始化锁这些底层的原理。有时候业务代码写多了就会停止思考底层原理、为什么会这样做,这也是人的惰性所在。单一的动作重复习惯了,就会让人停止思考,这是非常可怕的。所以do more thinking,业务代码一样有技术含量。
2020年5月17日 于北京记