写点什么

01. 单例模式设计思想

作者:杨充
  • 2024-10-14
    湖北
  • 本文字数:14245 字

    阅读完需:约 47 分钟

01.单例模式设计思想

目录介绍

  • 01.单例模式基础介绍

  • 1.1 模式的动机

  • 1.2 单例模式特点

  • 1.3 单例模式定义

  • 1.4 单例使用场景

  • 1.5 单例模式思考

  • 02.单例模式设计思考

  • 2.1 为何要用单例

  • 2.2 处理资源访问冲突

  • 2.3 表示全局唯一类

  • 03.如何实现单例模式

  • 3.1 如何实现一个单例

  • 3.2 饿汉式实现方式

  • 3.3 懒汉式实现方式

  • 3.4 双重 DCL 校验模式

  • 3.5 静态内部类方式

  • 3.6 枚举方式单例

  • 3.7 容器实现单例模式

  • 3.8 优缺点分析

  • 04.单例模式有那些不友好

  • 4.1 单例是反模式吗

  • 4.2 单例对 OOP 不友好

  • 4.3 隐藏类之间依赖

  • 4.4 代码扩展性不友好

  • 4.5 可测试性不友好

  • 4.6 不支持有参构造函数

  • 4.7 有何替代解决方案

  • 05.最后总结一下

  • 5.1 适用环境分析

  • 5.2 对单例总结下

01.单例模式基础介绍

1.0 本博客 AI 摘要

本文详细介绍了单例模式的设计思想及其应用。首先阐述了单例模式的基本概念、特点与定义,并探讨其适用场景与常见问题。接着深入分析了为何使用单例模式,包括处理资源访问冲突和表示全局唯一类。随后详细讲解了几种常见的单例实现方式,如饿汉式、懒汉式、双重检查锁定、静态内部类及枚举等,对比了各自优缺点。最后讨论了单例模式可能带来的问题,如对 OOP 不友好、隐藏依赖关系、扩展性差等,并提出了一些替代解决方案。文章内容丰富,适合希望深入了解单例模式及其应用的读者。

1.1 模式的动机

对于系统中的某些类来说,只有一个实例很重要,例如,一个系统中可以存在多个打印任务,但是只能有一个正在工作的任务;一个系统只能有一个窗口管理器或文件系统;一个系统只能有一个计时工具或 ID(序号)生成器。


如何保证一个类只有一个实例并且这个实例易于被访问呢?定义一个全局变量可以确保对象随时都可以被访问,但不能防止我们实例化多个对象


一个更好的解决办法是让类自身负责保存它的唯一实例。这个类可以保证没有其他实例被创建,并且它可以提供一个访问该实例的方法。这就是单例模式的模式动机。

1.2 单例模式特点

单例模式是应用最广的模式


也是最先知道的一种设计模式,在深入了解单例模式之前,每当遇到如:getInstance()这样的创建实例的代码时,我都会把它当做一种单例模式的实现。


单例模式特点


  1. 构造函数不对外开放,一般为 private

  2. 通过一个静态方法或者枚举返回单例类对象

  3. 确保单例类的对象有且只有一个,尤其是在多线程的环境下

  4. 确保单例类对象在反序列化时不会重新构造对象

1.3 单例模式定义

保证一个类仅有一个实例,并提供一个访问它的全局访问点。


单例模式有三个基本要点:一是这个类只能有一个实例;二是它必须自行创建这个实例;三是它必须自行向整个系统提供这个实例。

1.3 单例使用场景

应用中某个实例对象需要频繁的被访问。


应用中每次启动只会存在一个实例。如账号系统,数据库系统。


一个具有自动编号主键的表可以有多个用户同时使用,但数据库中只能有一个地方分配下一个主键编号,否则会出现主键重复,因此该主键编号生成器必须具备唯一性,可以通过单例模式来实现。

1.4 思考几个问题

网上有很多讲解单例模式的文章,但大部分都侧重讲解,如何来实现一个线程安全的单例。重点还是希望搞清楚下面这样几个问题。


  1. 为什么要使用单例?

  2. 单例存在哪些问题?

  3. 单例与静态类的区别?

  4. 有何替代的解决方案?

02.单例模式设计思考

2.1 为什么要使用单例

