【设计模式】单例模式
1. 定义及特点
单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供了一个全局访问点来访问该实例。
意图:确保一个类只有一个实例,并提供一个全局访问点来访问该实例。
主要解决:频繁创建和销毁全局使用的类实例的问题。
何时使用:当需要控制实例数目,节省系统资源时。
如何解决:检查系统是否已经存在该单例,如果存在则返回该实例;如果不存在则创建一个新实例。
关键代码:构造函数是私有的。
优点
- 内存中只有一个实例,减少内存开销,尤其是频繁创建和销毁实例时(如管理学院首页页面缓存)。
- 避免资源的多重占用(如写文件操作)。
缺点
- 没有接口,不能继承。
- 与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心实例化方式。
2. 适用场景
- 生成唯一序列号。
- WEB 中的计数器,避免每次刷新都在数据库中增加计数,先缓存起来。
- 创建消耗资源过多的对象,如 I/O 与数据库连接等。
3. 饿汉式与懒汉式
- 饿汉式:类加载就会导致该单实例对象被创建。
- 懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时被创建。
3.1 饿汉式
饿汉式简单实现代码如下:
1 | /** |
这种写法比较简单,就是在类装载的时候就完成实例化。但是也有一个显然的缺点:类加载时就初始化,如果没有使用到这个类,就会浪费内存。
那有没有一种方法让类加载时不进行实例化,只有用的时候才实例化呢?当然有,这时候就需要使用懒汉式了。
3.2 懒汉式
懒汉式简单实现代码如下:
1 | /** |
这种方式是最基本的实现方式,这种实现最大的问题就是不支持多线程,在多线程环境下:
1 | /** |
运行上述代码后,控制台可能会打印出:
1 | Thread-0 |
可以看到出现了多个实例,这显然就不是单例的了。
为了避免这种情况,我们可以对代码加 synchronized
锁,修改 getInstance()
方法:
1 | public static synchronized Singleton getInstance() { |
这样就能保证是单例了,这种方式具备很好的 lazy loading,能够在多线程中很好的工作,但是,效率很低。
那有没有一种方式在多线程情况下既能保持是单例,又能保证高性能呢?
我们只需要修改 getInstance()
方法为如下这样即可实现:
1 | // 双重检查锁定 |
这就是双重检查锁定(Double-Checked Locking)模式 ,上面的代码已经完美地解决了 **并发安全 **+ 性能低效 问题。
两次判断
null
的作用
- 第一次:性能优化,避免不必要的同步操作。如果
instance
已经被创建(即instance
不为null
),那么就没有必要再次进行同步操作来访问它。 - 第二次:确保线程安全,防止多个线程同时创建实例。如果没有同步块内的第二次判断,就可能存在多个线程同时进入同步块并尝试创建
instance
的情况,从而导致创建了多个实例,违反了单例模式的原则。
3.3 使用volatile防止指令重排
DCL 懒汉式还是存在问题,因为 instance = new Singleton()
创建一个对象不是一个原子性操作,在JVM中会经过三步:
- 为
instance
分配内存空间。 - 执行构造方法初始化
instance
对象。 - 将
instance
指向分配好的内存空间,此时instance != null
。
PS:指令重排序(Instruction Reordering)是现代计算机系统中优化性能的一种手段,通过改变语句的执行顺序来提高指令的并行度,从而提高执行效率。简单来说,就是指你在程序中写的代码,在执行时并不一定按照写的顺序。
由于指令重排序,原本是按 1-2-3 顺序的执行可能会变成 1-3-2。线程A在创建单例对象 Singleton
时,如果执行顺序非预期(如1-3-2),即分配内存给 instance
(步骤1),然后设置 instance
不为null(步骤3),但在初始化 instance
对象(步骤2)之前,这时候线程B来了,检查到 instance
不为 null
并尝试使用它。由于线程 A 没有执行步骤2, instance
实际上还未完成初始化,其内部属性可能都是 null
,这将增加线程B遇到空指针异常的风险。
因此,需要禁止指令重排,即使用 volatile 修饰的变量。对此,优化后的代码为:
1 | public class Singleton { |
4. 静态内部类
1 | /** |
这种方式能达到双检锁方式一样的功效,且实现更简单。
静态内部类同样利用了 classloader 机制来保证初始化 instance 时只有一个线程,而这种方式是 Singleton
类被装载了,instance
不一定被初始化。因为 SingletonHolder
类没有被主动使用,只有通过显式调用 getInstance
方法时,才会显式装载 SingletonHolder
类,从而实例化 instance
。这种方法不仅能确保线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。
5. 反射、序列化与反序列化
使用反射、序列化与反序列化,它们俩都可以把单例对象破坏掉(产生多个对象)。
5.1 反射破坏
修改懒汉式最后给出的示例代码,对 main()
方法进行修改,利用反射创建两个 Singleton
对象:
1 | public static void main(String[] args) throws Exception { |
运行结果:
1 | main |
显然创建的两个对象不是同一个,单例模式失效…
解决方法是在构造方法来添加一些限制,如果存在实例,那么就抛出异常,修改私有的构造方法:
1 | private Singleton() { |
运行代码,控制台打印:
好像解决问题了?
点开 newInstance()
的源码,源码中有这样一段判断:
1 | if ((clazz.getModifiers() & Modifier.ENUM) != 0) |
这段判断就是说不能通过反射创建枚举对象,枚举对象自带单例模式!
5.2 序列化与反序列化破坏
依旧还是以懒汉式最后给出的示例代码为基础,先让 Singleton
类实现 Serializable
接口,再对 main()
方法进行修改:
1 | public static void main(String[] args) throws Exception { |
运行代码后,控制台打印:
1 | main |
可以发现创建的两个对象不是同一个,单例模式失效…
实际上对于序列化与反序列化破坏单例模式的问题,主要是通过 readObject()
方法,出现了破坏单例模式的现象,主要是因为这个方法最后会通过反射调用无参数的构造方法创建一个新的对象,从而每次返回的对象都不一致。
那要怎么解决这个问题呢?
在 readObject()
方法的调用栈的底层方法中有这么两个方法:
hasReadResolveMethod
:表示如果实现了 serializable
或者 externalizable
接口的类中包含 readResolve
则返回 true 。
invokeReadResolve
:通过反射的方式调用要被反序列化的类的 readResolve
方法。
详细讲解可以看这篇文章:设计模式|序列化、反序列化对单例的破坏、原因分析、解决方案及解析
所以,原理也就清楚了,主要在Singleton中定义 readResolve
方法,并在该方法中指定要返回的对象的生成策略,就可以防止单例被破坏。
Singleton
类里添加如下代码:
1 | public class Singleton implements Serializable { |
运行代码测试:
1 | main |
成功!
6. 枚举实现
enum 的全称为 enumeration
, 是 JDK 1.5 中引入的新特性。
《Effective Java》一书作者Joshua Bloch书中提到:单元素的枚举类型已经成为实现 Singleton 的最佳方法。
在这种实现方式中,既可以避免多线程同步问题,还可以防止通过反射和反序列化来重新创建新的对象。
我们先来看看枚举如何实现单例模式的,代码如下:
1 | /** |
运行结果:
1 | 1349393271 |
哈哈,确实是单例唉!!
再来试试用反射破坏枚举实现的单例模式?修改上面代码的 main()
方法:
1 | public static void main(String[] args) throws Exception { |
运行测试:
会发现 newInstance()
方法直接不让 enum 类型利用反射来创建实例。所以,通过枚举实现单例模式是非常安全的。