单例设计模式(Singleton Design Pattern)理解起来非常简单。它的核心在于,单例模式可以保证一个类仅创建一个实例,并提供一个访问它的全局访问点。由于在一个系统中,一个类经常会被使用在不同的地方,通过单例模式,我们可以避免多次创建多个实例,从而节约系统资源。

该模式有三个基本要点:一是这个类只能有一个实例;二是它必须自行创建这个实例;三是它必须自行向整个系统提供这个实例。结合这三点,下面我们来看一下单例的实现方式:

单例模式的实现

1. 饿汉式

饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,具体是在类初始化的过程中被收集进类构造器即方法中。在多线程场景下,JVM 会保证只有一个线程能执行该类的方法,其它线程将会被阻塞等待。所以 instance 实例的创建过程是线程安全的。

不过,这样的实现方式不支持延迟加载(在真正用到的时候,再创建实例),从名字中我们也可以看出这一点。具体的代码实现如下所示:

  1. public class IdGenerator {
  2. // 提前创建
  3. private static final IdGenerator instance = new IdGenerator();
  4. // 私有构造方法
  5. private IdGenerator() {}
  6. // 供外部获取单例对象
  7. public static IdGenerator getInstance() {
  8. return instance;
  9. }
  10. }

有人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用的资源较多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。不过,我个人并不认同这样的观点。

如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。

如果实例占用资源多,按照 fail-fast 的设计原则,那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(比如 Java 中的 OOM),我们可以立即修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统可用性。

2. 懒汉式

有饿汉式,对应的,就有懒汉式。懒汉式相对于饿汉式的优势是支持延迟加载。具体的代码如下:

  1. public class IdGenerator {
  2. // 不实例化
  3. private static IdGenerator instance;
  4. private IdGenerator() {}
  5. public static synchronized IdGenerator getInstance() {
  6. // 当instance为null时,则实例化对象,否则直接返回对象
  7. if (instance == null) {
  8. instance = new IdGenerator();
  9. }
  10. return instance;
  11. }
  12. }

不过懒汉式的缺点也很明显,我们给 getInstance() 方法加了一把锁,导致这个函数的并发度很低。而这个函数是在单例使用期间,一直会被调用。如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。

3. 双重检测

饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发。那我们再来看一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式。在这种实现方式中,只要 instance 被创建之后,即便再调用 getInstance() 函数也不会再进入到加锁逻辑中了。所以,这种实现方式解决了懒汉式并发度低的问题。具体的代码实现如下:

  1. public class IdGenerator {
  2. private volatile static IdGenerator instance;
  3. private IdGenerator() {}
  4. public static IdGenerator getInstance() {
  5. if (instance == null) {
  6. synchronized(IdGenerator.class) {
  7. // 第二次判空是为了避免阻塞获取锁的线程获取到锁后,进入同步代码块里创建实例
  8. if (instance == null) {
  9. instance = new IdGenerator();
  10. }
  11. }
  12. }
  13. return instance;
  14. }
  15. }

为什么要用 volatile 修饰?

其实这跟 Happens-Before 规则和重排序有关系,这里我们先简单了解下 Happens-Before 规则和重排序。编译器为了尽可能地减少寄存器的读取、存储次数,会充分复用寄存器的存储值,比如以下代码,如果没有进行重排序优化,正常的执行顺序是步骤 1->2->3,而在编译期间进行了重排序优化后,执行的步骤有可能就变成了 1->3->2,这样就能减少一次寄存器的存取次数。

  1. int a = 1; //步骤1 加载a变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中
  2. int b = 2; //步骤2 加载b变量的内存地址到寄存器中,加载2到寄存器中,CPU通过mov指令把2写入到寄存器指定的内存中
  3. a = a + 1; //步骤3 重新加载a变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中

在 JMM 中,重排序是十分重要的一环,特别是在并发编程中。如果 JVM 可以对它们进行任意排序以提高程序性能,也可能会给并发编程带来一系列的问题。在执行 instance = new IdGenerator(); 代码时,正常情况下,实例过程这样的:

  1. 给 IdGenerator 分配内存;
  2. 调用 IdGenerator 的构造函数来初始化成员变量;
  3. 将 IdGenerator 对象指向分配的内存空间(执行完这步 singleton 就为非 null 了)。

