[关闭]
@Catyee 2021-04-06T12:34:16.000000Z 字数 6193 阅读 569

单例模式

设计模式


一、单例模式

单例模式是创建型模式,指的是要确保一个类在任何情况下都只有一个实例,并提供一个全局访问点。

单例模式的线程安全性是指在多线程环境下是否有可能出现多个实例。

二、单例模式的实现方式

2.1 饿汉式单例

饿汉式单例指的是在类加载的时候(程序启动的时候)就已经初始化出来了,即使程序允许的过程中一次都没有用它,它依然存在在jvm中,这种方式是绝对线程安全的,因为在用户线程还没出现之前就已经创建出来了。
优点就是不用加锁即可实现,执行效率高,缺点是即使完全不使用,也始终占据着内存,浪费了空间。
实现方式:

  1. public class Singleton {
  2. // 1、静态属性,在类加载的时候就已经初始化出来, final保证实例不会被修改
  3. private static final Singleton instance = new Singleton();
  4. // 2、私有构造方法
  5. private Singleton() {}
  6. // 3、全局访问点
  7. public static Singleton getInstance() {
  8. return instance;
  9. }
  10. }

2.2 懒汉式单例

懒汉式单例指的是只有在第一次调用的时候才会生成出来,一旦生成出来就始终存在在jvm中了。
优点自然是在未使用之前不用占据内存了。
不安全的懒汉式单例实现方式:

  1. public class Singleton {
  2. private static Singleton instance = null;
  3. // 私有构造方法
  4. private Singleton() {}
  5. // 全局访问点
  6. public static Singleton getInstance() {
  7. // 如果instance是null,则进行实例化
  8. if (instance == null) {
  9. instance = new Singleton();
  10. }
  11. return instance;
  12. }
  13. }

这段代码在多线程环境的时候可能多个线程同时执行到第10行,发现instance都为null,然后都会创建一个实例,至于最后返回的instance,其实已经不确定是哪个线程创建的实例了。

安全的懒汉式单例实现方式:
既然上面的实现方式并不是线程安全的,那最简单直接的实现思路就是加锁进行并发控制,看下面的代码:

  1. public class Singleton {
  2. private static Singleton instance = null;
  3. // 私有构造器
  4. private Singleton() {}
  5. // 在静态方法上加锁控制
  6. public synchronized static Singleton getInstance() {
  7. if (instance == null) {
  8. instance = new Singleton();
  9. }
  10. return instance;
  11. }
  12. }

上面的代码确实保证了安全性,但是synchronized关键字直接加在了静态方法上,意味只要有一个线程正在方法体内,其它尝试获取单例的线程都会被阻塞,即使单例早已经被创建出来了。也就是说并发控制力度太粗了,本来只需要在创建单例的那一次进行并发控制就可以了,单例已经创建出来之后就不用进行并发控制了,然而现在每次获取单例对象都会造成锁竞争。

我们知道synchronized关键字除了用于方法上,还可以用于代码块,进行更细粒度的并发控制,所以进一步的优化思路就是修改synchornizd的位置,也就是经典的双重检查的单例实现方式:

  1. public class Singleton {
  2. // 1、使用volatile关键字,保证对象的变化能够及时被其它线程所感知,同时防止指令重排序
  3. private static volatile Singleton instance = null;
  4. // 2、私有构造器
  5. private Singleton() {}
  6. // 全局访问点
  7. public static Singleton getInstance() {
  8. // 3、使用synchronized进行锁控制,注意锁对象是Singleton.class对象,是一个全局的锁对象
  9. if (instance == null) {
  10. synchronized(Singleton.class) {
  11. if (instance == null) {
  12. instance = new Singleton();
  13. }
  14. }
  15. }
  16. return instance;
  17. }
  18. }

为什么要进行双重检查
在同步代码块之前先进行判空检查,如果不为空,说明单例已经被创建出来了,直接返回,这个时候都没进入同步代码块,避免没必要的锁竞争。
进入同步代码块之后还要进行一次判空,是为了以下这种情况:A、B线程同时执行到11行,两个线程都发现单例还未创建出来,这个时候就会竞争锁,结果A拿到了锁,B被阻塞了。A进入同步代码块创建一个单例,然后退出同步代码块,这时候B被唤醒进入代码块,如果不进行第二次判空,B同样会创建一个单例,这样就有两个单例对象了,所以一定要进行第二次判空,如果B发现单例已经被创建出来,就直接退出代码块了。

