创建型:单例模式
# 一、为什么要使用单例
# 1.1 单例的定义
单例设计模式(Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者叫实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。
# 1.2 单例的用处
从业务概念上,有些数据在系统中 只应该保存一份,就比较适合设计为单例类。比如,系统的配置信息类。
除此之外,我们还可以使用单例 解决资源访问冲突 的问题。
# 1.3 单例的实现
概括起来,要实现一个单例,我们需要关注的点无外乎下面几个:
- 构造函数需要是
private
访问权限的,这样才能避免外部通过new
创建实例; - 考虑对象创建时的线程安全问题;
- 考虑是否支持延迟加载;
- 考虑
getInstance()
性能是否高(是否加锁)。
具体来说,单例有下面几种经典的实现方式。
饿汉式
饿汉式的实现方式,在类加载的期间,就已经将 instance
静态实例初始化好了,所以,instance
实例的创建是线程安全的。不过,这样的实现方式不支持延迟加载实例。
package singleton
// Singleton 饿汉式单例
type Singleton struct{}
var singleton *Singleton
func init() {
singleton = &Singleton{}
}
// GetInstance 获取实例
func GetInstance() *Singleton {
return singleton
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
懒汉式(双重检测)
懒汉式相对于饿汉式的优势是支持延迟加载,同时双重检测可以保证只要 instance
被创建,之后再调用 getInstance()
函数都不会进入到加锁逻辑中。所以,这是一种既支持延迟加载、又支持高并发的单例实现方式。
package singleton
import "sync"
// LazySingleton 懒汉式单例
type LazySingleton struct{}
var (
lazySingleton *LazySingleton
once sync.Once
)
// GetLazyInstance 获取实例
func GetLazyInstance() *LazySingleton {
if lazySingleton == nil {
once.Do(func() {
lazySingleton = &LazySingleton{}
})
}
return lazySingleton
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 二、单例存在哪些问题
# 2.1 对 OOP 特性支持不友好
OOP 的四大特性是封装、抽象、继承、多态。单例的使用方式违背了基于接口而非实现的设计原则,即违背了广义上理解的 OOP 的抽象特性。
此外,单例对继承、多态特性的支持也不友好。虽然单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。所以,一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性。
# 2.2 会隐藏类之间依赖关系
由于单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以使用。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。
# 2.3 对代码的扩展性不友好
我们知道,单例类只能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。
以数据库连接池为例,在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。
但之后我们如果想要将慢 SQL 和其他 SQL 分离执行,就需要两个连接池了,而单例显然无法满足这样的需求变更。也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。
# 2.4 对代码可测试性不友好
如果单例类依赖比较重的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换。
如果单例类持有成员变量,那它实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那我们在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。
# 2.5 不支持有参的构造函数
单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。
# 三、单例模式的唯一性
单例类中对象的唯一性的作用范围是 进程唯一 的。
- 进程唯一指的是:进程内唯一,进程间不唯一;
- 线程唯一指的是:线程内唯一,线程间不唯一;
- 实际上,进程唯一就意味着线程内、线程间都唯一,这也是进程唯一和线程唯一的区别之处。
- 集群唯一指的是:进程内唯一、进程间也唯一。
# 3.1 线程唯一的单例
我们通过一个 Map 来存储对象,其中 key 是线程 ID,value 是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。
注意
Golang 中不推荐使用,Golang 主要使用协程,而协程 ID 默认并没有暴露出来。
# 3.2 集群环境下的单例
我们需要把这个单例对象 序列化并存储到外部共享存储区(比如文件)。
进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。
为了保证任何时刻在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,需要显式地将对象从内存中删除,并且释放对对象的加锁。
# 四、多例模式的实现
“单例”指的是一个类只能创建一个对象。对应地,“多例”指的就是一个类可以创建多个对象,但是个数是有限制的。
多例的实现也比较简单,通过一个 Map 来存储对象类型和对象之间的对应关系,即同一类型的只能创建一个对象,不同类型的可以创建多个对象。这种多例模式的理解方式有点类似工厂模式。它跟工厂模式的不同之处是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的是不同子类的对象。