如果虚拟机发生了重排序优化,这个时候步骤 3 可能发生在步骤 2 之前。如果初始化线程刚好完成步骤 3,而步骤 2 没有进行时,则刚好有另一个线程到了第一次判断,这个时候判断为非 null,并返回对象使用,这个时候实际没有完成其它属性的构造,因此使用这个属性就很可能会导致异常。在这里,Synchronized 只能保证可见性、原子性,无法保证执行的顺序。

这个时候,就体现出 Happens-Before 规则的重要性了,它的意思是,前一个操作的结果可以被后续的操作获取,这条规则规范了编译器对程序的重排序优化。我们知道 volatile 关键字可以保证线程间变量的可见性,简单说就是当线程 A 对变量 X 进行修改后,在线程 A 后面执行的其它线程就能看到变量 X 的变动。除此之外,volatile 在 JDK1.5 之后还有一个作用就是阻止局部重排序的发生,也就是说,volatile 变量的操作指令都不会被重排序。所以使用 volatile 修饰 instance 之后,双重检测单例模式就万无一失了。

4. 静态内部类

我们再来看一种比双重检测更加简单的实现方法,那就是利用 Java 的静态内部类。它有点类似饿汉式,但又能做到了延迟加载。具体是怎么做到的呢?我们先来看它的代码实现。

  1. public class IdGenerator {
  2. private IdGenerator() {}
  3. private static class SingletonHolder {
  4. private static final IdGenerator instance = new IdGenerator();
  5. }
  6. public static IdGenerator getInstance() {
  7. return SingletonHolder.instance;
  8. }
  9. }

SingletonHolder 是一个静态内部类,当外部类 IdGenerator 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。instance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。

5. 枚举

最后,我们介绍一种最简单的实现方式,基于枚举类型的单例实现。这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。具体的代码如下所示:

  1. public enum IdGenerator {
  2. INSTANCE;
  3. }

FAQ

听说两个类加载器可能有机会各自创建自己的单例实例?
是的,每个类加载器都定义了一个命名空间,不同的类加载器可能会加载同一个类,从整个程序来看,同一个类会被加载多次,导致单例类产生多个实例。可以自行指定类加载器并指定同一个类加载器以解决。

单例可不可以被继承?
继承单例会遇到一个问题,就是构造器是私有的,无法用私有构造器来扩展类,所以必须修改单例的构造器访问权限。即便改了还会有另一个问题出现,单例的实现是利用静态变量,直接继承会导致所有子类共享同一个实例变量,这样的后果可能不是我们想要的。

单例模式的缺点

大部分情况下,我们在项目中使用单例,都是用它来表示一些全局唯一类,比如配置信息类、连接池类、ID 生成器类。单例模式书写简洁、使用方便,在代码中,我们不需要创建对象,直接通过类来调用就可以了。但这种使用方法有点类似硬编码(hard code),会带来诸多问题。

1. 单例对 OOP 不友好

我们知道,OOP 的四大特性是封装、抽象、继承、多态。单例这种设计模式对于其中的抽象、继承、多态都支持得不好。我们还是通过 IdGenerator 这个例子来讲解。IdGenerator 的使用方式违背了基于接口而非实现的设计原则,也就违背了广义上理解的 OOP 的抽象特性。如果未来某一天,我们希望针对不同的业务采用不同的 ID 生成算法。为了应对这个需求变化,我们需要修改所有用到 IdGenerator 类的地方,这样代码改动会比较大。

所以,一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性。

2. 单例会隐藏类的依赖关系

我们知道,代码的可读性非常重要。在阅读代码时,我们希望一眼就能看出类与类之间的依赖关系,搞清楚这个类依赖了哪些外部类。通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码时,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。

3. 单例对代码的扩展性不友好

我们知道,单例类只能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。你可能会说,会有这样的需求吗?既然单例类大部分情况下都用来表示全局类,怎么会需要两个或者多个实例呢?实际上,这样的需求并不少见。

我们拿数据库连接池来举例解释一下。在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。但之后我们发现,系统中有些 SQL 语句运行得非常慢。这些 SQL 语句在执行时,长时间占用数据库连接资源,导致其他 SQL 请求无法响应。为了解决这个问题,我们希望将慢 SQL 与其他 SQL 隔离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池,慢 SQL 独享一个数据库连接池,其他 SQL 独享另外一个数据库连接池,这样就能避免慢 SQL 影响到其他 SQL 的执行。

