MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java双重检查锁定单例模式

2021-10-164.8k 阅读

一、单例模式简介

在软件开发中,单例模式是一种常用的设计模式。它的作用是确保一个类仅有一个实例,并提供一个全局访问点。这种模式在许多场景下都非常有用,例如数据库连接池、线程池等,在整个应用程序中只需要一个实例来管理资源,避免资源的重复创建和浪费,同时也能保证数据的一致性。

单例模式的实现方式有多种,常见的包括饿汉式、懒汉式以及本文重点讨论的双重检查锁定(Double - Checked Locking)单例模式。

二、饿汉式单例模式

2.1 饿汉式单例模式代码实现

public class EagerSingleton {
    private static final EagerSingleton instance = new EagerSingleton();

    private EagerSingleton() {
        // 私有构造函数,防止外部实例化
    }

    public static EagerSingleton getInstance() {
        return instance;
    }
}

2.2 饿汉式单例模式特点

  • 优点:这种方式在类加载时就创建了实例,因此不存在线程安全问题。由于实例是在类加载阶段创建的,所以在多线程环境下调用 getInstance() 方法获取实例时,速度非常快,因为不需要额外的同步操作。
  • 缺点:如果这个单例类在整个应用程序中使用频率不高,但是在类加载时就创建了实例,会造成资源的浪费。因为即使这个实例在应用程序运行过程中可能根本不会被用到,它也会在类加载时就被创建。

三、懒汉式单例模式

3.1 懒汉式单例模式代码实现(线程不安全版本)

public class LazySingletonUnsafe {
    private static LazySingletonUnsafe instance;

    private LazySingletonUnsafe() {
        // 私有构造函数,防止外部实例化
    }

    public static LazySingletonUnsafe getInstance() {
        if (instance == null) {
            instance = new LazySingletonUnsafe();
        }
        return instance;
    }
}

3.2 线程不安全分析

在多线程环境下,当多个线程同时调用 getInstance() 方法时,可能会出现多个线程同时判断 instance == null 为真,然后各自创建一个实例,这样就违背了单例模式的原则,即一个类仅有一个实例。

3.3 懒汉式单例模式代码实现(线程安全版本)

public class LazySingletonSafe {
    private static LazySingletonSafe instance;

    private LazySingletonSafe() {
        // 私有构造函数,防止外部实例化
    }

    public static synchronized LazySingletonSafe getInstance() {
        if (instance == null) {
            instance = new LazySingletonSafe();
        }
        return instance;
    }
}

3.4 线程安全版本特点

  • 优点:通过在 getInstance() 方法上添加 synchronized 关键字,确保了在多线程环境下只有一个线程能够进入创建实例的代码块,从而保证了单例的正确性。这种方式实现了延迟加载,只有在真正需要使用实例时才会创建,避免了饿汉式单例模式中可能出现的资源浪费问题。
  • 缺点:由于 synchronized 关键字修饰了整个 getInstance() 方法,每次调用该方法都需要获取锁,这在高并发环境下会严重影响性能。因为锁的竞争会导致线程阻塞,降低系统的吞吐量。

四、双重检查锁定单例模式

4.1 双重检查锁定单例模式代码实现

public class DoubleCheckedLockingSingleton {
    private volatile static DoubleCheckedLockingSingleton instance;

    private DoubleCheckedLockingSingleton() {
        // 私有构造函数,防止外部实例化
    }

    public static DoubleCheckedLockingSingleton getInstance() {
        if (instance == null) {
            synchronized (DoubleCheckedLockingSingleton.class) {
                if (instance == null) {
                    instance = new DoubleCheckedLockingSingleton();
                }
            }
        }
        return instance;
    }
}

4.2 双重检查锁定原理分析

  1. 第一次 if (instance == null) 检查:这一步主要是为了提高性能。在多线程环境下,大部分情况下 instance 已经被创建,通过第一次检查可以避免不必要的锁竞争。只有当 instancenull 时,才会进入同步块。
  2. synchronized (DoubleCheckedLockingSingleton.class) 同步块:当多个线程同时通过第一次 if 检查时,同步块可以确保只有一个线程能够进入创建实例的代码块,从而保证实例的唯一性。
  3. 第二次 if (instance == null) 检查:在进入同步块后,再次检查 instance 是否为 null。这是因为可能有其他线程在当前线程获取锁之前已经创建了实例。如果不进行第二次检查,当前线程在获取锁后会再次创建实例,违背单例原则。

4.3 volatile 关键字的作用

在上述代码中,instance 变量被声明为 volatile。这是因为在 instance = new DoubleCheckedLockingSingleton(); 这行代码中,其实并不是一个原子操作。它大致可以分为以下三步:

  1. 分配内存空间:为 DoubleCheckedLockingSingleton 实例分配内存。
  2. 初始化对象:调用构造函数初始化对象。
  3. 将对象引用赋值给 instance:将初始化好的对象引用赋值给 instance 变量。

在 JVM 的即时编译器中,为了优化性能,可能会对指令进行重排序。在没有 volatile 修饰的情况下,步骤 2 和步骤 3 可能会被重排序。假设线程 A 执行到 instance = new DoubleCheckedLockingSingleton(); 时,先执行了步骤 1 和步骤 3,此时 instance 已经有了一个非 null 的值,但是对象还没有完全初始化。如果此时线程 B 执行 getInstance() 方法,通过第一次 if (instance == null) 检查,发现 instance 不为 null,就直接返回了一个未完全初始化的 instance,这会导致程序出现难以调试的错误。

