单例模式一文通
想在程序中表示某个东西只会存在一个,比如表示程序所运行于那台计算机的类、表示软件系统相关设置的类、表示视窗系统(Window system)的类时,我们可以使用单例模式。
classDiagram
class Singleton {
-$singleton
-Singleton()
+$getInstance()
}
这种使用单例模式的类通常会占用较多的内存,或者示例的初始化过程比较冗长,如果随意创建这些类的示例会影响系统性能。我们使用单例模式的目的:
- 想确保任何情况下都绝对只有 1 个实例,在程序上表现出「只存在一个实例」
- 处理资源访问冲突
单例模式的实现方法
设计单例时需要考虑的要点:
- 构造方法为私有
- 注意线程安全问题
- 考虑是否延时加载
- 考虑获取单例
getInstance()
的性能是否够高
实现方法 | 基本饿汉式 | 线程不安全的懒汉式 | 线程安全但锁粒度大的懒汉式 | DCL | IoDH | 枚举 |
---|---|---|---|---|---|---|
饿汉/懒汉 | 饿汉 | 懒汉 | 饿汉 | |||
JDK 版本 | JDK1.5 起 | JDK1.5 起 | ||||
延迟加载 | 🟥 | 🟩 | 🟩 | 🟩 | 🟥 | |
线程安全 | 🟩 | 🟥 | 🟩 | |||
实现难度 | 🟩 | 🟥 | ||||
应用场景 | 一般情况下使用 | 不建议 | 存在特殊需求时 | 在要明确实现 lazy loading 效果时 | 涉及到反序列化创建对象时 |
基本饿汉模式
基于 classloader 机制避免了多线程的同步问题。
1 | public class Singleton { |
instance
前的关键字加上了 final
关键字的原因:防止子类不恰当的方法覆盖单例。
构造方法为私有 private
的原因:禁止从 Singleton
类外部调用构造函数。只有自己才能 new
自己。外部只能通过类的静态方法使用。但是不能防止反射创建新的实例。
如果 Singleton
实现了序列化接口,我们还需要增加以下代码防止反序列化破坏单例:
1 | public class Singleton implements Serializable { |
这样的初始化是可以保证单例对象创建时的线程安全。因为静态成员变量的初始化在类加载阶段完成,类加载阶段由 JVM 保证线程安全。
如果饿汉模式下的单例初始化时间长,占用资源多是否意味着提前初始化是浪费资源的行为?答案是否定的,我们最好不要等用到的时候再初始化这个单例,这反而会降低系统性能。饿汉模式下将耗时的初始化操作提前到程序启动时完成,可以避免程序运行时初始化导致的性能问题。
相关参考:
- 类的生命周期中解释
instance
实例化的时机。 - 这种饿汉模式的单例模式实现方法,是静态工厂模式的一种应用。
基本懒汉模式
懒汉模式:没有一上来就创建实例,而是需要用的时候才创建。
代码 1:线程不安全的写法
严格意义上,这不是单例模式。
1 | public class Singleton { |
这个实现适合于单线程程序。然而,当引入多线程时,就必须通过同步来保护 getInstance()
方法。如果不保护 getInstance()
方法,则可能返回 Singleton
对象的两个不同的实例。
代码 2:线程安全的写法(锁粒度较大)
Balking 模式的一种体现。
改造 getInstance
方法:
1 | // 代码1容易有竞争问题,如果两个线程 getInstance,可能都误以为都是NULL。所以添加synchronized |
然而,当分析这段代码时,我们发现只有在第一次调用方法时才需要同步。由于只有第一次调用执行了 //2
处的代码,而只有此行代码需要同步,因此就无需对后续调用使用同步。
synchronized
是一个重量级的锁操作,它将全部代码块锁住,会导致较大的性能开销。否则,后续的每一次对 getInstance
的调用都要付出同步的代价。
代码 3:双重检查锁 DCL
双重检查锁 DCL(double checked locking)
为了避免对除第一次调用外的所有调用都实行同步的昂贵代价。我们将代码 2 中的 //2
包到一个同步块中。
1 | public static Singleton getInstance() { |
但是还是出现了和代码 1 一样的问题,可能会出现多个线程进入 if
内部。
为处理上面代码中的问题,我们需要对 instance
进行第二次检查。这就是“双重检查锁定”名称的由来。
1 | // DCL 双重检查锁 |
这个过程让我想起了动物界的精卵结合的过程…第一层 null 判断可快速返回单例,第二层 null 判断可防止重写。
双重检查锁定背后的理论是完美的,但还是会存在问题。双重检查锁定失败的问题并不归咎于 JVM 中的实现 bug,而是归咎于 Java 平台内存模型,这个内存模型允许所谓的“指令重排序”。
指令重排序是指编译器或处理器为了优化性能而采取的一种手段,在不存在数据依赖性情况下(如写后读,读后写,写后写),调整代码执行顺序。
代码行 //4
中,在 java 中创建一个对象并非是一个原子操作,可以被分解成三行伪代码:
1 | //1:分配对象的内存空间 |
而在一些 JIT 编译器中,为提高性能,伪代码的第二步和第三步顺序会交换(变为先赋值,再调用构造函数)。在单线程程序下,重排序不会对最终结果产生影响,但是并发的情况下,可能会导致某些线程访问到未初始化的变量。模拟一个 2 个线程创建单例的场景,如下表:
时间 | 线程 A | 线程 B |
---|---|---|
t1 | A1: 分配对象内存空间 | |
t2 | A3:设置 instance 指向内存空间(先赋值) |
|
t3 | B1:判断 instance 是否为空 |
|
t4 | B2:由于 instance 不为 null,线程 B 将访问 instance 引用的对象 |
|
t5 | A2:初始化对象(再调用构造函数) | |
t6 | A4:访问 instance 引用的对象 |
按照这样的顺序执行,线程 B 将会获得一个未初始化的对象,并且自始至终,线程 B 无需获取锁!
解决「指令重排序」的方法:
- (在 JDK1.5 之后)使用
volatile
关键字。private static volatile Singleton instance;
。 - 不用双重检查锁,而是使用代码 2 中的方法
- 改用饿汉模式
volatile
关键字volatile
保证了”可见性“,它的”可见性“是通过它的内存语义实现的:
- 写
volatile
修饰的变量时,JMM 会把本地内存中值刷新到主内存 - 读
volatile
修饰的变量时,JMM 会设置本地内存无效
为了实现可见性内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来防止重排序。
登记式/静态内部类 IoDH
Initialization Demand Holder。
饿汉式单例类不能实现延迟加载,不管将来用不用始终占据内存;懒汉式单例类线程安全控制烦琐,而且性能受影响。
在 IoDH 中,我们在单例类中增加一个静态(static
)内部类,在该内部类中创建单例对象,再将该单例对象通过 getInstance()
方法返回给外部使用。
1 | public class SingletonIoDH { |
和饿汉式方法一样,它同样利用了 classloader 机制来保证初始化 instance
时只有一个线程。不一样的是,饿汉式中,只要 Singleton
类被装载了,那么 instance
就会被实例化,没有达到延迟加载(lazy loading)的效果。IoDH 是 Singleton
类被装载了,instance
不一定被初始化。因为 SingletonHolder
类没有被主动使用,只有通过显式调用 getInstance
方法时,才会显式装载 SingletonHolder
类,从而实例化 instance
。
这种方式能达到双检锁方式一样的功效,但实现更简单。对静态域使用延迟初始化,应使用这种方式而不是双检锁方式。这种方式只适用于静态域的情况,双检锁方式可在实例域需要延迟初始化时使用。
通过使用 IoDH,我们既可以实现延迟加载,又可以保证线程安全,不影响系统性能,不失为一种最好的 Java 语言单例模式实现方式(其缺点是与编程语言本身的特性相关,很多面向对象语言不支持 IoDH)。
枚举
这种实现方式还没有被广泛采用,但这是实现单例模式的最佳方法。它更简洁,自动支持序列化机制,绝对防止多次实例化。
这种方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化。
枚举单例不能通过反射破坏单例。枚举属于饿汉式单例模式。
1 | public enum Singleton { |
单例模式存在的问题
单例模式对面向对象特性支持不友好
单例模式对面向对象特性中的继承、抽象、多态的支持不友好(注意用词,不是「完全不支持」)。假设现有一单例类 ID 生成器 IdGenerator
,调用其 getId()
方法可以生成一个 id 编号。现有如下情景:
1 | public class Order { |
这种场景下,单例类 IdGenerator
违背了基于接口而非实现的设计原则,即违背 OOP 中的抽象特性。如果我们需要替换部分单例类 ID 生成器的实现,我们需要修改所有用到 ID 生成器的代码。
理论上讲,单例类可以被继承或实现多态,但实现方式很奇怪,减少了代码的可读性。所以,一旦选择将某个类设计为单例类,就意味着放弃了继承和多态特性,损失了可以应对未来需求变化的拓展性。
单例会隐藏类之间的依赖关系
单例类我们直接上手用就行了,不会看到平常我们使用类时的创建和传递依赖参数的过程。当代码比较复杂时,单例类的调用关系就会非常隐蔽。在阅读某一个类的代码时,我们需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。
解决方式为,可以使用依赖注入方式使用单例:
1 | // 以前调用单例的方式 |
在新的方式中,我们可以将单例生成的对象当作参数传递给函数,或者某个类的成员变量。这样可以解决单例隐藏类之间的依赖关系。
单例对代码的扩展性和可测试性并不友好
单例类只能有一个实例,如果有一天想改成多个实例,就会对代码产生大的改动。一般数据库连接池、线程池最好不要设计成单例类。
如果单例类依赖比较重的外部资源,比如 DB,当我们在写单元测试的时候希望通过 mock 的方式将它替换掉时,单例的硬编码会导致我们无法实现 mock 替换。
此外,单例类持有的成员变量实际上相当于全局变量,被所有的代码共享。如果全局变量为可被修改的,在测试时还需关注不同测试用例之间的相互影响问题。
单例不支持有参数的构造函数
假设我们要创建一个连接池单例对象,我们无法通过参数指定连接池的大小。
下面介绍两种解决方案。
方法一:增加 init()
方法。
1 | public class Singleton { |
方法二:将参数放到 getInstance()
方法中,但会有些问题。两次执行 getInstance()
方法时,即使前后传递的参数不一致,得到的单例中的参数总是第一次调用时传递的,这会对调用者产生迷惑。
1 | public class Singleton { |
方式三:将参数放置在另一个全局变量 Config 类中。Config 存储的变量可以是静态定义,也可以从配置文件中加载。这种方式是推荐的方式 。
1 | public class Config{ |
在 站内文章单例模式的应用——为 Java 程序应用全局配置 文章中,展示了单例模式实现方式一、方式三的初始化方式。
替代单例模式的解决方案
上一节提到了单例模式中的很多问题,但是如果不用单例模式,有什么其他的替代方案保证类的实例全局唯一?
我们可以使用静态方法实现。但是这种方式比单例更不灵活,他无法支持延迟加载。
1 | // 静态方法实现方式 |
也可以通过 站内文章工厂模式、IOC 容器保证类的全局唯一性,也可以通过程序员自己来保证不创建两个对象的实例(让程序员为代码做承诺不稀奇,毕竟在 C++ 中,程序员也是要保证内存对象的正确释放的)。
单例模式的唯一性
单例模式是指,一个类只允许创建唯一一个对象。这个唯一指的是进程唯一的。新老进程中的单例对象并不是同一个对象。
单例是进程唯一的,该进程下的线程间也都使用同一个单例。线程唯一指的是在同一个进程上,不同线程使用各自不同的单例。实现线程唯一的例子:
1 | public class IdGenerator { |
Java 本身提供了 ThreadLocal
工具类,可以更加轻松地实现线程唯一的单例。不过,ThreadLocal
的底层实现原理也是基于上面的 HashMap
。
集群可以理解为多个进程构成的集合。集群唯一的单例是指,在同一个集群内的进程都共享同一个单例。实现的具体方式为,把这个单例对象序列化并存储到外部共享存储区(如文件),进程间在使用这个单例对象的时候,先从外部共享存储区中将它读到内存,并反序列化为对象然后再使用,使用完成后需要再存储回外部共享存储区。这个过程中,我们需要对对象进行加锁/解锁操作,以及显示从内存中删除已经使用过的单例的操作。
1 | public class IdGenerator { |
实现一个多例模式
这里的多例模式是指一个类可以创建有数量限制的多个对象。
简易实现如下:
1 | public class BackendServer { |
对于多例模式的另一种理解为:同一个类型的只能创建一个对象,不同类型的可以创建多个对象。代码如下:
1 | public class Logger { |
这种多例模式的理解方式有点类似以下两个设计模式:
- 工厂模式。与之不同的是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的是不同子类的对象
- 享元模式。
枚举类型其实也相当于多例模式:一个类型只能对应一个对象,一个类可以创建多个对象。
与单例模式相关的设计模式
在多数情况下,站内文章抽象工厂 Abstract Factory、Builder 模式、门面模式 Facade、Prototype 模式只会生成一个单例。
本文 PlantUML 代码存档
1 | class Singleton{ |
本文参考
- 《图解设计模式》第 5 章单例模式
- 软件设计模式与体系结构–单例模式_软件设计模式与体系结构电子版-CSDN博客
- 单例模式之双重检测锁 - ring977 - 博客园 (cnblogs.com)
- IBM 公司高级软件工程师 Peter Haggar 2004 年在 IBM developerWorks 上发表了一篇名为《双重检查锁定及单例模式——全面理解这一失效的编程习语》的文章,对 JDK 1.5 之前的双重检查锁定及单例模式进行了全面分析和阐述。JDK 1.5 版本之前加入的 volatile 关键字不生效。中文翻译(我猜的):Java单例模式中双重检查锁的问题_java单例模式双重锁-CSDN博客
- Updates (oswego.edu) 说明了 JDK 1.5 版本之后 volatile 关键字生效。
- 63-单例模式的双重检查锁模式为什么必须加 volatile?_double-checked locking 为什么要加volatile-CSDN博客
- 单例陷阱——双重检查锁中的指令重排问题 - Nauyus - 博客园 (cnblogs.com)
- 单例模式 | 菜鸟教程 (runoob.com)
- IoDH实现的单例模式-CSDN博客
- 黑马程序员深入学习Java并发编程,JUC并发编程全套教程_哔哩哔哩_bilibili
- 极客时间专栏 - 设计模式之美 - 王争(开源文档地址)