掘金 后端 ( ) • 2024-04-02 09:43

theme: orange

单例设计模式(Singleton Pattern)是一种常用的软件设计模式,它的目的是确保一个类只有一个实例,并提供一个全局访问点来获取这个实例。这种模式通常用于管理共享资源,比如数据库连接或文件系统操作,确保这些资源不会被重复创建,从而节省系统资源并避免潜在的错误。

实现思路

  1. 私有构造函数:确保外部无法通过 new 关键字创建类的实例。
  2. 静态实例变量:保存类的唯一实例。
  3. 公有静态方法:提供一个全局访问点,用于获取这个唯一实例。

5种实现方式

1. 饿汉式(Eager Initialization)

  • 原理:在类加载时就立即初始化并创建单例对象。
  • 线程安全:由于实例在类加载时就已经创建,因此不存在线程安全问题。
  • 缺点:不管是否使用该实例,都会在类加载时就创建,可能会造成资源浪费。
public class Singleton {
    // 类加载时就初始化一个实例
    private static final Singleton instance = new Singleton();
    // 私有构造函数,防止被实例化
    private Singleton() {}
    // 提供一个全局访问点
    public static Singleton getInstance() {
        return instance;
    }
}

2. 懒汉式(Lazy Initialization)

  • 原理:延迟创建对象,第一次调用getInstance()方法时再创建对象。
  • 线程不安全:在多线程环境下,如果没有适当的同步机制,可能会创建多个实例。这是因为在多个线程同时首次调用 getInstance() 方法时,都可能会进入 if (instance == null) 判断,从而创建多个实例。
  • 同步方法:为了解决线程不安全的问题,可以在 getInstance() 方法上添加 synchronized 关键字,确保同一时间只有一个线程可以执行这个方法。但这会带来性能开销,因为每次调用 getInstance() 都需要进行同步。
public class Singleton {
    private static Singleton instance;
    private Singleton() {}
    // 同步方法确保线程安全
    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

3. 双重校验锁(Double-Checked Locking)

  • 双重校验:这种方法通过在getInstance()方法中进行两次检查instance == null,来确保实例只被创建一次。第一次检查是为了避免每次调用getInstance()方法时都竞争同步锁,第二次检查是为了确保在多线程环境下不会创建多个实例。

    • 在懒汉式中,所有线程不管三七二十一都先去竞争同步锁,等抢到锁之后才去判断是否需要创建唯一实例(即判断instance == null是否为True)。而在双重校验锁中,先检查实例是否已创建(第一次校验),未创建时再去竞争同步锁。
    • 第一次校验并没有在同步代码块中,因此可能有多个线程在第一次校验时都判断为True,这些线程都将参与同步锁的竞争并且都有机会执行同步代码块中的内容。为了避免创建唯一实例的语句instance = new Singleton()被多次执行,因此进入同步代码块之后,需要先检查实例是否已创建(第二次校验),未创建时再创建。
  • 避免指令重排:在下面的代码中instance被声明为volatilevolatile变量的写操作先于读操作,这可以防止指令重排。在创建对象时(instance = new Singleton();),这行代码可以分解为以下三个步骤:

    1. 分配内存空间。
    2. 初始化对象。
    3. instance变量指向分配的内存地址。

    如果没有volatile关键字,这些步骤可能会被重排。例如,步骤2和步骤3可能会被重排,这样在对象未完全构造完成之前,instance变量可能就已经不是null了。这意味着另一个线程可能会看到一个非null但未完全构造的instance对象,从而导致不可预料的行为。

