云计算百科
云计算领域专业知识百科平台

【从0开始学设计模式-4| 单例模式】

在这里插入图片描述

Tip:本篇的代码实现都是基于Java的实现。

而且由于不同语言的特性不同。例如c++跟java的内存管理是不一样的,语法上也略有差别,所以单例模式的实现也有差别。例如C++的饿汉模式可以有"非局部静态实现"、"局部静态实现"等等,如果想了解C++版本的单例模式自行百度即可

概述

对于一个软件系统的某些类而言,我们无须创建多个实例。为了节约系统资源,有时需要确保系统中某个类只有唯一一个实例,当这个唯一实例创建成功之后,我们无法再创建一个同类型的其他对象,所有的操作都只能基于这个唯一实例。为了确保对象的唯一性,我们可以通过单例模式(Singleton Pattern) 来实现,这就是单例模式的动机所在。

定义:确保某一个类只有一个实例,并提供一个全局的访问点来访问这个实例。

结构

在这里插入图片描述

只包含一个单例角色:

  • Singleton(单例): 在单例类的内部实现只生成一个实例,同时它提供一个静态的getInstance()工厂方法,让客户可以访问它的唯一实例;为了防止在外部对其实例化,将其构造函数设计为私有;在单例类内部定义了一个Singleton类型的静态对象,作为外部共享的唯一实例。

实现

饿汉模式

饿汉模式简单来说就是提前准备好这个单例,不管你之后用不用。

这种方式最简单,也没有并发问题和效率问题,但是在类加载时就初始化,有些浪费内存,因为有可能这个方法自始至终都不会被调用到,尤其是在一些对外提供的工具包或 API 时应该尽量避免这种方式。

Java中,最简洁、最现代的饿汉式实现方式,就是枚举单例。这种方式利用了利用了类加载机制以及JVM机制,帮助我们解决了线程安全问题以及拦截反射问题,下面看代码:

  • 枚举单例类

    public enum SingletonEnum {
    /***/
    INSTANCE;//枚举常量 public static final类型
    }

  • 测试类

    public class ReflectInjectionEnum {
    /*
    Lombok 库提供的一个“黑科技”注解
    能让你在不写 try-catch 或 throws 声明的情况下,抛出受检异常(Checked Exceptions)。
    让你的方法看起来干净简洁
    */

    @SneakyThrows
    public static void main(String[] args) throws Exception {
    //获取到类定义:获取 SingletonEnum 对应的 Class 对象,是反射的入口,包含了类的所有结构信息
    Class x = SingletonEnum.class;

    //获取构造器:获取枚举类定义的第一个构造函数。
    //注意:枚举默认有一个隐藏的私有构造函数 (String name, int ordinal)。
    Constructor constructor = x.getDeclaredConstructors()[0];

    //暴力破解权限:强行将私有的构造函数设置为可访问,这是反射攻击的标配
    constructor.setAccessible(true);

    //尝试调用构造函数创建一个全新的对象。
    SingletonEnum o = (SingletonEnum) constructor.newInstance();

    //获取单例
    SingletonEnum instance = SingletonEnum.INSTANCE;
    //比较单例与初始化的实例
    if (instance == o) {
    System.out.println("相同的实例");
    } else {
    System.out.println("不同的实例");
    }
    }
    }

  • 运行结果

    在这里插入图片描述

