单例模式的实现
单例模式确保一个类只有一个实例,并且为该实例提供全局的访问方式。其实现方式有多种,每种实现方式均有自己的特点。
一个简单的实现
根据单例模式的定义,可以很容易写出如下代码
1 | public class Singleton { |
思路
这段代码比较简单,其思路是使用类的私有静态属性保存类的唯一实例,通过一个静态的方法对 getInstance() 对外暴露该实例。
程序第一次调用 getInstance() 时 instance 为null,此时需要 new 一个实例对象出来,后续都直接返回已有实例,目的是使得每次都获取到同一个实例。
注意代码中将构造函数设置为 private,其目的是使得 Singleton 类只能在类内部初始化,防止创建新的实例。
测试
来段代码测试下看看
1 | public class TestSingleton { |
输出结果:
1 | true |
看起来运行的很好。然而这只是单线程的情况,在多线程环境下情况就不那么乐观了。
看下一个并发场景:
假如说第一个线程 1 执行完第 12 行
if (instance == null)
后让出了cpu,那么它恢复后会执行第 13 行代码此时另外一个线程 2 执行到第 12 行,判断结果为 true 后执行了第 13 行创建了一个 Singleton 实例
线程 1 恢复执行后仍会执行第 13 行去创建一个新的 Singleton 实例
以上的结果会导致创建不止一个 Singleton 实例,这与我们的目标不一致。针对这种情况,可以考虑在存在并发问题的代码处加锁。
并发问题修复
使用 synchronized 锁保证并发的安全性,修改后的代码如下
1 | public class Singleton { |
注意第 10 行加上了 synchronized
修饰符,这相当于使 getInstance() 内的代码变成了临界代码,在并发环境下,多个线程按顺序进入该方法,也就不存在创建多个 Singleton 实例的情况了。
并发的问题解决了,然而又引起了性能的问题。
按照单例模式的定义,单例类仅有一个实例,该类最多初始化一次,也就是说第 13 行只会执行一次,其余情况下都是直接返回 instance。如上加了 synchronized
锁后,所有的请求都必须先获取到锁,然后才能拿到 instance 实例,这势必影响系统性能。
分析导致创建多个实例的原因,关键在于 instance = new Singleton()
这句代码执行了多次,因此可以仅对这个关键位置加锁。
性能问题修复
将 synchronized
移到 getInstance() 方法内,在 instance = new Singleton()
前加锁。
考虑一种情况:一个线程判断 instance 为 null,并且获取到 synchronized
锁,此时就可以创建 Singleton 实例吗?
答案是不一定。
为什么呢?假如说在该线程获取到 synchronized
锁之前,另外一个线程已经获取过锁并且执行过 instance = new Singleton()
了,此时 instance 已经不为 null。因此在 instance = new Singleton()
前需要再次判断下 instance 是否为 null。
这就是著名的双重校验锁(DCL),代码如下:
1 | public class Singleton { |
问题似乎解决了,一起看起来很美好!
然而现实是残酷的。为了提高执行性能,JVM和处理器层面均支持指令的重排序,在这里会引起另外一个问题:获取到未初始化的实例。
为了分析问题,先反编译下字节码文件
1 | javap -v Singleton.class |
如下是节选的部分输出内容:
1 | public static Singleton getInstance(); |
重点关注下如下几行内容,这正是 instance = new Singleton()
对应的JVM指令
1 | 17: new #3 // class Singleton |
这 3 行的含义为
- 创建类的实例(未初始化)
- 执行实例的初始化方法
<init>
- 赋值给静态变量 instance
JVM 重排序后执行顺序可能变为 1 > 3 > 2,这在多线程环境下可能会导致一个线程获取到未初始化的实例。
为了解决这个问题,可以使用 volatile 修饰 instance 以禁止该行代码重排序。
JDK5 and later extends the semantics for volatile so that the system will not allow a write of a volatile to be reordered with respect to any previous read or write, and a read of a volatile cannot be reordered with respect to any following read or write.
JDK5 以后增强了 volatile 的语义:禁止 volatile 写和它之前的任何读/写重排序,禁止 volatile 读和它之后的任何读/写重排序。
未初始化问题修复
修复后的 DCL 代码如下
1 | public class Singleton { |
这个就是比较常见的 DCL 实现,解决了前面提到的几个问题
另外一种思路
既然初始化实例这里这么麻烦,干脆在加载类的时候直接初始化,利用虚拟机的初始化机制解决并发的问题
1 | public class Singleton { |
这种方案是实现简单,不存在并发的问题,缺点可能会导致资源利用率不高的问题:比如说系统未使用到该单例,但是由于某种原因加载了 Singleton 类,那么会导致实例初始化,占用系统资源。
这个就是传说中的饿汉式单例,提前建立单例对象。
静态内部类实现
针对饿汉式的缺点,可以使用内部类来持有实例,在第一次获取实例时加载内部类并且实例化单例对象,以达到延迟初始化的目的,具体代码如下:
1 | public class Singleton { |
枚举实现
前面讲到 DCL (也就是懒汉式)、饿汉式、静态内部类共 3 种线程安全的单例实现方式,在实际开发中也比较常见,但这并不代表它们是完美的。
我们知道通过反射可以修改 private 属性值,同样也可以达到访问 private 方法的目的。按照这个思路可以通过反射调用 Singleton 的构造函数创建新的实例,代码如下:
1 | public class TestSingleton { |
输出结果:
1 | true |
可见两个对象均是 Singleton 类型,但是却不相等,是独立的两个对象。
使用枚举实现单例,JVM 可以保证不会被反射创建新的实例,并且提供了安全的序列化机制(支持序列化和反序列化,并且反序列化不会导致创建多个实例)。
枚举实现单例的代码如下:
1 | public enum Singleton { |
验证下反序列化是否会存在问题
1 | public class TestSingleton { |
输出结果为:
1 | true |
这说明反序列化后得到的还是同一个实例
总结
有多种方式可以实现单例模式,常见的有:双重校验锁(DCL)、饿汉式、静态内部类、枚举式等,其中枚举式实现简单、多线程安全、可防止序列化或者反射攻击,是值得推荐的单例实现方法。
参考
秦小波. 设计模式之禅
Joshua Bloch. Effective Java
- 2019-03-13
并发编程中经常会使用到 volatile 关键字,使用该关键字修饰的共享变量可以保证多线程之间的可见性,也就是说当一个线程修改了变量的值,另一个线程能够读到修改后的值。
- 2020-01-22
XmlBeanFactory 可以说是 Spring 中一个最简单的 BeanFactory,通过它可以了解 Spring 的设计思路。
- 2019-03-31
JSR-133 定义了新的 JMM 的规范,增强了 volatile 语义和 final 语义等。The JSR-133 Cookbook for Compiler Writers 是一份非正式的指南,本文对其进行了翻译,以方便查阅。可能存在翻译不准确甚至错误的情况,仅供参考,请对比原文查看。