聊聊双重检查锁定(Double-Checked Locking)

在多线程编程中,我们经常需要延迟初始化(Lazy Initialization)某个对象,特别是在实现单例模式时。最简单粗暴的方法当然是直接上 synchronized,但由此带来的性能问题也让我们不得不寻找更优的方案。今天,我们就来深入聊聊大名鼎鼎的双重检查锁定(Double-Checked Locking, DCL),看看它到底牛在哪里,又有哪些坑需要我们注意。

问题在哪?无脑 synchronized 的性能瓶颈

咱们先看一个最直观的单例实现:

public class Singleton {
    private static Singleton instance;

    // 直接在方法上加锁
    public synchronized static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

代码简单明了,synchronized 关键字确保了 getInstance() 方法在同一时间只会被一个线程执行,从而保证了线程安全。

但问题也随之而来。synchronized 是一把"重锁",一旦实例被创建之后,实际上我们不再需要任何同步了,因为 instance 不再是 null,后续的所有 if 判断都是 false,直接返回即可。可 synchronized 会让所有调用 getInstance() 的线程,无论实例是否已创建,都得排队等待获取锁。在高并发场景下,这里会成为一个巨大的性能瓶颈,大量的线程都在做无意义的等待。

更聪明的玩法:双重检查锁定(DCL)

为了解决上述问题,前辈们想出了一个更巧妙的办法——双重检查锁定。它的核心思想是:只有在实例未被创建时才进行同步,一旦创建成功,就再也不用锁了。

直接上代码:

public class Singleton {
    // 关键点1: volatile
    private static volatile Singleton instance;

    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 高性能的关键。
  2. 同步块:只有当 instancenull 时,线程才会尝试进入 synchronized 代码块。这确保了同一时间只有一个线程能执行实例的创建逻辑。
  3. 第二次检查(有锁)if (instance == null)。这是 DCL 的精髓所在。它防止了多个线程在第一次检查都通过后,重复创建实例。

你可能会问,既然外面已经检查过一次了,为什么进了同步块还要再检查一次?

想象一下这个场景:线程 A 和 B 同时执行到第一次检查,都发现 instancenull。它们都想进入同步块,假设线程 A 抢到了锁,进入代码块,创建了实例,然后释放锁。此时线程 B 拿到了锁,如果同步块里没有第二层检查,线程 B 就会毫不知情地再次创建一个新的实例,这就破坏了单例的初衷。第二次检查正是为了防止这种情况发生。

下面这个流程图能帮你更好地理解这个过程:

开始
instance == null?
返回实例
进入同步块
instance == null?
退出同步块
创建新实例

灵魂拷问:volatile 到底在干嘛?

在 DCL 的实现中,volatile 关键字是绝对不能少的。如果少了它,看似正常的代码在多线程环境下可能会出现致命问题。这就要提到 JVM 的指令重排序了。

instance = new Singleton() 这行代码,在我们看来是一步操作,但在 JVM 内部,它大致分为三个步骤:

  1. 分配内存:为 Singleton 对象分配一块内存空间。
  2. 初始化对象:调用 Singleton 的构造函数,对对象进行初始化。
  3. 建立连接:将 instance 引用指向分配好的内存地址。

正常情况下,顺序是 1 -> 2 -> 3。但为了性能优化,JVM 可能会对指令进行重排序,把顺序变成 1 -> 3 -> 2

这时候问题就来了:

  1. 线程 A 执行 instance = new Singleton()
  2. 由于指令重排序,JVM 先执行了步骤 1 和 3,instance 引用被赋值,不再是 null
  3. 此时,线程 B 调用 getInstance(),执行第一次检查 if (instance == null)。它会发现 instance 已经不是 null 了,于是直接返回 instance
  4. 但实际上,线程 A 的步骤 2 (初始化对象) 还没执行完。线程 B 拿到的 instance 是一个未完全初始化的对象。如果此时去使用这个对象,就可能引发各种诡异的错误。

volatile 关键字有两大作用:

  1. 禁止指令重排序:确保 instance = new Singleton() 的操作按照 1 -> 2 -> 3 的顺序执行,不会出现上面那种"半成品"对象的情况。
  2. 保证可见性:当一个线程修改了 instance 的值,这个新值会立刻对其他线程可见。

所以,volatile 是确保 DCL 线程安全的最后一道,也是最关键的一道防线。

还有没有更好的选择?

当然有!DCL 虽然高效,但写法相对复杂,容易出错。在现代 Java 中,我们有更简洁、更安全的实现方式。

静态内部类(Lazy Initialization Holder Class)

这是目前最受推荐的单例实现方式之一。它利用了 JVM 类加载机制来保证线程安全。

public class Singleton {
    // 私有构造
    private Singleton() {}

    // 静态内部类
    private static class Holder {
        static final Singleton INSTANCE = new Singleton();
    }

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

getInstance() 方法第一次被调用时,Holder 类才会被加载,此时 JVM 会保证 INSTANCE 只被初始化一次,并且这个过程是线程安全的。这种方式既实现了懒加载,又无需任何同步锁,代码也更简单。

枚举单例

这是《Effective Java》作者 Joshua Bloch 极力推崇的方式。它不仅写法超级简单,还能天然防止反射和反序列化攻击。

public enum Singleton {
    INSTANCE;

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

调用时直接使用 Singleton.INSTANCE 即可。如果你不需要懒加载,这无疑是最佳选择。

总结一下

我们来对比一下这几种方案的优劣:

方案 优点 缺点
直接 synchronized 实现简单,绝对线程安全 性能差,无论是否需要都会加锁
双重检查锁定 (DCL) 性能高,只在首次初始化时加锁 写法复杂,必须正确使用 volatile
静态内部类 无锁、线程安全、写法简单、懒加载 相对DCL代码稍多一点
枚举单例 极简、防反射、防序列化 非懒加载

总的来说,双重检查锁定(DCL)是一个在特定场景下(例如需要懒加载且追求极致性能)非常经典的解决方案,但我们必须深刻理解其背后的 volatile 和指令重排序原理,才能正确地使用它。

不过,在大多数情况下,静态内部类枚举通常是更推荐、更安全的选择。作为开发者,了解 DCL 不仅是为了在面试中脱颖而出,更是为了加深我们对并发编程底层原理的理解。

你可能感兴趣的:(聊聊双重检查锁定(Double-Checked Locking))