    使用volatile关键字可以防止这种重排,确保在对象构造完成并且instance变量被赋值后,其他线程才能看到这个更新。因此,在双重校验锁中,volatile关键字是至关重要的,它保证了多线程环境下的可见性和有序性。

public class Singleton {
    private static volatile Singleton instance; // 将instance声明为volatile变量,防止指令重排
    private Singleton() {}
    public static Singleton getInstance() {
        // 第一次校验:检查实例是否已创建,未创建时再加锁创建。
        if (instance == null) {
            synchronized (Singleton.class) {
                // 第二次校验:检查实例是否已创建,未创建时再创建
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

4. 静态内部类(Static Inner Class)

  • 内部静态类Singleton 类中定义了一个静态内部类 SingletonHolder
  • 懒汉式加载SingletonHolder 类中持有一个 Singleton 类的静态实例。这个实例在 SingletonHolder 类被加载时创建,而 SingletonHolder 类的加载是在调用 getInstance() 方法时触发的。
  • 线程安全:Java 虚拟机(JVM)保证了类的加载是线程安全的,所以这种方式既能实现延迟加载,又能保证线程安全,而无需使用同步。
  • 无需同步:这种方法既实现了延迟加载,又避免了同步机制带来的性能影响。
public class Singleton {
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    private Singleton() {}
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

5. 枚举(Enum)

  • 简洁:枚举提供了一种非常简洁的单例实现方式。使用枚举时,可以直接通过 Singleton.INSTANCE 来访问实例。
  • 线程安全:由 JVM 保证枚举实例的创建是线程安全的。
  • 防止反射攻击:由于枚举类型的特殊性,它不能通过反射来实例化。
  • 防止反序列化问题:Java 枚举类型自动支持序列化机制,防止反序列化时创建新的实例。
public enum Singleton {
    INSTANCE;
    public void doSomething() {
        // 执行操作
    }
}

枚举类实例的创建过程: 枚举类实例的创建过程是Java编译器和虚拟机共同作用的结果,这个过程比较特殊,因为枚举实例的创建和普通类的实例创建有所不同。下面是枚举类实例创建的详细过程:

  1. 编译期处理

在编译期,Java编译器会为枚举类型生成一个相应的类文件。这个类文件继承自 java.lang.Enum。例如,如果你定义了一个枚举 Day,编译器会生成一个名为 Day.class 的文件,这个类将扩展 Enum 类。

  1. 实例初始化

枚举中的每个实例都被视为该枚举类的静态常量。在类加载期间,随着枚举类的初始化,枚举实例也会被初始化。这意味着枚举实例的创建是线程安全的,因为类初始化是同步的。

  1. 构造函数调用

每个枚举实例在初始化时都会调用其类的构造函数。枚举的构造函数总是私有的,这是因为枚举实例应该在枚举定义中完全指定,而不应该通过公有的构造函数在外部创建。

  1. 添加到静态字段

枚举实例在初始化后,会被添加到枚举类的静态字段中。例如,如果你有一个枚举 Day,它包含 MONDAY, TUESDAY, ... 等实例,这些实例在内部被存储为一个静态数组,这个数组是枚举类的一个私有静态字段。

  1. 序列化处理

Java枚举实例是可序列化的。当枚举实例被序列化时,仅仅是将枚举的名称序列化,而不是整个实例。当反序列化时,会通过名称获取对应的枚举实例,而不是创建一个新的实例。

  1. 枚举的switch支持

Java还为枚举提供了特殊的支持,允许在 switch 语句中使用枚举,提高了代码的可读性和安全性。

5种实现方式的线程安全性分析

  • 饿汉式:线程安全,因为实例在类加载时就已经创建。
  • 懒汉式:线程不安全,在多线程环境下可能会创建多个实例。(可以将getInstance()方法设为synchronized来保证线程安全)
  • 双重校验锁:线程安全,通过同步锁和volatile关键字确保实例的唯一性。
  • 静态内部类:线程安全,利用类加载机制保证实例在多线程环境下的唯一性。
  • 枚举:线程安全,由JVM保证。

如何选择用那种实现方式?

每种方法都有其适用场景和优缺点。例如,饿汉式适用于实例在应用程序生命周期内总是需要的情况,而懒汉式适用于实例使用不是很频繁的情况。双重校验锁和静态内部类提供了延迟初始化的同时保证了线程安全。枚举方法是最简单也是最高效的一种实现方式。选择哪种方法取决于你的具体需求和场景。