单例模式应该是众多设计模式中最容易被引用,最容易理解的了,但是也最容易出问题。今天写个帖记录一下。
单例模式(Singleton)属于对象创建型模式。在《设计模式:可复用面向对象软件的基础》中的介绍:
“保证一个类仅有一个实例,并提供一个对该实例的全局访问点。”
动机
- 在很多开发场景中,整个系统只需要拥有一个该类的全局对象:有利于协调系统整体的行为
- 让类保证没有其他实例可以被创建
适用性
- 适合在公用场合使用,如多个模块使用同一个数据源连接对象等场景
- 这类实例可以为:
- 需要频繁实例化然后销毁的对象
- 创建对象时耗时过多或耗资源过多,但又经常用到的对象
- 有状态的工具类对象
- 频繁访问数据库或文件的对象
此时:
- 类只能有一个实例,客户可从一个众所周知的访问点访问它
- 或:这个唯一实例可以通过子类化扩展,且客户应该无需代码改动就能使用一个扩展的实例
工具类的选型:使用单例模式还是静态方法
- 如果是没有配置信息的工具类:使用静态类,随处调用,不需引用;
- 如果是有配置信息的工具类:(考虑到配置信息的不同带来的对象状态的改变)单例模式优先,比如有多个数据源的情况就可以用单例模式。
结构
以上,可见单例模式就包括一个角色:
Singleton
- 定义一个 Instance 操作(类操作),允许客户访问它的唯一实例
- 可能也会负责创建它自己的唯一实例
协作
客户只能通过 Singleton 的 instance()
操作访问一个 Singleton 实例
效果
- 对唯一实例的受控访问
- Singleton 类封装它的唯一实例
- 严格控制客户怎样以及何时访问它
- 能够节省内存,加快对象的访问速度
- 缩小 namespace
- 属于对全局变量的改进
- 避免存储唯一实例的全局变量污染 namespace
- 允许对操作和表示的精细化
- Singleton 类可以有子类:使用一个扩展类的实例去配置一个应用,是很容易的
- 可以在运行时刻配置应用
- 允许可变数目的实例
- 控制应用所使用的实例数目
- 只有允许访问 Singleton 实例的操作需要改变
- 比类操作更灵活
实现
1. 保证一个唯一的实例
- 该唯一实例是类的一般实例,但应用里只有一个实例能被创建
- 创建实例的操作隐藏在类操作(静态成员函数或者类方法)后面
- 使用全局/静态对象的缺点:所有单例无论用到与否都要被创建
- 一个类能够返回对象的一个引用(永远是同一个)和一个获得该实例的方法(必须是静态方法)
- 客户仅通过
getInstance()
访问该单例 - 由此,其他代码就无法通过调用该类的构造函数来实例化该类的对象,只有通过该类提供的静态方法(
getInstance()
)来得到该类的唯一实例。
饿汉式单例
1 | public class Singleton { |
懒汉式(Lazy)单例
1 | private ClazzName clazz; |
虽然简单,但是缺点是:并发高的情况下会阻塞;仅能实现静态变量的延迟初始化;不能传递参数
双重检查锁(Double-checked Locking)
常见于多线程环境下。
1 | public class Singleton { |
解析:
1. 使用 volatile
限制指令重排序:
背景:执行程序时为了提高性能,处理器和编译器常会对指令重排序
重排序的准则:
- 单线程环境下不能改变程序运行的结果(不影响数据的结果)
- 存在数据依赖关系的指令集不允许重排序
如双重检查锁中的 uniqueSingleton:1
uniqueSingleton = new Singleton();
包含以下三步:1
2
3memory = allocate(); // 1. 分配内存空间
ctorInstance(memory); // 2. 初始化对象
uniqueSingleton = memory; // 3. 将对象指向刚分配的内存空间
有些编译器为了性能原因,可能会将第二步和第三步重排序,结果变成了:1
2
3memory = allocate(); // 1. 分配内存空间
uniqueSingleton = memory; // 3. 将对象指向刚分配的内存空间(对象还没被初始化)
ctorInstance(memory); // 2. 初始化对象
如果不禁止重排序的话,考虑两个线程访问单例,在时间片中如下:
时间片 Time | 线程 A | 线程 B |
---|---|---|
T1 | 检查到 uniqueSingleton 为空 | |
T2 | 获取锁 | |
T3 | 再次检查到 uniqueSingleton 为空 | |
T4 | 为 uniqueSingleton 分配内存空间 | |
T5 | 将 uniqueSingleton 指向内存空间 | |
T6 | 检查到 uniqueSingleton 不为空 | |
T7 | 访问 uniqueSingleton(此时该对象还未完成初始化) | |
T8 | 初始化 uniqueSingleton |
由上,可以发现 A 线程从时间片 T4 开始的步骤是经过重排序的,结果会导致 B 线程访问到的是一个还没初始化的对象。
因此使用 volatile 禁止重排序,所有对变量的读操作(将对象指向刚分配的内存空间)必须发生在写操作(分配内存空间,及初始化对象)之后。
2. 双重检查 null
情景:两个线程同时在初始化 context 中调用 getInstance()
此时 singleton == null,两个线程均可通过第一层空值判断。
- 随后,两个线程中的一个获取锁。如果没有第二层空值判断的话,先获得锁的线程创建完单例,释放锁之后,另一个线程随即获得锁,仍然会去创建单例,停不下来了。
- 此时单例变多例,违反了设计模式。
因此加上第二层空值判断,这样的话前面的线程获得锁,创建完单例后释放锁,随后的线程经过空值判断之后,就不会重新创建单例了。
如果没有第一层空值判断:看似是可以实现了单例模式,但是每一个进程访问都需要进入一次锁,非常消耗性能。
在 Java 6 及之前会带来很大的性能消耗,Java 7 开始引入了锁的升级机制,大大优化性能。
2. 创建 Singleton 的子类
- 在 Singleton 的 getInstance() 操作中决定想使用哪一个单例
- 或:将 getInstance() 的实现放入子类中
- 直接链接:难以在运行时选择单例
- 条件语句:硬性规定了可能的单例组合
- 使用注册表
1 | Singleton* Singleton::Instance() { |
相关模式
很多的设计模式都可用 Singleton 实现,如 Abstract Factory,Builder,Prototype 等
Spring Framework 中单例的应用
多个客户端请求同一个服务时:容器给每一个请求分配一个线程
此时:多个线程会并发执行该请求所对应的业务逻辑(对应成员方法)
如该业务逻辑有对单例状态的修改(改变单例的成员属性):必须考虑线程同步问题。
- 有状态单例(Stateful Bean):有数据存储功能
- 有实例变量的对象,可以保存数据,非线程安全
- 在不同方法调用间不保留任何状态
- 无状态单例(Stateless Bean):一次性操作,不存储数据,是不变类
- 没有实例变量的对象,不能保存数据,线程安全
Bean 的单例实现
1 | public abstract class AbstractBeanFactory implements ConfigurableBeanFactory { |
Controller 默认是单例的
因此不要在 Controller 中使用非静态的成员变量,否则会发生数据逻辑混乱。
1 |
|
单例对象的生命周期
单例对象与容器共存亡:
- 出生:容器创建其时,对象出生。(立即创建或者延迟创建)
- 活着:只要容器在,对象就一直活着。
- 死亡:容器被销毁之后,对象消亡。
多例对象的生命周期
- 出生:当我们使用对象时,Spring 框架为我们创建对象。(延迟创建)
- 活着:对象只要在使用过程中就一直活着。
- 死亡:当对象长时间不用且没有别的对象引用时,由 Java 垃圾回收器回收。