单例设计模式(Singleton Design Pattern)理解起来非常简单。


一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。


重点看一下,为什么我们需要单例这种设计模式?它能解决哪些问题?接下来我通过两个实战案例来讲解。


  1. 第一个是处理资源访问冲突;

  2. 第二个是表示全局唯一类;

2.2 处理资源访问冲突

实战案例一:处理资源访问冲突


先来看第一个例子。在这个例子中,我们自定义实现了一个往文件中打印日志的 Logger 类。具体的代码实现如下所示:


public class Logger {    private final FileWriter writer;    public Logger() {        File file = new File("/Users/yangchong/log.txt");        try {            //true表示追加写入            writer = new FileWriter(file, true);        } catch (IOException e) {            throw new RuntimeException(e);        }    }    public void log(String message) {        try {            writer.write(message);        } catch (IOException e) {            throw new RuntimeException(e);        }    }}
public class UserController { private final Logger logger = new Logger();
public void login(String username, String password) { // ...省略业务逻辑代码... logger.log(username + " logined!"); }}
public class OrderController { private final Logger logger = new Logger();
public void create(String order) { // ...省略业务逻辑代码... logger.log("created order: " + order); }}
复制代码


看完代码之后,先别着急看我下面的讲解,你可以先思考一下,这段代码存在什么问题。


  1. 在上面的代码中,所有的日志都写入到同一个文件 /Users/yangchong/log.txt 中。在 UserController 和 OrderController 中,我们分别创建两个 Logger 对象。

  2. 如果两个线程同时分别执行 login() 和 create() 两个函数,并且同时写日志到 log.txt 文件中,那就有可能存在日志信息互相覆盖的情况。

  3. 为什么会出现互相覆盖呢?可以这么类比着理解。在多线程环境下,如果两个线程同时给同一个共享变量加 1,因为共享变量是竞争资源,所以,共享变量最后的结果有可能并不是加了 2,而是只加了 1。

  4. 同理,这里的 log.txt 文件也是竞争资源,两个线程同时往里面写数据,就有可能存在互相覆盖的情况。


那如何来解决这个问题呢?


最先想到的就是通过加锁的方式:给 log() 函数加互斥锁(Java 中可以通过 synchronized 的关键字),同一时刻只允许一个线程调用执行 log() 函数。具体的代码实现如下所示:


public class Logger {  private FileWriter writer;
public Logger() { File file = new File("/Users/yangchong/log.txt"); writer = new FileWriter(file, true); //true表示追加写入 } public void log(String message) { synchronized(this) { writer.write(message); } }}
复制代码


不过,你仔细想想,这真的能解决多线程写入日志时互相覆盖的问题吗?


答案是否定的。这是因为,这种锁是一个对象级别的锁,一个对象在不同的线程下同时调用 log() 函数,会被强制要求顺序执行。


但是,不同的对象之间并不共享同一把锁。在不同的线程下,通过不同的对象调用执行 log() 函数,锁并不会起作用,仍然有可能存在写入日志互相覆盖的问题。

2.3 表示全局唯一类

从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。


比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。


再比如,唯一递增 ID 号码生成器,如果程序中有两个对象,那就会存在生成重复 ID 的情况,所以,我们应该将 ID 生成器类设计为单例。


public static class IdGenerator {    private final AtomicLong id = new AtomicLong(0);    private static final IdGenerator instance = new IdGenerator();    private IdGenerator() {}
public static IdGenerator getInstance() { return instance; } public long getId() { return id.incrementAndGet(); }}
public void test() { // IdGenerator使用举例 long id = IdGenerator.getInstance().getId();}
复制代码


实际上,讲到的两个代码实例(Logger、IdGenerator),设计的都并不优雅,还存在一些问题。

03.如何实现单例模式

3.1 如何实现一个单例

介绍如何实现一个单例模式的文章已经有很多了,但为了保证内容的完整性,这里还是简单介绍一下几种经典实现方式。概括起来,要实现一个单例,需要关注的点无外乎下面几个:


  1. 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;

  2. 考虑对象创建时的线程安全问题;

  3. 考虑是否支持延迟加载;

  4. 考虑 getInstance() 性能是否高(是否加锁)。

3.2 饿汉式实现方式

饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。


不过,这样的实现方式不支持延迟加载,从名字中我们也可以看出这一点。具体的代码实现如下所示:


//饿汉式单例类.在类初始化时,已经自行实例化public static class Singleton2 {    //static修饰的静态变量在内存中一旦创建,便永久存在    private static final Singleton2 singleton = new Singleton2();
private Singleton2() {
}
public static Singleton2 getInstance() { return singleton; }
public static void main(String[] args) { Singleton2 instance = Singleton2.getInstance(); }}
复制代码


代码分析


使用了 static 修饰了成员变量 instance,所以该变量会在类初始化的过程中被收集进类构造器即方法中。在多线程场景下,JVM 会保证只有一个线程能执行该类的 方法,其它线程将会被阻塞等待。


等到唯一的一次方法执行完成,其它线程将不会再执行方法,转而执行自己的代码。也就是说,static 修饰了成员变量 instance,在多线程的情况下能保证只实例化一次。


其中 instance = new Singleton()可以写成:


static {    instance = new Singleton();}
复制代码


懒汉式单例说明


  1. 在类初始化阶段就已经在堆内存中开辟了一块内存,用于存放实例化对象,所以也称为饿汉模式。

  2. 饿汉模式实现的单例的优点是,可以保证多线程情况下实例的唯一性,而且 getInstance 直接返回唯一实例,性能非常高。


有人觉得这种实现方式不好,思考下是否认同这种观点


因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。不过,我个人并不认同这样的观点。如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。


采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。如果实例占用资源多,按照 fail-fast 的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(比如 Java 中的 PermGen Space OOM),我们可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性。

3.3 懒汉式实现方式

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


//懒汉式单例类.在第一次调用的时候实例化自己public static class Singleton3 {    private static Singleton3 singleton;    private Singleton3() {
} public static Singleton3 getInstance() { if (singleton == null) { singleton = new Singleton3(); } return singleton; }
public static void main(String[] args) { Singleton3 instance = Singleton3.getInstance(); }}
复制代码


代码分析:懒汉式(线程不安全)的单例模式分为三个部分:私有的构造方法,私有的全局静态变量,公有的静态方法。起到重要作用的是静态修饰符 static 关键字,在程序中,任何变量或者代码都是在编译时由系统自动分配内存来存储的,而所谓静态就是指在编译后所分配的内存会一直存在,直到程序退出内存才会释放这个空间,因此也就保证了单例类的实例一旦创建,便不会被系统回收,除非手动设置为 null。


优缺点说明


  1. 优点:延迟加载(需要的时候才去加载)

  2. 缺点:线程不安全,在多线程中很容易出现不同步的情况,如在数据库对象进行的频繁读写操作时。


以上代码在单线程下运行是没有问题的,但要运行在多线程下,就会出现实例化多个类对象的情况。这是怎么回事呢?


当线程 A 进入到 if 判断条件后,开始实例化对象,此时 instance 依然为 null;又有线程 B 进入到 if 判断条件中,之后也会通过条件判断,进入到方法里面创建一个实例对象。


所以我们需要对该方法进行加锁,保证多线程情况下仅创建一个实例。这里我们使用 Synchronized 同步锁来修饰 getInstance 方法


上面这个可以看到是线程不安全的,其实还可以演变一下:


public static synchronized Singleton3 getInstance() {    if (singleton == null) {        singleton = new Singleton3();    }    return singleton;}
复制代码


代码分析:这种单例实现方式的 getInstance()方法中添加了 synchronized 关键字,也就是告诉 Java(JVM)getInstance 是一个同步方法。同步的意思是当两个并发线程访问同一个类中的这个 synchronized 同步方法时,一个时间内只能有一个线程得到执行,另一个线程必须等待当前线程执行完才能执行,因此同步方法使得线程安全,保证了单例只有唯一个实例。


优缺点


  1. 优点:解决了线程不安全的问题。

  2. 缺点:效率有点低,每次调用实例都要判断同步锁,它的缺点在于每次调用 getInstance()都进行同步,造成了不必要的同步开销。这种模式一般不建议使用。


不过懒汉式的缺点也很明显


  1. 给 getInstance() 这个方法加了一把大锁(synchronized),导致这个函数的并发度很低。量化一下的话,并发度是 1,也就相当于串行操作了。而这个函数是在单例使用期间,一直会被调用。

  2. 如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。

3.4 双重 DCL 校验模式

饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发。那再来看一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式。


  1. 在这种实现方式中,只要 instance 被创建之后,即便再调用 getInstance() 函数也不会再进入到加锁逻辑中了。

  2. Double-Check,它可以大大提高支持多线程的懒汉模式的运行性能。所以,这种实现方式解决了懒汉式并发度低的问题。


具体的代码实现如下所示:


public static class Singleton4 {    private static Singleton4 singleton;  //静态变量
private Singleton4() { //私有构造函数 }
public static Singleton4 getInstance() { if (singleton == null) { //第一层校验 synchronized (Singleton4.class) { if (singleton == null) { //第二层校验 singleton = new Singleton4(); } } } return singleton; }
public static void main(String[] args) { Singleton4 instance4 = Singleton4.getInstance(); }}
复制代码


代码分析:这种模式的亮点在于 getInstance()方法上,其中对 singleton 进行了两次判断是否空,第一层判断是为了避免不必要的同步,第二层的判断是为了在 null 的情况下才创建实例。


优缺点


  1. 优点:在并发量不多,安全性不高的情况下或许能很完美运行单例模式

  2. 缺点:不同平台编译过程中可能会存在严重安全隐患。


那这样做是不是就能保证万无一失了呢?还会有什么问题吗?


这里又跟 Happens-Before 规则和重排序扯上关系了,这里我们先来简单了解下 Happens-Before 规则和重排序。


编译器为了尽可能地减少寄存器的读取、存储次数,会充分复用寄存器的存储值,比如以下代码,如果没有进行重排序优化,正常的执行顺序是步骤 1/2/3,而在编译期间进行了重排序优化之后,执行的步骤有可能就变成了步骤 1/3/2,这样就能减少一次寄存器的存取次数。


在 JMM 中,重排序是十分重要的一环,特别是在并发编程中。如果 JVM 可以对它们进行任意排序以提高程序性能,也可能会给并发编程带来一系列的问题。


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


模拟分析,假设线程 A 执行到了 singleton = new Singleton(); 语句,这里看起来是一句代码,但是它并不是一个原子操作,这句代码最终会被编译成多条汇编指令,它大致会做三件事情:


  1. a)给 Singleton 的实例分配内存

  2. b)调用 Singleton()的构造函数,初始化成员字段;

  3. c)将 singleton 对象指向分配的内存空间(即 singleton 不为空了);

  4. 但是由于 Java 编译器允许处理器乱序执行,以及在 jdk1.5 之前,JMM(Java Memory Model:java 内存模型)中 Cache、寄存器、到主内存的回写顺序规定,上面的步骤 b 步骤 c 的执行顺序是不保证了。

  5. 也就是说执行顺序可能是 a-b-c,也可能是 a-c-b,如果是后者的指向顺序,并且恰恰在 c 执行完毕,b 尚未执行时,被切换到线程 B 中,这时候因为 singleton 在线程 A 中执行了步骤 c 了,已经非空了,所以,线程 B 直接就取走了 singleton,再使用时就会出错。这就是 DCL 失效问题。


如何解决 DCL 中可能出现的失效问题(指 a-b-c,顺序变成了 a-c-b 导致使用对象出错)


volatile 在 JDK1.5 之后还有一个作用就是阻止局部重排序的发生,也就是说,volatile 变量的操作指令都不会被重排序。


所以使用 volatile 修饰 instance 之后,Double-Check 懒汉单例模式就万无一失了。将 singleton 定义的代码改成:


private volatile static Singleton singleton;  //使用volatile 关键字
复制代码


网上有人说,这种实现方式有些问题。


因为指令重排序,可能会导致 singleton 对象被 new 出来,并且赋值给 instance 之后,还没来得及初始化(执行构造函数中的代码逻辑),就被另一个线程使用了。


要解决这个问题,需要给 instance 成员变量加上 volatile 关键字,禁止指令重排序才行。


实际上,只有很低版本的 Java 才会有这个问题。现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)。

3.5 静态内部类方式

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


在饿汉模式中,我们使用了 static 修饰了成员变量 instance,所以该变量会在类初始化的过程中被收集进类构造器即 方法中。


在多线程场景下,JVM 会保证只有一个线程能执行该类的 方法,其它线程将会被阻塞等待。这种方式可以保证内存的可见性、顺序性以及原子性。


如果我们在 Singleton 类中创建一个内部类来实现成员变量的初始化,则可以避免多线程下重复创建对象的情况发生。


这种方式,只有在第一次调用 getInstance() 方法时,才会加载 InnerSingleton 类,而只有在加载 InnerSingleton 类之后,才会实例化创建对象。


public static class Singleton5 {    private Singleton5() {
}
public static Singleton5 getInstance() { return SingletonHolder.INSTANCE; }
//定义的静态内部类 private static class SingletonHolder { private static final Singleton5 INSTANCE = new Singleton5(); //创建实例的地方 }
public static void main(String[] args) { Singleton5 singleton5 = Singleton5.getInstance(); }}
复制代码


优缺点:优点:延迟加载,线程安全(java 中 class 加载时互斥的),也减少了内存消耗


代码分析:当第一次加载 Singleton 类的时候并不会初始化 INSTANCE ,只有第一次调用 Singleton 的 getInstance()方法时才会导致 INSTANCE 被初始化。因此,第一次调用 getInstance()方法会导致虚拟机加载 SingletonHolder 类,这种方式不仅能够确保单例对象的唯一性,同时也延迟了单例的实例化。

3.6 枚举方式单例

介绍一种最简单的实现方式,基于枚举单例模式的单例实现。


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


public static Singleton6 getInstance6(){    return Singleton6.INSTANCE;}
public enum Singleton6 {
INSTANCE;
public void whateverMethod() {
}}
复制代码


代码分析


枚举单例模式最大的优点就是写法简单,枚举在 java 中与普通的类是一样的,不仅能够有字段,还能够有自己的方法,最重要的是默认枚举实例是线程安全的,并且在任何情况下,它都是一个单例。


即使是在反序列化的过程,枚举单例也不会重新生成新的实例。而其他几种方式,必须加入如下方法:才能保证反序列化时不会生成新的对象。


private Object readResolve()  throws ObjectStreamException{    return INSTANCE;}
复制代码

3.7 容器实现单例模式

这一种比较少见,直接上代码,如下所示:


public static class SingletonContainer {    private static final Map<String, Singleton6> container = new HashMap<>();
private SingletonContainer() { // 私有构造函数,防止外部实例化 } public static Singleton6 getInstance(String key) { if (!container.containsKey(key)) { synchronized (SingletonContainer.class) { if (!container.containsKey(key)) { Singleton6 instance = new Singleton6(); // 根据需要创建实例的方法 container.put(key, instance); } } } return container.get(key); }
public static class Singleton6 { //单例类 }}
复制代码


代码分析:在程序的初始,将多种单例模式注入到一个统一的管理类中,在使用时根据 key 获取对应类型的对象。

3.8 优缺点分析

优点


  1. 提供了对唯一实例的受控访问。因为单例类封装了它的唯一实例,所以它可以严格控制客户怎样以及何时访问它,并为设计及开发团队提供了共享的概念。

  2. 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象,单例模式无疑可以提高系统的性能。

  3. 允许可变数目的实例。我们可以基于单例模式进行扩展,使用与单例控制相似的方法来获得指定个数的对象实例。


缺点


  1. 由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。

  2. 单例类的职责过重,在一定程度上违背了“单一职责原则”。因为单例类既充当了工厂角色,提供了工厂方法,同时又充当了产品角色,包含一些业务方法,将产品的创建和产品的本身的功能融合到一起。

  3. 滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;现在很多面向对象语言(如 Java、C#)的运行环境都提供了自动垃圾回收的技术,因此,如果实例化的对象长时间不被利用,系统会认为它是垃圾,会自动销毁并回收资源,下次利用时又将重新实例化,这将导致对象状态的丢失。

04.单例模式有那些不友好

4.1 单例是反模式吗

尽管单例是一个很常用的设计模式,在实际的开发中,我们也确实经常用到它,但是,有些人认为单例是一种反模式(anti-pattern),并不推荐使用。


所以,就针对这个说法详细地讲讲这几个问题:单例这种设计模式存在哪些问题?为什么会被称为反模式?如果不用单例,该如何表示全局唯一类?有何替代的解决方案?

4.2 单例对 OOP 不友好

OOP 的四大特性是封装、抽象、继承、多态


单例这种设计模式对于其中的抽象、继承、多态都支持得不好。为什么这么说呢?我们还是通过 IdGenerator 这个例子来讲解。


public class Order {  public void create(...) {    //...    long id = IdGenerator.getInstance().getId();    //...  }}
public class User { public void create(...) { // ... long id = IdGenerator.getInstance().getId(); //... }}
复制代码


IdGenerator 的使用方式违背了基于接口而非实现的设计原则,也就违背了广义上理解的 OOP 的抽象特性。


如果未来某一天,我们希望针对不同的业务采用不同的 ID 生成算法。比如,订单 ID 和用户 ID 采用不同的 ID 生成器来生成。


为了应对这个需求变化,我们需要修改所有用到 IdGenerator 类的地方,这样代码的改动就会比较大。


public class Order {  public void create(...) {    //...    long id = IdGenerator.getInstance().getId();    // 需要将上面一行代码,替换为下面一行代码    long id = OrderIdGenerator.getIntance().getId();    //...  }}
public class User { public void create(...) { // ... long id = IdGenerator.getInstance().getId(); // 需要将上面一行代码,替换为下面一行代码 long id = UserIdGenerator.getIntance().getId(); }}
复制代码


除此之外,单例对继承、多态特性的支持也不友好。


这里之所以会用“不友好”这个词,而非“完全不支持”,是因为从理论上来讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。


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

4.3 隐藏类之间依赖

代码的可读性非常重要。


在阅读代码的时候,我们希望一眼就能看出类与类之间的依赖关系,搞清楚这个类依赖了哪些外部类。通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。


但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。

4.4 代码扩展性不友好

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


实际上,这样的需求并不少见。我们拿数据库连接池来举例解释一下。


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


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

4.5 可测试性不友好

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


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

4.6 不支持有参构造函数

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


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


public class Singleton {  private static Singleton instance = null;  private final int paramA;  private final int paramB;
private Singleton(int paramA, int paramB) { this.paramA = paramA; this.paramB = paramB; }
public static Singleton getInstance() { if (instance == null) { throw new RuntimeException("Run init() first."); } return instance; }
public synchronized static Singleton init(int paramA, int paramB) { if (instance != null){ throw new RuntimeException("Singleton has been created!"); } instance = new Singleton(paramA, paramB); return instance; }}
Singleton.init(10, 50); // 先init,再使用Singleton singleton = Singleton.getInstance();
复制代码


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


public class Singleton {  private static Singleton instance = null;  private final int paramA;  private final int paramB;
private Singleton(int paramA, int paramB) { this.paramA = paramA; this.paramB = paramB; }
public synchronized static Singleton getInstance(int paramA, int paramB) { if (instance == null) { instance = new Singleton(paramA, paramB); } return instance; }}
Singleton singleton = Singleton.getInstance(10, 50);
复制代码


不知道你有没有发现,上面的代码实现稍微有点问题。如果我们如下两次执行 getInstance() 方法,那获取到的 singleton1 和 signleton2 的 paramA 和 paramB 都是 10 和 50。也就是说,第二次的参数(20,30)没有起作用,而构建的过程也没有给与提示,这样就会误导用户。这个问题如何解决呢?


Singleton singleton1 = Singleton.getInstance(10, 50);Singleton singleton2 = Singleton.getInstance(20, 30);
复制代码


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


public class Config {  public static final int PARAM_A = 123;  public static fianl int PARAM_B = 245;}
public class Singleton { private static Singleton instance = null; private final int paramA; private final int paramB;
private Singleton() { this.paramA = Config.PARAM_A; this.paramB = Config.PARAM_B; }
public synchronized static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; }}
复制代码

4.7 有何替代解决方案

提到了单例的很多问题,你可能会说,即便单例有这么多问题,但我不用不行啊。业务上有表示全局唯一类的需求,如果不用单例,怎么才能保证这个类的对象全局唯一呢?


为了保证全局唯一,除了使用单例,我们还可以用静态方法来实现。这也是项目开发中经常用到的一种实现思路。比如,上一节课中讲的 ID 唯一递增生成器的例子,用静态方法实现一下,就是下面这个样子:


// 静态方法实现方式public class IdGenerator {  private static AtomicLong id = new AtomicLong(0);
public static long getId() { return id.incrementAndGet(); }}// 使用举例long id = IdGenerator.getId();
复制代码


不过,静态方法这种实现思路,并不能解决我们之前提到的问题。实际上,它比单例更加不灵活,比如,它无法支持延迟加载。我们再来看看有没有其他办法。实际上,单例除了我们之前讲到的使用方法之外,还有另外一个种使用方法。具体的代码如下所示:


// 1. 老的使用方式public demofunction() {  //...  long id = IdGenerator.getInstance().getId();  //...}
// 2. 新的使用方式:依赖注入public demofunction(IdGenerator idGenerator) { long id = idGenerator.getId();}// 外部调用demofunction()的时候,传入idGeneratorIdGenerator idGenerator = IdGenerator.getInsance();demofunction(idGenerator);
复制代码


基于新的使用方式,我们将单例生成的对象,作为参数传递给函数(也可以通过构造函数传递给类的成员变量),可以解决单例隐藏类之间依赖关系的问题。不过,对于单例存在的其他问题,比如对 OOP 特性、扩展性、可测性不友好等问题,还是无法解决。

05.最后总结一下

5.1 适用环境分析

在以下情况下可以使用单例模式:


  1. 系统只需要一个实例对象,如系统要求提供一个唯一的序列号生成器,或者需要考虑资源消耗太大而只允许创建一个对象。

  2. 客户调用类的单个实例只允许使用一个公共访问点,除了该公共访问点,不能通过其他途径访问该实例。

  3. 在一个系统中要求一个类只有一个实例时才应当使用单例模式。反过来,如果一个类可以有几个实例共存,就需要对单例模式进行改进,使之成为多例模式

5.2 对单例总结下

01.单例模式基础介绍


对于系统某些需求来说,保证一个实例很重要,比如文件管理系统,ID 生成器等。为了保证实例只能被创建一次,因此这才有了单例模式!


单例模式特点,构造私有,单例类只有一个,反序列化不会重新构建对象,通过静态返回单例对象。


使用场景:应用中只存在一个实例,比如账号系统,数据库等。思考几个问题:为何使用单例,它存在什么问题,跟静态类有何区别,是否有替代方案?


02.单例模式设计思考


为何要使用单例?一个类只允许创建一个对象(或者实例),表示全局唯一的类,比如 Android 中数据库,所有数据操作都是指向一个数据库!


03.单例模式实现方式


如何实现单例:构造必须私有,避免外部创建;要考虑线程安全问题,避免多线程下创建多个对象;是否支持延迟加载;性能


  1. 方式 1: 熟悉单例模式各自的优缺点和使用场景。

  2. 方式 2: 饿汉式实现方式。

  3. 方式 3: 懒汉式实现方式。

  4. 方式 4: 双重 DCL 校验模式。这种用的最为广泛!

  5. 方式 5: 静态内部类方式。

  6. 方式 6: 枚举方式单例。

  7. 方式 7: 容器实现单例模式。


有什么优点:1.提供全局访问【共享】;2.只有一个对象【对于高频率比较好】;3.使用简单。缺点也很明显:1.拓展难;2.指责不清晰;3.滥用单例导致对象状态丢失。


04.单例模式有那些不友好


单例对 OOP 不友好。单例违背了面向对象设计思想,不能搞封装,继承,多态等。如果强行实现面向对象,则会让人感到奇怪!


对代码类之间的依赖和可读性要注意。避免单例中内容太过于庞大,代码逻辑复杂导致维护比较难。


对代码拓展不友好。对可测试不够友好。不支持有参数的构造。

5.3 更多内容推荐


23 种设计模式



发布于: 1 小时前阅读数: 6
用户头像

杨充

关注

还未添加个人签名 2018-07-30 加入

还未添加个人简介

评论

发布
暂无评论
01.单例模式设计思想_杨充_InfoQ写作社区