设计模式深入解析----单例模式

950 阅读8分钟

最近在学设计模式了,那就从最简单的单例模式开始。

单例模式概述

一、是什么单例模式

抛开 CPU 的缓存不谈,单例的本质是在进程的所分配的内存中仅能存在唯一的一个对象,而单例模式就是采用一种手段或者方法,使得这个对象在内存中是唯一存在的。

从 Java 的层面讲,对象的实例分配在堆区(详见我写的这篇文章,250+的👍),而我们所需要保证的是在这个堆内存区域,通过 Class 所创建的这个对象的全局唯一性。

二、为什么需要单例模式

那么我们为什么需要使用单例模式呢?显而易见的是,单例模式保证了内存中全局的唯一性,避免了对象实例的重复创建,节约了系统资源。但是它的缺点也是比较明显的,没有接口,不能被继承,并且也违反单一职责的原则(一个单例往往使用的业务场景比较多,试想,一个单例负责一个功能,它对系统资源的使用率势必会下降)。

下面说一下单例模式的写法,Java中具体实现。

单例模式的写法

一、饿汉式

首先来看饿汉式,代码如下:

/**
 * @Author: JonesYong
 * @Date: 2021-07-18
 * @Description:
 */
public class HungrySingleton {
    private static final HungrySingleton instance =  new HungrySingleton(); // ----> 注释1
    
    private HungrySingleton(){}  // ----> 注释2

    public static HungrySingleton getInstance() {
        return instance;
    }  // ---- 注释3
}

饿汉模式解释如下:

  • peivate 控制这个对象的访问权限,final 代表这个对象一旦创建就不能修改,static 利用了 JVM 类加载的机制,对象创建完成的时机是在类加载的初始化阶段,并且 instance 这个变量是存储在方法区(元空间)的。
  • 私有化构造函数,使得外部的类无法通过 new 的方式获取。
  • 单例模式需要被使用,就需要提供一个对外的接口。

但是饿汉式的缺点也是非常明显,即便我们没有去使用这个对象,也会在这个类加载的时候去创建这个单例,这是一种对内存资源的浪费。

二、懒汉式

前面说过饿汉式的的缺点是对内存资源的浪费,那么有没有一种机制,能够实现延迟初始化,只有在我们需要的时候进行创建。

有的,下面看懒汉式单例的实现:

/**
 * @Author: JonesYong
 * @Date: 2021-07-18
 * @Description: 懒汉式
 */
public class LazySingleton {

    private static LazySingleton instance;  // ----> 注释1

    private LazySingleton() {
    }

    public static LazySingleton getInstance() {
        if (instance == null) {
            //  ----> 注释2
            instance = new LazySingleton(); // ----> 注释3
        }
        return instance;
    }
}

懒汉单例实现了延迟初始化,只有在 getInstance() 的时候才会创建这样的一个对象实例。但是这是一个线程不安全的单例,在多线程并发的情况下,会出现数据不同步的问题。下面分析:

现在有两个线程,分别为 T1、T2。现假设 T1 走到了 注释2 处,这个阶段单例还没创建出来,但是这时候它的 CPU 时间片已经使用完了。这时候该轮到 T2 执行,T2 执行的时候,成功创建了一个 LazySingleton 的对象实例。在某一个时刻,T1 又获得了 CPU 的执行权,就开始执行 注释3 处的代码,这个时候就又会创建了对象实例,从而破坏了单例。

那有没有一种既能够实现延迟初始化的,又能够保证全局单例的方法呢?下面来看双锁检测(double check)的实现方式。(敲重点!!!)

三、双锁检测(double check)(重点)

面试经常手写单例模式,在五种单例模式,考察 double check 写法频率是最高的。希望能引起大家的格外重视,但是不仅仅是面试,对 double check 的理解程度也往往能看出一个面试者的基础掌握的情况。

下面来看 double check 实现细节:

/**
 * @Author: JonesYong
 * @Date: 2021-07-18
 * @Description: double check
 */
public class DoubleCheckSingleton {
    private static volatile DoubleCheckSingleton instance; // ----> 注释1

    private DoubleCheckSingleton() {
    }

    public static DoubleCheckSingleton getInstance() {
        if (instance == null) {
            // ----> 注释2
            synchronized (DoubleCheckSingleton.class) {
                if (instance == null) { // ----> 注释3
                    instance = new DoubleCheckSingleton();
                }
            }
        }
        return instance;
    }
    
}

如果面试被问到,考察的点大致如下:

  • 为什么要使用 volatile?

  • 为什么要进行 double check?

为什么要使用 volatile呢? 让我们来看一下这行代码: instance = new DoubleCheckSingleton() ,执行完这一行代码可以分成三个步骤:

  • step1:在内存中分配一块空间。
  • step2:对内存空间进行初始化。
  • step3:把对象在内存中的位置指向 instance

如果按照 CPU 或者 JIT 编译器能够按照片正常的指令执行的话,是不需要 volatile,但是 CPU 和 JIT 即时编译器为了能获得性能上的提升,往往会对字节码指令进行重排序,这就会导致 step2 和 step3 执行的顺序颠倒。执行步骤就变成了:

  • step1:在内存中分配一块空间。
  • step3:把对象在内存中的位置指向 instance
  • step2:对内存空间进行初始化。

