Skip to content

双重判定锁是什么

在实现单例模式时,我们希望对象只在第一次被需要时才创建(延迟初始化),同时还要保证在多线程环境下只有一个实例被创建(线程安全)。

一个最简单直接的线程安全方法是这样的:

public class Singleton {
    private static Singleton instance;

    private Singleton() {}

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}
  • 说明:这个方法在getInstance()上加了synchronized关键字,确保了线程安全。
  • 问题:这种方法虽然安全,但效率低下。synchronized会给整个方法上锁,意味着无论实例是否已经被创建,每次调用getInstance()都会触发同步,产生不必要的性能开销。实际上,我们只需要在第一次创建实例时进行同步,一旦实例创建完成,后续的读取操作是不需要同步的。

双重判定锁(DCL)正是为了解决这个性能问题而诞生的。它的核心思想是:在加锁前,先检查一次实例是否存在。

双重判定锁(DCL)的实现

下面是DCL的典型实现代码:

public class Singleton {
    // 关键点1: 使用 volatile 关键字修饰实例变量
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        // 关键点2: 第一次检查,不加锁
        if (instance == null) {
            // 关键点3: 进入同步代码块
            synchronized (Singleton.class) {
                // 关键点4: 第二次检查,加锁状态下检查
                if (instance == null) {
                    // 创建实例
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
  1. 第一次检查 (if (instance == null)):

    • 这是DCL的第一个“判定”。
    • 它的作用是:如果实例已经创建好了,那么后续的线程调用getInstance()时,会直接通过这个检查并立即返回已经创建好的实例,完全不会进入synchronized同步块。这极大地提高了性能,避免了绝大多数情况下的同步开销。
  2. 进入同步块 (synchronized (Singleton.class)):

    • 只有当instancenull时,线程才会尝试进入这个同步代码块。
    • 这保证了在任何一个时间点,只有一个线程可以进入内部代码去创建实例。
  3. 第二次检查 (if (instance == null)):

    • 这是DCL的第二个“判定”,也是其精髓所在。
    • 它的作用是:防止多个实例被创建。考虑这样一种情况:线程A和线程B同时通过了第一次检查(因为那时instance确实是null)。线程A先获得了锁,进入了同步块;线程B在外面等待。当线程A创建完实例并释放锁后,线程B获得锁并进入同步块。如果没有这第二次检查,线程B会毫不知情地再次创建一个新的实例,这就违背了单例模式的初衷。有了第二次检查,线程B会发现instance已经不为null了,于是它不会再创建实例,直接退出同步块。

volatile关键字为什么是必需的?

在早期的DCL实现中,很多人忽略了volatile关键字,这导致DCL在某些情况下是失效的。这是一个非常微妙且重要的问题,根源在于Java内存模型中的指令重排序(Instruction Reordering)。

instance = new Singleton(); 这行代码看起来是一个原子操作,但实际上它在JVM中大致被分为三个步骤: 1. memory = allocate(); // 1. 分配对象的内存空间 2. ctorInstance(memory); // 2. 初始化对象(执行构造函数) 3. instance = memory; // 3. 将instance引用指向分配的内存地址

在没有volatile的情况下,JVM为了优化性能,可能会对这三个步骤进行重排序,例如执行顺序可能变成 1 -> 3 -> 2

出现问题的场景:

  1. 线程A进入同步块,执行instance = new Singleton();
  2. 由于指令重排序,JVM先执行了步骤1(分配内存)和步骤3(将instance指向内存地址)。此时,instance引用已经不为null了,但它指向的内存中的对象还没有被初始化(步骤2还没执行)。
  3. 就在这一瞬间,线程B调用getInstance()
  4. 线程B执行第一次检查if (instance == null)。它发现instance不为null,于是直接返回instance
  5. 但是,线程B得到的instance是一个尚未初始化完成的对象,如果此时线程B尝试使用这个对象的任何成员变量或方法,都可能会导致空指针异常或其他不可预见的错误。

volatile的作用:

volatile关键字在这里有两个关键作用: 1. 保证可见性:当一个线程修改了volatile变量的值,新值对其他线程来说是立即可见的。 2. 禁止指令重排序:volatile会提供一个“内存屏障”(Memory Barrier),它能确保在volatile写操作(在这里是instance = memory;)之前的所有操作(包括对象的初始化)都已经完成。它强制1 -> 2 -> 3的顺序执行,从而防止了上面描述的那个问题。

因此,volatile是实现一个正确、可靠的双重判定锁单例模式不可或缺的一部分。

现代实践

双重判定锁是一个聪明的设计,它在保证线程安全的同时,兼顾了性能。但由于其复杂性(尤其是对volatile和指令重排序的依赖),在面试和理论学习中非常重要。

不过,在现代Java编程实践中,有更简单、更安全的实现单例模式的方法,它们利用了JVM类加载机制来保证线程安全,通常更受推荐:

  • 静态内部类(Initialization-on-demand holder idiom):
public class Singleton {
    private Singleton() {}

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

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

这是目前最被推崇的单例实现方式。它既实现了延迟加载,又由JVM保证了线程安全,代码简洁且不会出错。

  • 枚举(Enum):
public enum Singleton {
    INSTANCE;

    public void doSomething() {
        // ...
    }
}