为什么双重检查的时候要使用volatile关键字,而上面将synchronized直接加在方法上就不用使用volatile关键字
关键在于instance = new instance这行代码并不是原子指令,使用javap -c可以直接查看字节码文件,会发现这一行代码实际上是三步:

1、内存分配
2、调用构造器方法,执行初始化
3、将对象引用赋值给变量

这三个指令可能发生重排序,2和3可能变换顺序。假如线程a先执行内存分配,然后就将对象引用赋值给变量了,这个时候还没调用构造器方法进行初始化,这时候另一个线程执行到了第11行,发现instance引用已经有值就直接返回了,但是这时候可能对象并没初始化完成,在调用的时候就会出现问题。(早期的jdk版本比较容易复现,但是1.7之后的jdk已经很难出现这个问题了,但是不妨碍理解)
至于将synchronized直接放在方法上就不需要加volatile了,因为在单例初始化结束之前其它线程都是被阻塞的,而且synchronized关键字也可以保证可见性的语义,不需要volatile来保证,所以就不需要加volatile了。

有没有不加锁的线程安全的单例实现方式呢?
答案是有,也就是内部类的单例实现方式,看如下代码:

  1. public class Singleton {
  2. // 1、私有构造器
  3. private Singleton() {}
  4. // 2、内部类
  5. private static final class Holder {
  6. private static final Singleton instance = new Singleton();
  7. }
  8. // 全局访问点
  9. public static Singleton getInstance() {
  10. return Holder.instance;
  11. }
  12. }

我们知道类并不是在程序启动的时候就会全部加载到jvm中,而是在使用之前进行加载,如果没有使用到就不会加载,而在加载SingleTon这个类的时候会先加载Holder这个内部类,加载Holder这个内部类的时候会进行单例的初始化。这样就巧妙的保证了懒汉式的加载方式,并且是线程安全的,因为在调用getInstance方法之前,单例已经创建出来了。

三、绝对的单例模式

上面这些单例模式在绝大多数时候已经够用了,但在一些特殊情况下仍然有可能打破“单例”的限制。

第一种情况就是使用反射破坏单例:
可以看到上面这些单例实现方式虽然在构造方法上用private进行了限制,无法使用new来新建实例,正常情况下不会出现多个实例,但如果我们使用反射直接调用其构造方法,绕过private的限制,其实仍然可以创建出另外一个实例。看如下代码,执行结果为false:

  1. public static void main(String[] args) throws Exception {
  2. Class<Singleton> clazz = Singleton.class;
  3. // 通过反射获取构造器
  4. Constructor<Singleton> constructor = clazz.getDeclaredConstructor();
  5. // 绕过private限制
  6. constructor.setAccessible(true);
  7. // 实例化一个新的对象
  8. Singleton another = constructor.newInstance();
  9. // ==比较的是引用,执行结果为false,说明不是同一个实例
  10. System.out.println(another == Singleton.getInstance());
  11. }

那如何来限制反射可能带来的破坏呢?对于内部类的单例实现方式中可以在构造函数中进行单例检查, 一旦发现在使用特殊方式创建实例则抛出异常(这种方式并不常见,而且一般不会有人使用反射特地破坏单例):

  1. public class Singleton {
  2. // 1、私有构造器,在构造的时候进行检查,如果已经调用过构造方法创建了单例,则不允许再次创建
  3. private Singleton() {
  4. if (Holder.instance != null) {
  5. throw new RuntimeException("单例模式不允许创建多个实例");
  6. }
  7. }
  8. // 2、内部类
  9. private static final class Holder {
  10. private static final Singleton instance = new Singleton();
  11. }
  12. // 全局访问点
  13. public static Singleton getInstance() {
  14. return Holder.instance;
  15. }
  16. }

