发布于: 2022-10-15最后更新: 2022-10-16字数 3363阅读时长 9 分钟

type
Post
status
Published
date
Oct 15, 2022
slug
the-singleton-design-pattern-of-the-creational-pattern
summary
tags
单例
category
设计模式
icon
password
 

一、为什么要使用到单例

 

1、处理资源访问冲突

 
例:自定义实现了一个往文件中打印日志的 Logger 类
 
在 UserController 和 OrderController 中,我们分别创建两个 Logger 对象。在 Web 容器的 Servlet 多线程环境下,如果两个 Servlet 线程同时分别执行 login() 和 create() 两个函数,并且同时写日志到 log.txt 文件中,那就有可能存在日志信息互相覆盖的情况:
 
 
解决方案一:加锁
 
添加synchronized类锁而非对象锁this!
  • 分布式锁是最常听到的一种解决方案。不过,实现一个安全可靠、无 bug、高性能的分布式锁,并不是件容易的事情。
  • 并发队列(比如 Java 中的 BlockingQueue)也可以解决这个问题:多个线程同时往并发队列里写日志,一个单独的线程负责将并发队列中的数据,写入到日志文件。这种方式实现起来也稍微有点复杂。
 
 
解决方案二:使用单例模式
 
单例模式相对于之前类级别锁的好处是,不用创建那么多 Logger 对象,一方面节省内存空间,另一方面节省系统文件句柄。
 
所有的线程共享使用的同一个 Logger 对象,共享一个 FileWriter 对象,而 FileWriter 本身是对象级别线程安全的,也就避免了多线程情况下写日志会互相覆盖的问题!
 
 

2、表示全局唯一类

 
从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。
 

二、单例多种实现

 
  • 饿汉:指的是没有调用时就已经创建了,饿汉式最重要的是要保证反射和反序列化创建对象
  • 懒汉:调用时才会创建,懒汉式最重要的就是保证多线程的安全性
 

1、饿汉式(静态变量)

 
  • 不支持懒加载,不一定不好,可以避免用到时初始化造成的程序卡顿
  • 饿汉式天生线程安全
    • 创建对象其实就是在静态代码块,类加载阶段完成
 
 
防止反射和反序列化破坏单例:
  • 构造方法抛出异常是防止反射破坏单例
  • readResolve() 是防止反序列化破坏单例
  • Unsafe类(调用的是底层操作系统)可以不走构造方法创建对象,暂时无法预防

2、饿汉式(静态代码块)

 

3、懒汉式(线程安全-同步方法)

 
  • 支持懒加载
  • 同步方法并发性低

4、双检锁懒汉式

 
  • 饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发
  • 双检锁解决了懒汉式并发度低的问题
 
volatile 修饰实例?
为何必须加 volatile:会添加内存屏障防止指令重排序!
  • INSTANCE = new Singleton4() 不是原子的,分成 3 步:创建对象、调用构造、给静态变量赋值,其中后两步可能被指令重排序优化,变成先赋值、再调用构造
  • 如果线程1 先执行了赋值,线程2 执行到第一个 INSTANCE == null 时发现 INSTANCE 已经不为 null,此时就会返回一个未完全构造的对象
 
 

5、内部类懒汉式

 

6、枚举饿汉式

 
也是饿汉式,枚举类内部会有静态代码块,加载阶段就会创建对象!
  • 枚举饿汉式能天然防止反射、反序列化破坏单例
  • 当然还是无法阻止Unsafe创建对象
 
 

三、单例存在的问题

 

1、单例对 OOP 特性的支持不友好

 
下方例子违背了 OOP 的抽象性,业务发生修改,用到的地方都得修改!
 
 
单例对继承、多态特性的支持也不友好。
这里我之所以会用“不友好”这个词,而非“完全不支持”,是因为从理论上来讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。
所以,一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性。
 

2、单例会隐藏类之间的依赖关系

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

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

如果我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。
这样的需求并不少见,例如数据库连接池:
如果我们希望将慢 SQL 与其他 SQL 隔离开来执行。可以在系统中创建两个数据库连接池,慢 SQL 独享一个数据库连接池,其他 SQL 独享另外一个数据库连接池,这样就能避免慢 SQL 影响到其他 SQL 的执行。 单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类

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

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

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

 
解决方案:无意义
  • 单例毕竟是单例,第一次传参单例已然确定,第二次是无效传参
 

四、如何实现线程唯一的单例

 

1、单例模式中的唯一性是线程唯一还是进程唯一

 
进程唯一!
即线程内核线程间都是共享一个实例!
进程间不唯一!
 

2、如何实现线程唯一的单例?

 
通过一个Map即可!Java本身有保证线程唯一的 ThreadLocal 来保证线程唯一!
 
线程唯一即线程间可以不唯一!
  • 使用线程安全的 ConcurrentHashMap 映射线程id 和实例即可
 
 

五、如何实现集群环境下的单例

 
集群唯一?
相当于进程内唯一、进程间也唯一。
 
经典的单例模式是进程内唯一的,如何实现一个进程间也唯一的单例呢?
  • 无法解决问题,那就加一层,加一层外部存储
  • 把这个单例对象序列化并存储到外部共享存储区(比如文件)
  • 进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。
  • 为了保证单例,一个进程在获取到对象之后,需要对对象加锁,在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁。
  • 由于是集群间的访问,加锁需要分布式锁,借助 Redis 等实现即可!
 
 

六、如何实现一个多例模式

 
例一:一个类创建固定个多例
 
 
例二:一个类根据入参不同创建不同实例
 

Loading...
创建型模式之工厂设计模式

创建型模式之工厂设计模式


设计模式之七大设计原则

设计模式之七大设计原则