现在假设有两个线程T1、T2,T1 线程执行完重排序后的 step3 ,CPU 的执行权被 T2 获得。这个时候,instance 已经不为 null 了,他指向了内存中的一块地址。T2 执行到第一个 if 的时候,发现 instance 不为 null,就直接返回,但是这个 instance 并没有被初始化,这就会导致 T2 在执行的过程中发生不可预知的错误。

现在来看第二个问题,为什么要使用 double check?

现假设有两个 T1 和 T2,T1 执行到注释2处,CPU 的执行权被 T2 抢夺走,T2 执行完成之后创建了一个对象实例,并且释放 Java 的类锁。这个时候 T1 又重新获得了 CPU 的执行权,并且获得了类锁。如果没有第二个 if 的判断,T1 又会重新创建一个 实例对象,这样就破坏了单例。

明白了为什么会有第二个 if ,现在来看为什么会有第一个 if?其实不难看懂,现在假设有一个线程 T3 ,如果没有第一个 if,它就会直接尝试获取锁资源。要知道,锁资源是非常宝贵的,如果每个线程一来就直接申请锁资源,而不是先对 instance 进行判断,这势必会对程序的性能造成影响。

四、静态内部类

静态内部类的实质是利用了 JVM 的加载机制,它的本质和饿汉式是一样的。下面来看具体的代码实现:

/**
 * @Author: JonesYong
 * @Date: 2021-07-18
 * @Description: 静态类部类实现单例模式
 */
public class InnerClassSingleton {
    private InnerClassSingleton() {
    }

    public static InnerClassSingleton getInstance() {
        return InnerClassHolder.INSTANCE;
    }

    private static final class InnerClassHolder {
        private static final InnerClassSingleton INSTANCE = new InnerClassSingleton();
    }
}

道理其实是很简单的,作为一个私有的静态内部类,它关闭了对外实例化的接口,而在它的内部,实例化了一个外部类的对象。那么这个 InnerClassSingleton 在什么时候会被实例化呢?只有在它调用 getInstance()的时候才会被初始化,这是不是就实现了延迟初始化呢?

五、枚举单例

写完了上面的四种单例,现在来深入思考一下,上面的单例真的是安全的吗?也就是说难道真的没有办法对它们的单例进行破坏了吗?下面我用 Double Check 来进行验证说明,来看具体的代码实现:

public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
    DoubleCheckSingleton instance = DoubleCheckSingleton.getInstance();

    Class<?> cls = DoubleCheckSingleton.getInstance().getClass();
    DoubleCheckSingleton instance1 = (DoubleCheckSingleton) cls.newInstance();

    System.out.println(instance == instance1);
}

验证代码很简单,控制台输出 false。可以看出反射是可以破坏单例的,那么序列化的方式呢?请看接下来的代码:

public static void main(String[] args) throws IOException, ClassNotFoundException {
    // 1、序列化
    DoubleCheckSingleton singleton = DoubleCheckSingleton.getInstance();
    FileOutputStream fileOutputStream = new FileOutputStream(new File("single.txt"));
    ObjectOutputStream oos = new ObjectOutputStream(fileOutputStream);
    oos.writeObject(singleton);
    
    // 2、反序列化
    FileInputStream inputStream = new FileInputStream("single.txt");
    ObjectInputStream oosInput = new ObjectInputStream(inputStream);
    System.out.println(singleton == oosInput.readObject());
}

通过对 double check 验证,我们知道了它可以通过反射获取到对象实例,说明这也不是一个安全的单例。那么有没有安全的单例了,可以防止被序列化和反射?

有的,可以通过枚举来实现!

最后来看看枚举单例的实现,这是 Effective Java 这本书的作者的推荐写法:

/**
 * @Author: JonesYong
 * @Data: 2021-07-18
 * @Description: 枚举单例
 */
public enum EnumSingleton {
    INSTANCE;

    public static EnumSingleton getInstance() {
        return INSTANCE;
    }
}

为什么序列化和反射的方式无法破坏枚举类型的单例呢?

任何的枚举类都会继承 Enum 这个抽象类,来看一下 Enum 的源码:

截屏2021-07-18 18.45.16.png 同样,在进行反射的过程也会对枚举类型进行判断,如果是枚举类型,就会直接抛出异常。这似乎解决了我们上面的问题,直接抛出异常来阻止反射和序列化的破坏枚举单例。

但是这还没有完,为什么 JVM 不支持对枚举的反射和序列化呢,而是使用抛异常的方式来阻止它?可以猜测一下,既然不支持,也就是说枚举类必然是不同于和他其他的类,那么枚举类的本质是什么呢?

通过 javap 命令可以得到字节码指令,字节码指令如下:(截取部分)

// 1、对枚举累的描述
public final class singleton.EnumSingleton extends java.lang.Enum<singleton.EnumSingleton>
  minor version: 0
  major version: 57
  flags: (0x4031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER, ACC_ENUM
  this_class: #2                          // singleton/EnumSingleton
  super_class: #13                        // java/lang/Enum
  interfaces: 0, fields: 2, methods: 5, attributes: 2
  
// 2、对变量的描述
 public static final singleton.EnumSingleton INSTANCE;
  descriptor: Lsingleton/EnumSingleton;
  flags: (0x4019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL, ACC_ENUM

枚举本质上是一个 final 类型类,它的变量都是 static、final 类型的变量。