解释
  • 枚举类怎么保证了单例的实现?

    • 枚举类SingletonEnum会变成public final class SingletonEnum extends Enum
    • 枚举类里面的成员在编译阶段会变成public static final这种类型的变量
    • 而且会自动生成一个 static {} 静态代码块,里面包含了 INSTANCE = new SingletonEnum();
  • 类加载机制怎么保证了唯一实例以及多线程安全?

    • 编译并不触发类加载,只有当运行的时候,且有人第一次碰这个类,它才加载初始化。
    • 当程序第一次执行到 SingletonEnum.INSTANCE 时,JVM 才会触发类加载:
      • JVM读取.class字节码文件,然后为静态变量分配内存并设置默认的初始值
      • 然后执行那个隐藏的 static {} 静态代码块,JVM内部有一把初始化锁,只有一个线程可以执行 new SingletonEnum(),其他线程会阻塞等待。等到初始化完成之后,其他线程再想获取INSTANCE这个对象,就会发现以及初始化完毕了!
      • 这样就保证了INSTANCE对象只有一个,且创建过程绝对线程安全。
  • JVM怎么做到了拦截反射?

    • 我们的例子中,constructor.newInstance() 被调用是反射创建对象的尝试。

    • 执行 newInstance() 时,JVM 内部会进行一次判定:检查该类的修饰符是否包含 ENUM 标志位。

    • 由于 SingletonEnum 带有枚举标志,JVM不会执行,并抛出 IllegalArgumentException。

      // java.lang.reflect.Constructor 源码片段
      if ((clazz.getModifiers() & Modifier.ENUM) != 0)
      throw new IllegalArgumentException("Cannot reflectively create enum objects");

    • 所以才会有上面那样的运行结果,这成功拦截了反射,保证了安全。

  • 懒汉模式

    懒汉模式是在第一次使用单例对象时才完成初始化工作。避免了内存浪费和启动缓慢。但是此时可能存在多线程竞态环境,如不加锁限制会导致重复构造或构造不完全问题。

    双重检查锁 配合 volatile关键字(DCL版本)

    单例类实现的代码如下:

    public class Singleton {
    private static volatile Singleton instance;
    //构造函数
    private Singleton(){
    /*
    防止有人通过反射调用构造函数
    即便你通过反射绕过了 private,但是只要 instance 已经被赋值,第二次尝试进入构造函数都会抛出异常。
    */

    synchronized (Singleton.class) {
    if (instance != null) {
    throw new RuntimeException("单例对象已经实例化了");
    }
    //将初始化对象赋值给静态变量
    Singleton.instance = this;
    }
    }
    //getInstance()方法
    public static Singleton getInstance() {
    if(instance == null){//提高性能
    /*
    指向的是该类在 JVM 中的 Class 对象。
    由于在一个类加载器下,每个类只有一个 Class 对象,因此 类锁是全局唯一的
    使有成千上万个线程同时访问,只要它们执行到这一行,都必须竞争这把唯一的“类钥匙”。
    */

    synchronized (Singleton.class){//类锁,保证并发安全
    if (instance == null){
    instance = new Singleton();
    }
    }
    }
    return instance;
    }
    }

    先解释一下这个类

    • volatile关键字,volatile 提供了两个核心特性:可见性 和 禁止指令重排,这正是要使用这个关键字的原因
      • 可见性:多线程环境下,每个线程都有自己的工作内存。当一个线程修改了某个变量,其他线程不一定会立刻看到。
        • 有了这个关键字,例如现在有两个线程A、B,一旦线程 A 修改了 instance,JVM 会强制要求该值立即刷入主内存,并且让其他线程缓存的该变量失效。这样就不会出现当线程 A 修改了 instance,线程 B 可能还在读自己缓存里的旧值 null,导致线程 B 又去创建了一个实例的情况了
      • 禁止指令重排:看一句代码的实现,如instance = new Singleton();这行代码。虽然在 Java 里这是一行代码,但在 JVM 底层它实际上分成了 3 步:
        • 分配内存空间、初始化对象、将引用指向内存空间
        • 编译器或 CPU 为了优化性能,可能会把顺序变成 1 -> 3 -> 2。
        • 如果执行到3了,还没有执行2,但是此时引用instance 已经指向了分配的那块内存空间了!此时就会出问题,如果此时刚好有另一个线程执行到getInstance() 的第一个判断 if (instance == null),发现它不为 null,于是直接返回了 instance。此时的instance是一个没有执行步骤2的"半成品"
    • synchronized (Singleton.class),类锁 -> 保证线程安全
      • 类锁是全局唯一的,影响访问该类所有实例,不同于对象锁synchronized(this),对象锁只影响访问同一个实例的线程

    再看测试类,通过测试类来明白单例类的流程是什么样子的

    先看一下"先反射创建对象,然后获取单例对象"这种情况

    public class ReflectInjection {
    @SneakyThrows//用于抛异常的注解
    public static void main(String[] args) throws Exception {
    //获取到类定义
    Class x = Singleton.class;
    //获取构造器
    Constructor constructor = x.getDeclaredConstructors()[0];
    //设置可以访问
    constructor.setAccessible(true);
    Singleton o = (Singleton) constructor.newInstance();
    //获取单例
    Singleton instance = Singleton.getInstance();
    //比较单例与初始化的实例
    if (instance == o) {
    System.out.println("相同的实例");
    } else {
    System.out.println("不同的实例");
    }
    }
    }

    此时结果正常输出

    在这里插入图片描述

    解释

    • 反射创建对象newInstance(),进入 Singleton 的构造函数
    • 碰到synchronized(Singleton.class) ,发现类锁未被拿走,执行 instance = this。
    • 反射出来的对象 o 被赋值给了静态变量 instance。
    • 后续获取单例的时候,执行getInstance,此时发现instance不是null,直接返回反射创建的对象。

    再看一下先获取单例对象,然后反射创建对象这种情况

    public class ReflectInjection {
    @SneakyThrows
    public static void main(String[] args) throws Exception {
    //获取单例
    Singleton instance = Singleton.getInstance();

    //获取到类定义
    Class x = Singleton.class;
    //获取构造器
    Constructor constructor = x.getDeclaredConstructors()[0];
    //设置可以访问
    constructor.setAccessible(true);
    Singleton o = (Singleton) constructor.newInstance();

    //比较单例与初始化的实例
    if (instance == o) {
    System.out.println("相同的实例");
    } else {
    System.out.println("不同的实例");
    }
    }
    }

    在这里插入图片描述

    解释

    • 获取单例 (getInstance),此时判断instance是null,然后获取到类锁。
    • 执行 new Singleton()。此时会嵌套调用构造函数。
    • 执行构造函数内的 synchronized。注意,此时是同一个线程,由于 Java 的锁是可重入的,它能直接进入。
    • 然后进行赋值:instance = this(这是第一次正式赋值)
    • 返回:getInstance 里的 instance = new Singleton() 再次确认赋值。
    • 然后反射创建 (newInstance):反射强行进入构造函数。
    • 执行构造函数内的 synchronized,反射线程拿到锁。执行判断,但是此时 instance 已经不为 null 了,触发 throw new RuntimeException。

    此时应该能清楚了单例类是怎样的工作流程,现在根据上面的流程解释,进行两个思考:

  • getInstance方法为什么有两次判空,他们分别的作用是什么?

    • 外层判空:过滤已创建实例的请求,提高性能。当单例对象已经创建好后,后续所有的调用都不需要再进入 synchronized 块了。这样可以提升性能

    • 内层判空:防止在多线程并发下重复创建对象。类锁保证了全 JVM 范围内,创建动作是单线程执行的。

      • 例如线程 A 和 线程 B 同时执行,都发现外层 instance 为 null。线程 A 抢到了锁,进去 new 了一个对象。线程 A 释放锁离开。然后此时线程B抢到了锁,如果没有这层内层判空,线程 B会再new一个对象,这样会使得单例失效。
  • 构造函数判空的作用是什么?

    • 作用是手动逻辑进行反射拦截,不同于枚举的 JVM 自动拦截,DCL 模式需要在构造函数中手动加入防御代码。当我们已经先使用getInstance创建了单例对象之后,如果后续再有人使用反射尝试使用构造函数创建对象,就会被判空逻辑拦截,抛出异常!避免了非法对象的产生!
  • 静态内部类单例( Holder 模式)

    这是一种利用 JVM 类加载机制 来巧妙实现懒汉模式的方案。

    在保证延迟加载的同时,利用 JVM 底层机制实现了高性能的线程安全,避免了上一种方法中复杂的锁逻辑。

    一句话:使用静态内部类创建单例对象

    代码如下:

    public class Singleton {
    // 构造器
    private Singleton() {
    /*
    * 跟DCL版本一样的防御反射攻击手段
    */

    synchronized (Singleton.class) {
    if (SingletonHandler.singleton != null) {
    throw new RuntimeException("单例对象已经实例化了,非法访问!");
    }
    }
    }

    // 内部类里面创建了一个静态的对象
    private static class SingletonHandler {
    private static final Singleton singleton = new Singleton();
    }

    // 对外方法
    public static Singleton getInstance() {
    return SingletonHandler.singleton;
    }
    }

    解释

    • 在外部类 Singleton 中定义一个私有的静态内部类 SingletonHandler。只有在显式调用 getInstance() 方法时,才会触发内部类的加载,进而初始化其静态常量 singleton。

      你调用 Singleton.class 或者访问外部类的其他静态变量时,Singleton 类会被加载,但 SingletonHandler 并不会被加载。

      只有当你第一次调用 Singleton.getInstance() 时,代码执行到了 return SingletonHandler.singleton;。此时才会触发 SingletonHandler 的类加载程序。

    • 利用 JVM 保证线程安全:

      • 与枚举单例类似,JVM 在执行这个内部类的初始化时会加锁。
      • 如果有多个线程同时尝试触发内部类的加载,JVM 会确保只有一个线程去执行 new Singleton(),其他线程则阻塞等待直到初始化完成。这种线程安全性是由 JVM 原生保障的,比 DCL 的手动加锁性能更高。
    • 手动进行反射拦截

      • 与 DCL 模式一样,静态内部类本质上还是通过构造函数创建对象的。
      • 所以为了避免使用反射来利用构造函数创建对象,需要在构造函数进行判空逻辑

    使用环境

    单例模式作为一种目标明确、结构简单、理解容易的设计模式,在软件开发中使用频率相当高,在很多应用软件和框架中都得以广泛应用。

    主要优点

    • 单例模式提供了对唯一实例的受控访问。因为单例类封装了它的唯一实例,所以它可以严格控制客户怎样以及何时访问它。
    • 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象单例模式无疑可以提高系统的性能。
    • 允许可变数目的实例(多例模式)。基于单例模式我们可以进行扩展,使用与单例控制相似的方法来获得指定个数的对象实例,既节省系统资源,又解决了单例对象共享过多有损性能的问题。

    主要缺点

    • 由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。
    • 单例类的职责过重,在一定程度上违背了“单一职责原则”。因为单例类既充当了工厂角色,提供了工厂方法,同时又充当了产品角色,包含一些业务方法,将产品的创建和产品的本身的功能融合到一起。
    • 现在很多面向对象语言(如Java、C#)的运行环境都提供了自动垃圾回收的技术,因此,如果实例化的共享对象长时间不被利用,系统会认为它是垃圾,会自动销毁并回收资源,下次利用时又将重新实例化,这将导致共享的单例对象状态的丢失。

    适用环境

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

    • 系统只需要一个实例对象,如系统要求提供一个唯一的序列号生成器或资源管理器,或者需要考虑资源消耗太大而只允许创建一个对象。
    • 客户调用类的单个实例只允许使用一个公共访问点,除了该公共访问点,不能通过其他途径访问该实例。

    如果这篇文章对你有帮助,欢迎点赞、评论、关注、收藏。你们的支持是我前进的动力!

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【从0开始学设计模式-4| 单例模式】
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!