第二种情况是使用反序列化破坏单例:
一个单例对象创建好后,有时候需要将对象序列化然后写入磁盘(不推荐,一般不会序列化单例),下次使用时再从磁盘中读取对象并进行反序列化,将其转化为内存对象。反序列化后的对象会重新分配内存,即重新创建。如果序列化的目标对象为单例对象,就违背了单例模式的初衷,相当于破坏了单例。
注意,序列化实际上是保存了把对象的“状态”保存为一组字节,所谓“状态”就是属性及属性的值,但是不包括类的静态变量值。反序列化的时候并没有调用任何构造器,而是直接使用读取的字节将对象还原出来。
看如下代码,执行结果也是false,说明反序列化确实创建了新的对象:

  1. public static void main(String[] args) throws Exception {
  2. // Singleton需要实现Serializable接口来支持序列化
  3. Singleton instance1 = Singleton.getInstance();
  4. // 序列化
  5. try (FileOutputStream fos = new FileOutputStream("/tmp/Singleton.obj");
  6. ObjectOutputStream oos = new ObjectOutputStream(fos)) {
  7. oos.writeObject(instance1);
  8. }
  9. // 反序列化
  10. try (FileInputStream fis = new FileInputStream("/tmp/Singleton.obj");
  11. ObjectInputStream ois = new ObjectInputStream(fis)) {
  12. Singleton instance2 = (Singleton) ois.readObject();
  13. System.out.println(instance1 == instance2);
  14. }
  15. }

如何规避反序列化对单例带来的破坏呢?反序列化的时候会调用readResove方法,这个方法是一个神秘的方法,它是private的,而且并不是继承自Serializable接口的,对这个方法的理解需要深入理解java序列化、反序列化机制,这里不展开讲了。修改过后的单例:

  1. public class Singleton implements Serializable {
  2. // 1、私有构造器
  3. private Singleton() {}
  4. // 2、内部类
  5. private static final class Holder {
  6. private static final Singleton instance = new Singleton();
  7. }
  8. // 全局访问点
  9. public static Singleton getInstance() {
  10. return Holder.instance;
  11. }
  12. // 使用单例对象直接替换掉反序列化得到的对象
  13. private Object readResolve() {
  14. return Holder.instance;
  15. }
  16. }

当从I/O流中读取对象时,readResolve()方法都会被调用到。这里实际上就是用readResolve()中返回的对象直接替换在反序列化过程中创建的对象,而被创建的对象则会被垃圾回收掉。(感觉是不是多此一举?既然反序列化仍然使用原有的对象,那我们为什么还要将单例对象序列化?所以最好的方式就是不序列化单例对象,养成好的编程习惯
通过readResolve()方法其实并不是从根本上解决了问题,可以看到单例仍然实例化了两次,只不过新创建的对象并没有返回而已。

三、注册式的单例

注册式单例模式又称为登记式单例模式,就是将每一个实例都登记到某一个地方,使用唯一的标识获取实例。注册式单例模式有两种:一种为枚举式单例模式,另一种为容器式单例模式。

3.1 枚举式单例

  1. public enum Singleton {
  2. INSTANCE;
  3. public static Singleton getInstance() {
  4. return INSTANCE;
  5. }
  6. // 单例能提供的业务
  7. ...
  8. }

使用反射或序列化都不会破坏枚举式单例的单例特性,至于原因需要看JDK的源码,这里不进行展开,可以看看《Spring5核心原理与30个类手写实战》这本书,书中有对jdk源码的解读。
用反编译工具(Jad工具:https://varaneckas.com/jad/)对枚举式单例进行反编译会发现枚举式单例其实是饿汉式的实现方式,它在静态代码块中对INSTANCE进行了初始化:

  1. static
  2. {
  3. INSTANCE = new Singleton("INSTANCE", 0);
  4. $VALUES = (new Singleton[] {
  5. INSTANCE;
  6. });
  7. }

3.2 容器式的单例

容器式单例模式适用于实例非常多的情况,便于管理。spring中的单例池实际上就是容器式单例的一种体现。容器式单例与其说是一种设计模式,不如说是一种具体的实现方式。

  1. public class Singleton {
  2. private Singleton() {}
  3. private static final Map<String, Object> ioc = new ConcurrentHashMap<>();
  4. public static Object getBean(String className) {
  5. return ioc.computeIfAbsent(className, key -> {
  6. try {
  7. return Class.forName(key).newInstance();
  8. } catch (Exception e) {
  9. throw new RuntimeException(e);
  10. }
  11. });
  12. }
  13. }

四、总结

一般使用单例的场景都不会有谁去用反射去重新构造单例,大多数情况也不会需要将单例序列化和反序列化,所以个人一般推荐双重检查的单例实现方式内部类的单例实现方式

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注