如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。

4. 单例对代码的可测试性不友好

单例模式的使用会影响到代码的可测试性。如果单例类依赖比较重的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换。

除此之外,如果单例类持有成员变量(比如 IdGenerator 中的 id 成员变量),那它实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那我们在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。

5. 单例不支持有参数的构造函数

单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。针对这个问题,我们来看下都有哪些解决方案。

第一种解决思路是:创建完实例后,再调用 init() 函数传递参数。需要注意的是,我们在使用这个单例类的时候,要先调用 init() 方法,然后才能调用 getInstance() 方法,否则代码会抛出异常。具体的代码实现如下:

  1. public class Singleton {
  2. private static Singleton instance = null;
  3. private final int paramA;
  4. private final int paramB;
  5. private Singleton(int paramA, int paramB) {
  6. this.paramA = paramA;
  7. this.paramB = paramB;
  8. }
  9. public static Singleton getInstance() {
  10. if (instance == null) {
  11. throw new RuntimeException("Run init() first.");
  12. }
  13. return instance;
  14. }
  15. public synchronized static Singleton init(int paramA, int paramB) {
  16. if (instance != null){
  17. throw new RuntimeException("Singleton has been created!");
  18. }
  19. instance = new Singleton(paramA, paramB);
  20. return instance;
  21. }
  22. }

第二种解决思路是:将参数放到 getIntance() 方法中。具体的代码实现如下所示:

  1. public class Singleton {
  2. private static Singleton instance = null;
  3. private final int paramA;
  4. private final int paramB;
  5. private Singleton(int paramA, int paramB) {
  6. this.paramA = paramA;
  7. this.paramB = paramB;
  8. }
  9. public synchronized static Singleton getInstance(int paramA, int paramB) {
  10. if (instance == null) {
  11. instance = new Singleton(paramA, paramB);
  12. }
  13. return instance;
  14. }
  15. }

第三种解决思路是:将参数放到另外一个全局变量中。具体的代码实现如下。Config 是一个存储了 paramA 和 paramB 值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。实际上,这种方式是最值得推荐的。

  1. public class Config {
  2. public static final int PARAM_A = 123;
  3. public static final int PARAM_B = 245;
  4. }
  5. public class Singleton {
  6. private static Singleton instance = null;
  7. private final int paramA;
  8. private final int paramB;
  9. private Singleton() {
  10. this.paramA = Config.PARAM_A;
  11. this.paramB = Config.PARAM_B;
  12. }
  13. public synchronized static Singleton getInstance() {
  14. if (instance == null) {
  15. instance = new Singleton();
  16. }
  17. return instance;
  18. }
  19. }

单例模式使用案例

1. Java Runtime 类

JDK 中 java.lang.Runtime 类就是一个单例类。每个 Java 应用在运行时会启动一个 JVM 进程,每个 JVM 进程都只对应一个 Runtime 实例,用于查看 JVM 状态以及控制 JVM 行为。进程内唯一,所以比较适合设计为单例。在编程的时候,我们不能自己去实例化一个 Runtime 对象,只能通过 getRuntime() 静态方法来获得。

Runtime 类的的代码实现如下所示。这里面只包含部分相关代码,其他代码做了省略。从代码中,我们也可以看出,它使用了最简单的饿汉式的单例实现方式。

  1. /**
  2. * Every Java application has a single instance of class
  3. * <code>Runtime</code> that allows the application to interface with
  4. * the environment in which the application is running. The current
  5. * runtime can be obtained from the <code>getRuntime</code> method.
  6. * <p>
  7. * An application cannot create its own instance of this class.
  8. *
  9. * @author unascribed
  10. * @see java.lang.Runtime#getRuntime()
  11. * @since JDK1.0
  12. */
  13. public class Runtime {
  14. private static Runtime currentRuntime = new Runtime();
  15. public static Runtime getRuntime() {
  16. return currentRuntime;
  17. }
  18. /** Don't let anyone else instantiate this class */
  19. private Runtime() {}
  20. //....
  21. }

2. Spring Bean 单例