volatile 关键字可以禁止指令重排序,确保在写操作之前,前面所有的写操作都已经刷新到主内存中,在读取操作之后,后面所有的读操作都可以从主内存中获取最新的值。这样就保证了 instance 在被赋值之前,对象已经完全初始化。

五、双重检查锁定单例模式在不同 Java 版本中的表现

5.1 Java 5.0 之前

在 Java 5.0 之前,由于 JVM 的内存模型存在一些问题,双重检查锁定模式并不能完全保证线程安全。即使使用了 volatile 关键字,也无法阻止指令重排序带来的问题。因此在 Java 5.0 之前,不建议使用双重检查锁定单例模式。

5.2 Java 5.0 及之后

从 Java 5.0 开始,JVM 对内存模型进行了改进,增强了 volatile 关键字的语义,使得双重检查锁定单例模式能够正确工作。volatile 关键字能够保证可见性和禁止指令重排序,从而确保了单例模式在多线程环境下的正确性。

六、双重检查锁定单例模式的应用场景

  1. 数据库连接池:在一个应用程序中,通常只需要一个数据库连接池实例来管理数据库连接。使用双重检查锁定单例模式可以确保在多线程环境下,数据库连接池只有一个实例,并且在需要时才创建,避免了资源的浪费。
  2. 线程池:与数据库连接池类似,线程池在整个应用程序中也只需要一个实例。双重检查锁定单例模式可以保证线程池实例的唯一性,同时实现延迟加载,提高系统的性能。
  3. 日志记录器:在应用程序中,日志记录器通常需要全局唯一,以便在不同的模块中记录日志时使用相同的配置和输出目标。双重检查锁定单例模式可以满足这一需求,并且在高并发环境下也能保证其正确性。

七、双重检查锁定单例模式的优缺点

7.1 优点

  1. 延迟加载:与饿汉式单例模式相比,双重检查锁定单例模式实现了延迟加载,只有在真正需要使用实例时才会创建,避免了资源的浪费。
  2. 高性能:在多线程环境下,通过双重检查和 volatile 关键字的配合,既保证了线程安全,又避免了不必要的锁竞争,提高了系统的性能。在大部分情况下,由于第一次 if 检查可以快速返回已创建的实例,只有在首次创建实例时才会进入同步块,大大减少了锁的使用频率。

7.2 缺点

  1. 代码复杂度:相比饿汉式和简单的懒汉式单例模式,双重检查锁定单例模式的代码更加复杂,需要理解 volatile 关键字的语义以及指令重排序等底层原理,这对于开发人员来说有一定的学习成本。
  2. 兼容性问题:在 Java 5.0 之前,双重检查锁定单例模式不能完全保证线程安全,这限制了它在旧版本 Java 环境中的使用。

八、与其他单例模式实现方式的比较

  1. 与饿汉式单例模式比较:饿汉式单例模式在类加载时就创建实例,不存在线程安全问题,但可能造成资源浪费;双重检查锁定单例模式实现了延迟加载,只有在需要时才创建实例,避免资源浪费,但代码复杂度较高,需要考虑线程安全问题。
  2. 与懒汉式单例模式(线程安全版本)比较:懒汉式(线程安全版本)通过在 getInstance() 方法上加锁来保证线程安全,但在高并发环境下性能较低;双重检查锁定单例模式在保证线程安全的同时,通过双重检查机制减少了锁的竞争,提高了性能。

九、双重检查锁定单例模式的替代方案

  1. 静态内部类单例模式
public class StaticInnerClassSingleton {
    private StaticInnerClassSingleton() {
        // 私有构造函数,防止外部实例化
    }

    private static class SingletonHolder {
        private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
    }

    public static StaticInnerClassSingleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

这种方式利用了类加载机制来保证单例的唯一性。当 StaticInnerClassSingleton 类被加载时,SingletonHolder 类并不会被立即加载。只有当调用 getInstance() 方法时,SingletonHolder 类才会被加载,从而创建 INSTANCE 实例。这种方式既实现了延迟加载,又保证了线程安全,并且代码相对简洁。

  1. 枚举单例模式
public enum EnumSingleton {
    INSTANCE;

    // 可以在这里定义其他方法和属性
    public void doSomething() {
        System.out.println("执行一些操作");
    }
}

枚举单例模式是 Java 中实现单例模式的一种简洁且安全的方式。枚举类型本身是线程安全的,并且在类加载时就会被初始化,保证了实例的唯一性。同时,枚举单例模式还可以防止反序列化创建新的实例,相比其他单例模式实现方式更加健壮。

十、总结双重检查锁定单例模式相关要点

  1. 核心原理:双重检查锁定单例模式通过两次 if 检查和同步块来保证在多线程环境下实例的唯一性,同时实现延迟加载。volatile 关键字用于防止指令重排序,确保对象在被赋值之前已经完全初始化。
  2. 应用场景:适用于需要全局唯一实例且希望实现延迟加载和高性能的场景,如数据库连接池、线程池、日志记录器等。
  3. 优缺点:优点是延迟加载和高性能,缺点是代码复杂度较高以及在旧版本 Java 中存在兼容性问题。
  4. 替代方案:静态内部类单例模式和枚举单例模式是双重检查锁定单例模式的良好替代方案,它们在保证线程安全的同时,具有不同的特点和适用场景,可以根据具体需求选择使用。

在实际开发中,选择合适的单例模式实现方式需要综合考虑应用场景、性能要求、代码复杂度以及兼容性等因素。双重检查锁定单例模式在 Java 5.0 及之后的版本中提供了一种高效且线程安全的单例实现方式,但在使用时需要充分理解其原理和潜在问题,以确保代码的正确性和稳定性。