由于4年前为了准备设计模式面试,简单研究过单例模式,创建型模式的第一篇就来研究研究单例模式,回顾和熟练一下,由于学习的都是设计模式,所有系列文章都遵循如下的目录:
本篇Blog继续学习创建型模式,创建型模式的主要关注点是怎样创建对象,它的主要特点是将对象的创建与使用分离,这样可以降低系统的耦合度,使用者不需要关注对象的创建细节。本篇学习的是单例模式。由于学习的都是设计模式,所有系列文章都遵循如下的目录:
- 模式档案:包含模式的定义、模式的特点、解决什么问题、优缺点、使用场景等
- 模式结构:包含模式的结构,包含的角色定义及调用关系
- 模式实现:包含模式的实现方式代码举例或者生活中简单问题映射代码举例
- 模式实践:如果工作中或开源项目用到了该模式,就将使用过程贴到这里,并且客观讨论使用的是否恰当
- 模式对比:如果模式相似或模式有额外的替换方法,有必要体现其相似点及不同点,区分使用,说明哪些场景下使用哪种模式比较好
- 模式扩展:如果模式有与标准结构定义不同的变体形式,一并体现出其变体结构;对模式的思考需要进行发散等。
接下来所有设计模式的介绍都暂且遵循此基本行文逻辑吗,如果某一条目没有则无需体现,但条目顺序遵循此结构,本文的模式实践案例大多来自极客时间。
模式档案
在有些系统中,为了节省内存资源、保证数据内容的一致性,对某些类要求只能创建一个实例,这就是所谓的单例模式。
模式定义:单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象,通过单例模式可以保证整个系统中该类只有一个实例
模式特点:单例模式有以下几个特点
- 单例类只能有一个实例。不能通过外部new创建,所以构造方法需要为私有。
- 单例类必须自己创建自己的唯一实例。自己内部进行new来创建。
- 单例类必须给所有其他对象提供这一实例。创建好后通过静态方法提供。
总而言之就是:保证一个类仅有一个实例,并提供一个访问它的全局访问点
解决什么问题:对于系统中的某些类来说,只有一个实例很重要,当系统中需要该实例全局保证唯一以避免对该类实例频繁地创建与销毁造成大量性能开销时、当系统中需要该实例全局保证唯一以避免产生二义性时。例如一个系统中可以存在多个打印任务,但是只能有一个正在工作的任务;一个系统只能有一个窗口管理器或文件系统;一个系统只能有一个计时工具或ID(序号)生成器。
优点:内存里只有一个实例,减少了内存的开销,尤其是频繁的创建和销毁实例。
缺点:由于构造函数是私有的,类似一种硬编码,不具备Java的继承、多态、抽象等特性:
- 单例对 OOP 特性的支持不友好: 对于一个单例的ID生成器来说。如果我们希望针对不同的业务采用不同的 ID 生成算法。比如,订单 ID 和用户 ID 采用不同的 ID 生成器来生成。为了应对这个需求变化,我们需要修改所有用到 IdGenerator 类的地方,这样代码的改动就会比较大,相当于复制一套ID生成器。有点类似于硬编码
- 单例会隐藏类之间的依赖关系:通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽
- 单例对代码的扩展性不友好:单例类只能有一个对象实例,为了方便资源控制,假设我们用单例做数据库连接池。但之后我们发现,系统中有些 SQL 语句运行得非常慢。这些 SQL 语句在执行的时候,长时间占用数据库连接资源,导致其他 SQL 请求无法响应。为了解决这个问题,我们希望将慢 SQL 与其他 SQL 隔离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池,慢 SQL 独享一个数据库连接池,其他 SQL 独享另外一个数据库连接池,这样就能避免慢 SQL 影响到其他 SQL 的执行。但单例是很难适应这样的修改的,所以数据库连接池、线程池这类的资源池,最好还是不要设计成单例类
- 单例对代码的可测试性不友好:如果单例类持有成员变量(比如 IdGenerator 中的 id 成员变量),那它实际上相当于一种全局变量,被所有的代码共享,测试的时候一个用例对成员变量的修改可能会导致另一个测试用例非正常运行
- 单例不支持有参数的构造函数:比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。
虽然单例有时被称之为一种反模式(anti-pattern),不适应任何扩展,但是单例存在的意义就是解决唯一性问题的,有其独特的适用场景。如果单例类并没有后续扩展的需求,并且不依赖外部系统,那设计成单例类就没有太大问题。对于一些全局的类,我们在其他地方 new 的话,还要在类之间传来传去,不如直接做成单例类,使用起来简洁方便。
使用场景:创建对象时耗时过多或耗费资源过多,但又经常用到的对象;工具类对象;频繁访问数据库或文件的对象;该对象持有的数据是唯一的无二义性的。以上几种,当系统中对该类的实例使用较为通用化、全局化、频繁化、唯一化时,设计成单例是个比较好的选择
模式结构
单例模式的实现方式有很多种,也是大多数面试八股爱考的,这里我们简单盘一盘几种最能用到的吧,毕竟不是为了八股文而学习设计模式的。总体而言分为懒汉式和饿汉式,懒汉式就是延迟加载,在第一次调用方法时实例化自己,饿汉式就是类加载时就实例化自己。
模式实现
要实现一个单例,我们需要关注的点无外乎下面几个:构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例(必须满足);考虑对象创建时的线程安全问题(必须满足);考虑是否支持延迟加载;考虑 getInstance() 性能是否高(是否加锁),基于以上几点考虑我们来看以下几种常用的实现方式
1 饿汉式单例实现
饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载(在真正用到 IdGenerator 的时候,再创建实例),从名字中我们也可以看出这一点。具体的代码实现如下所示
public class IdGenerator { private AtomicLong id = new AtomicLong(0); //饿汉式单例的属性修饰不一定需要final,static final保证类加载时初始化赋值,static保证类clint初始化时赋值,要晚一些,但在此过程中java一样会保证过程中线程安全 private static final IdGenerator instance = new IdGenerator(); private IdGenerator() {} public static IdGenerator getInstance() { return instance; } public long getId() { return id.incrementAndGet(); } }
如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。不支持延迟加载
但是从另一个角度来看,如果这个实例一定会用到,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题,即使有问题,例如如果实例占用资源多,按照 fail-fast 的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(比如 Java 中的 PermGen Space OOM),我们可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性
2 懒汉式单例实现
懒汉式相对于饿汉式的优势是支持延迟加载。具体的代码实现如下所示:
public class IdGenerator { private AtomicLong id = new AtomicLong(0); private static IdGenerator instance; private IdGenerator() {} public static synchronized IdGenerator getInstance() { if (instance == null) { instance = new IdGenerator(); } return instance; } public long getId() { return id.incrementAndGet(); } }
不过懒汉式的缺点也很明显,我们给 getInstance() 这个方法加了一把大锁(synchronzed),导致这个函数的并发度很低。量化一下的话,并发度是 1,也就相当于串行操作了。而这个函数是在单例使用期间,一直会被调用。如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。getInstance()方法性能低
3 双检锁单例实现
饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发。那我们再来看一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式。双检锁/双重校验锁(DCL,即 double-checked locking),这种方式采用双锁机制,安全且在多线程情况下能保持高性能,只要 instance 被创建之后,即便再调用 getInstance() 函数也不会再进入到加锁逻辑中了。所以,这种实现方式解决了懒汉式并发度低的问题
package com.example.designpattern.singleton; public class IdGenerator { private AtomicLong id = new AtomicLong(0); //成员需要为静态,否则没法在静态方法中使用 private volatile static IdGenerator instance; //构造方法需要为private,防止在外部被new,也正因为这样,单例不能被继承,因为在super父类时构造方法私有构造会失败 private IdGenerator() {} //方法需要为静态,因为IdGenerator 在方法内创建单个实例前不能被实例化,也就是方法调用前不可能有实例调用,只能用类来调用 public static IdGenerator getInstance() { if (instance == null) { synchronized(IdGenerator.class) { // 此处为类级别的锁 if (instance == null) { instance = new IdGenerator(); } } } return instance; } public long getId() { return id.incrementAndGet(); } }
这段代码有三个要素需要注意:
- 第一重
instance == null的作用,为了防止每个进入该方法的线程都进入同步块,当实例创建好后无需再执行同步代码,避免内存损耗。 - 第二重
instance == null的作用,当两个线程同时到达方法内部,即同时调用getInstance()方法,此时由于instance == null两个线程都可以通过第一重instance == null检查,进入第一重 if 语句后,由于存在锁机制,所以会有一个线程进入 lock 语句并进入第二重instance == null,而另外的一个线程则会在 lock 语句的外面等待。而当第一个线程执行完instance = new Singleton()语句后,便会退出锁定区域,此时,第二个线程便可以进入 lock 语句块,此时,如果没有第二重instance == null第二个线程还是可以调用instance = new Singleton()语句,这样第二个线程也会创建一个 Singleton 实例,这样也还是违背了单例模式的初衷 volatile关键字的作用,为什么需要使用volatile呢?因为instance = new Singleton();并非一个原子操作, 首先应该注意的是使用内置锁加锁的是Singleton.class(在此处,由于instance未初始化,所以使用对象锁会报空指针异常),并不是instance ,也就是说没有在instance 实现同步,那么在这种情况下,当有两个线程同时进行到synchronized代码块时,只有一个线程可以进入,然后初始化了instance ,但是这仅仅只能保证的是两个线程在访问上的独占性,也就是说两个线程在此一定是一先一后进行访问,但是不能保证的是instance的内存可见性,原因很简单,因为同步的对象并不是instance, 而是Singleton.class。instance = new Singleton();可以拆解为三个JVM指令:
memory = allocate(); //1 分配对象的内存空间 ctorInstance(memory); //2 初始化对象 instance = memory; //3 设置instance指向刚分配的内存地址
上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:
memory =allocate(); //1 分配对象的内存空间 instance =memory; //3 instance指向刚分配的内存地址,此时对象还未初始化 ctorInstance(memory); //2 初始化对象
可以看到指令重排之后,instance 指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法第一重instance == null判断instance引用不为null,然后就将其返回使用,导致返回了一个没有被初始化的对象而出错。getInstance()方法性能高,有延迟加载
4 静态内部类实现
这种方式能达到双检锁方式一样的功效,但实现更简单。对静态域使用延迟初始化,应使用这种方式而不是双检锁方式。这种方式只适用于静态域的情况,双检锁方式可在实例域需要延迟初始化时使用。
package com.example.designpattern.singleton; public class IdGenerator { private AtomicLong id = new AtomicLong(0); private IdGenerator() {} private static class SingletonHolder{ private static final IdGenerator instance = new IdGenerator(); } public static IdGenerator getInstance() { return SingletonHolder.instance; } public long getId() { return id.incrementAndGet(); } }
SingletonHolder 是一个静态内部类,当外部类 IdGenerator 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。instance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。getInstance()方法性能高,有延迟加载
5 枚举式单例实现
以上三种单例的实现方式在正常场景下是绝对单例的,但在某些场景下也不是绝对安全的,例如:反射和序列化:
- 反射可以获得类的私有构造方法,这样就会导致可以创建多个实例,破坏单例模式的对象唯一性
- 通过序列化和反序列化,得到的对象是一个新的对象,破坏了单例模式对象的唯一性。
枚举单例不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化
public enum IdGenerator { INSTANCE; private AtomicLong id = new AtomicLong(0); public long getId() { return id.incrementAndGet(); } }
使用时直接调用方法即可:
package com.example.designpattern.singleton; import java.util.concurrent.atomic.AtomicLong; public class EnumTest { public static void main(String[] args) { System.out.println(IdGenerator.INSTANCE.getId()); } } enum IdGenerator { INSTANCE; private AtomicLong id = new AtomicLong(0); public long getId() { return id.incrementAndGet(); } }
打印结果如下:
针对单例的唯一性、线程安全、反射和序列化安全做如下说明:
- 枚举实例是static final 的,保证只被实例化一次
- 枚举是一个饿汉式加载,因此也就是线程安全的
- 对于反射来说,枚举的反编译是一个抽象类,就不能通过反射来创建实例了
- 对于序列化和反序列化,因为每一个枚举类型和枚举变量在JVM中都是唯一的,即Java在序列化和反序列化枚举时做了特殊的规定有默认的实现方式,枚举的
writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法是被编译器禁用的,因此也不存在实现序列化接口后调用readObject会破坏单例的问题。
所以说枚举类才是实实在在的单例王者啊!对于以上的几种单例实现方式,一般情况下建议使用类加载实例化方式和双检锁方式。静态域只有在要明确实现 lazy loading 效果时,才会使用 静态内部类方式。如果涉及到反序列化创建对象时,非常推荐使用枚举方式。

