What
一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式(Singleton Design Pattern),简称单例模式。
注意:单例类中对象的唯一性的作用范围是进程内的,在进程间是不唯一的。
为什么要使用单例?
- 处理资源访问冲突;
- 表示全局唯一类。
场景:
构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;
- 考虑对象创建时的线程安全问题;
- 考虑是否支持延迟加载;
-
饿汉式
在类加载的时候就创建对象,线程安全,但不支持延迟加载,容易产生垃圾对象,类加载时就初始化,浪费内存 。
优点: 没有加锁,执行效率会提高。
- 缺点:不支持延迟加载,浪费内存;反射可破坏。
示例代码:
public class HungryWay {
private static final HungryWay INSTANCE = new HungryWay();
/**
* 私有化构造,避免外部创建
*/
private HungryWay() {
}
/**
* 获取实例
*/
public static HungryWay getInstance() {
return HungryWay.INSTANCE;
}
}
个人观点:
有人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。不过,我个人并不认同这样的观点。
如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。
如果实例占用资源多,按照 fail-fast 的设计原则(有问题早暴露),那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(比如 Java 中的 PermGen Space OOM),我们可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性。
懒汉式
用的时候创建对象
- 优点: 第一次调用才初始化,避免内存浪费,线程安全;
缺点: 必须整体加锁(即读写都加锁)才能保证单例,会导致频繁加锁、释放锁,以及并发度低等问题;反射可破坏。
/**
* 懒汉式
*/
public class LazyWay {
public static LazyWay instance;
private LazyWay() {
}
public static synchronized LazyWay getInstance(){
if (null == instance){
instance = new LazyWay();
}
return instance;
}
}
DCL懒汉式
double-checked locking(双重锁/双重校验锁)
- 优点:具有普通懒汉式的所有优点;同时因为读取时没有加锁(读写分离思想),效率相比普通懒汉式更高。
- 缺点:反射可被破坏;
- 说明:
- volatile关键字禁止了指令重排;
- synchronized 锁实现了原子操作。
代码示例:
/**
* DCL(double-checked locking)懒汉式
* 双重校验锁
*
* ● 优点:具有普通懒汉式的所有优点;同时因为读取时没有加锁(读写分离思想),效率相比普通懒汉式更高。
* ● 缺点:反射可被破坏;
*/
public class LazyDclWay {
public static volatile LazyDclWay instance;
private LazyDclWay() {
}
public static LazyDclWay getInstance() {
//1.读写分离
if (instance == null) {
synchronized (LazyDclWay.class) {
//2. 避免多线程干扰
if (instance == null) {
instance = new LazyDclWay();
}
}
}
return instance;
}
}
- 为什么要使用volatile关键字:
singleton = new LazyDCL(); 这段代码其实是分为三步:
1.为 singleton 分配内存空间 —> 2.初始化 singleton —> 3.将 singleton 指向分配的内存地址。
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出先问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用getInstance() 后发现 singleton 不为空,因此返回 singleton,但此时singleton 还未被初始化。使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
实际上,只有很低版本的 Java 才需要在这儿加volatile来防止指令重排。我们现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)。 - 注释1:
避免整体加锁,当有对象时,不用加锁判断,直接返回对象即可,提高了执行效率,体现了读写分离思想。 - 注释2:
二次判断,避免线程T1在执行完注释1处的代码后,准备进入代码块时被挂起,然后线程T2成功获取锁进入代码块创建了对象后,T1被唤醒直接进入代码块又创建了一个对象。
个人疑问:
虽然singleton = new LazyDCL() 是非原子操作的,但是synchronized是能保证原子性的,不是应该在工作内存中将其执行完毕后再回写到主内存中吗?那么即使,singleton = new LazyDCL() 指令重排导致先赋值再初始化对象,不也应该只有当其整个操作执行完毕,回写到主内存中后,其他线程才有可能在执行注释1处的代码时才可见吗?
————————————————
尝试解答:
synchronized保证的是线程层面的原子性(即synchronized块中的语句是单线程的,且要么全部连续执行成功,要么失败),但synchronized块中的语句本身(singleton = new LazyDCL())并不是一个原子操作。
同时,有一点要十分明确,工作内存将数据回写到主内存中,是Java层面的操作,而指令重排是cpu等底层的硬件操作。
静态内部类
通过静态内部类的特性(内部类是延迟加载,且静态内部类无需外部类实例即可调用 ),来实现不加锁、线程安全、且支持延迟加载
优点: 有饿汉式的所有优点,同时做到了延迟加载。由于没锁,性能比DCL懒汉式更高。
缺点: 反射可破坏。
代码示例:
/**
* 静态内部类
*
*/
public class StaticInnerClassWay {
private StaticInnerClassWay() {
}
public static StaticInnerClassWay getInstance() {
return SingletonHolder.INSTANCE;
}
/**
* 静态内部类
*/
private static class SingletonHolder {
private static final StaticInnerClassWay INSTANCE = new StaticInnerClassWay();
}
}
只有在执行SingletonHolder.instance 时,SingletonHolder类才会加载,在该类加载的时候执行OuterClass类的实例化。
枚举
枚举本质上也是一个Class类。JVM不允许枚举被反射,也就天生不存在被反射破坏的风险。
Java规范字规定,每个枚举类型及其定义的枚举变量在JVM中都是唯一的,因此在枚举类型的序列化和反序列化上,Java做了特殊的规定。在序列化的时候Java仅仅是将枚举对象的name属性输到结果中,反序列化的时候则是通过java.lang.Enum的valueOf()方法来根据名字查找枚举对象。也就是说,序列化的时候只将DATASOURCE这个名称输出,反序列化的时候再通过这个名称,查找对应的枚举类型,因此反序列化后的实例也会和之前被序列化的对象实例相同。
优点:
它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化。- 缺点:
当想实例化一个单例类的时候,必须要记住使用相应的获取对象的方法,而不是使用new,可能会给其他开发人员造成困扰,特别是看不到源码的时候(在实际工作中,很少使用)。
Why
单例存在哪些问题?
如果要完全解决下面的5个问题,我们可能要从根上,寻找其他方式来实现全局唯一类。实际上,类对象的全局唯一性可以通过多种不同的方式来保证。我们既可以通过单例模式来强制保证,也可以通过工厂模式、IOC 容器(比如 Spring IOC 容器)来保证,还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。这就类似 Java 中内存对象的释放由 JVM 来负责,而 C++ 中由程序员自己负责,道理是一样的。
- 对OOP特性的支持不友好
OOP 的四大特性是封装、抽象、继承、多态。单例这种设计模式对于其中的抽象、继承、多态都支持得不好。为什么这么说呢?我们通过 IdGenerator 这个例子来讲解。
- 抽象
IdGenerator 的使用方式违背了基于接口而非实现的设计原则,也就违背了广义上理解的 OOP 的抽象特性。如果未来某一天,我们希望针对不同的业务采用不同的 ID 生成算法。比如,订单 ID 和用户 ID 采用不同的 ID 生成器来生成。为了应对这个需求变化,我们需要修改所有用到 IdGenerator 类的地方,这样代码的改动就会比较大。
- 继承、多态
单例对继承、多态特性的支持也不友好。这里我之所以会用“不友好”这个词,而非“完全不支持”,是因为从理论上来讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。不明白设计意图的人,看到这样的设计,会觉得莫名其妙。所以,一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性。
- 会隐藏类之间的依赖关系
我们知道,代码的可读性非常重要。在阅读代码的时候,我们希望一眼就能看出类与类之间的依赖关系,搞清楚这个类依赖了哪些外部类。
通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义(声明),就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。
- 解决方案:依赖注入
基于新的使用方式,我们将单例生成的对象,作为参数传递给函数(也可以通过构造函数传递给类的成员变量),可以解决单例隐藏类之间依赖关系的问题。
- 对代码的扩展性不友好
我们知道,单例类只能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。你可能会说,会有这样的需求吗?既然单例类大部分情况下都用来表示全局类,怎么会需要两个或者多个实例呢?
实际上,这样的需求并不少见。我们拿数据库连接池来举例解释一下。
在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。但之后我们发现,系统中有些 SQL 语句运行得非常慢。这些 SQL 语句在执行的时候,长时间占用数据库连接资源,导致其他 SQL 请求无法响应。为了解决这个问题,我们希望将慢 SQL 与其他 SQL 隔离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池,慢 SQL 独享一个数据库连接池,其他 SQL 独享另外一个数据库连接池,这样就能避免慢 SQL 影响到其他 SQL 的执行。
如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。
- 对代码的可测试性不友好
单例模式的使用会影响到代码的可测试性。如果单例类依赖比较重的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换。
除此之外,如果单例类持有成员变量(比如 IdGenerator 中的 id 成员变量),那它实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那我们在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。关于这一点,你可以回过头去看下第 29 讲中的“其他常见的 Anti-Patterns:全局变量”那部分的代码示例和讲解。
- 不支持有参数的构造函数
单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。针对这个问题,我们来看下都有哪些解决方案。
- 解决方案一: 调用 init() 函数传递参数
思路:创建完实例之后,再调用 init() 函数传递参数。需要注意的是,我们在使用这个单例类的时候,要先调用 init() 方法,然后才能调用 getInstance() 方法,否则代码会抛出异常。具体的代码实现如下所示:
- 解决方案二: 参数放到 getIntance() 方法中
思路:将参数放到 getIntance() 方法中。具体的代码实现如下所示:
不知道你有没有发现,上面的代码实现稍微有点问题。如果我们如下两次执行 getInstance() 方法,那获取到的 singleton1 和 signleton2 的 paramA 和 paramB 都是 10 和 50。也就是说,第二次的参数(20,30)没有起作用,而构建的过程也没有给与提示,这样就会误导用户。这个问题如何解决呢?
- 🌟解决方案三(推荐):参数放到另外一个全局变量中
思路:将参数放到另外一个全局变量中。具体的代码实现如下。Config 是一个存储了 paramA 和 paramB 值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。实际上,这种方式是最值得推荐的。
如何理解单例模式中的唯一性?
- 进程唯一(默认)
首先,我们重新看一下单例的定义:“一个类只允许创建唯一一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。”
定义中提到,“一个类只允许创建唯一一个对象”。那对象的唯一性的作用范围是什么呢?是指线程内只允许创建一个对象,还是指进程内只允许创建一个对象?答案是后者,也就是说,单例模式创建的对象是进程唯一的。这里有点不好理解,我来详细地解释一下。
我们编写的代码,通过编译、链接,组织在一起,就构成了一个操作系统可以执行的文件,也就是我们平时所说的“可执行文件”(比如 Windows 下的 exe 文件)。可执行文件实际上就是代码被翻译成操作系统可理解的一组指令,你完全可以简单地理解为就是代码本身。
当我们使用命令行或者双击运行这个可执行文件的时候,操作系统会启动一个进程,将这个执行文件从磁盘加载到自己的进程地址空间(可以理解操作系统为进程分配的内存存储区,用来存储代码和数据)。接着,进程就一条一条地执行可执行文件中包含的代码。比如,当进程读到代码中的 User user = new User(); 这条语句的时候,它就在自己的地址空间中创建一个 user 临时变量和一个 User 对象。
进程之间是不共享地址空间的,如果我们在一个进程中创建另外一个进程(比如,代码中有一个 fork() 语句,进程执行到这条语句的时候会创建一个新的进程),操作系统会给新进程分配新的地址空间,并且将老进程地址空间的所有内容,重新拷贝一份到新进程的地址空间中,这些内容包括代码、数据(比如 user 临时变量、User 对象)。
所以,单例类在老进程中存在且只能存在一个对象,在新进程中也会存在且只能存在一个对象。而且,这两个对象并不是同一个对象,这也就说,单例类中对象的唯一性的作用范围是进程内的,在进程间是不唯一的。
- 线程唯一
“进程唯一”指的是进程内唯一,进程间不唯一。类比一下,“线程唯一”指的是线程内唯一,线程间可以不唯一。实际上,“进程唯一”还代表了线程内、线程间都唯一,这也是“进程唯一”和“线程唯一”的区别之处。
尽管概念理解起来比较复杂,但线程唯一单例的代码实现很简单,如下所示。在代码中,我们通过一个 HashMap 来存储对象,其中 key 是线程 ID,value 是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。实际上,Java 语言本身提供了 ThreadLocal 工具类,可以更加轻松地实现线程唯一单例。不过,ThreadLocal 底层实现原理也是基于下面代码中所示的 HashMap。
- 集群唯一
“集群唯一”就相当于是进程内唯一、进程间也唯一。也就是说,不同的进程间共享同一个对象,不能创建同一个类的多个对象。
具体来说,我们需要把这个单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。
为了保证任何时刻,在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁。
伪代码示例如下:
如何实现一个多例模式?
- 类型一:以个数限制
- 类型二:以类型限制
在代码中,logger name 就是“类型”,同一个 logger name 获取到的对象实例是相同的,不同的 logger name 获取到的对象实例是不同的。