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

聊聊双重检查锁定(Double-Checked Locking)这点事
XR在多线程编程中,我们经常需要延迟初始化(Lazy Initialization)某个对象,特别是在实现单例模式时。最简单粗暴的方法当然是直接上 synchronized,但由此带来的性能问题也让我们不得不寻找更优的方案。今天,我们就来深入聊聊大名鼎鼎的双重检查锁定(Double-Checked Locking, DCL),看看它到底牛在哪里,又有哪些坑需要我们注意。
问题在哪?无脑 synchronized 的性能瓶颈
咱们先看一个最直观的单例实现:
1 | public class Singleton { |
代码简单明了,synchronized 关键字确保了 getInstance() 方法在同一时间只会被一个线程执行,从而保证了线程安全。
但问题也随之而来。synchronized 是一把”重锁”,一旦实例被创建之后,实际上我们不再需要任何同步了,因为 instance 不再是 null,后续的所有 if 判断都是 false,直接返回即可。可 synchronized 会让所有调用 getInstance() 的线程,无论实例是否已创建,都得排队等待获取锁。在高并发场景下,这里会成为一个巨大的性能瓶颈,大量的线程都在做无意义的等待。
更聪明的玩法:双重检查锁定(DCL)
为了解决上述问题,前辈们想出了一个更巧妙的办法——双重检查锁定。它的核心思想是:只有在实例未被创建时才进行同步,一旦创建成功,就再也不用锁了。
直接上代码:
1 | public class Singleton { |
这个实现看起来复杂了一些,但逻辑非常清晰:
- 第一次检查(无锁):
if (instance == null)。这是一个无锁的读操作。如果实例已经存在,线程就直接返回,完全避免了锁的开销。这是 DCL 高性能的关键。 - 同步块:只有当
instance为null时,线程才会尝试进入synchronized代码块。这确保了同一时间只有一个线程能执行实例的创建逻辑。 - 第二次检查(有锁):
if (instance == null)。这是 DCL 的精髓所在。它防止了多个线程在第一次检查都通过后,重复创建实例。
你可能会问,既然外面已经检查过一次了,为什么进了同步块还要再检查一次?
想象一下这个场景:线程 A 和 B 同时执行到第一次检查,都发现 instance 是 null。它们都想进入同步块,假设线程 A 抢到了锁,进入代码块,创建了实例,然后释放锁。此时线程 B 拿到了锁,如果同步块里没有第二层检查,线程 B 就会毫不知情地再次创建一个新的实例,这就破坏了单例的初衷。第二次检查正是为了防止这种情况发生。
下面这个流程图能帮你更好地理解这个过程:
graph TD
A[开始] --> B{instance == null?}
B -->|否| G[返回实例]
B -->|是| C[进入同步块]
C --> D{instance == null?}
D -->|否| E[退出同步块]
D -->|是| F[创建新实例]
F --> E
E --> G
灵魂拷问:volatile 到底在干嘛?
在 DCL 的实现中,volatile 关键字是绝对不能少的。如果少了它,看似正常的代码在多线程环境下可能会出现致命问题。这就要提到 JVM 的指令重排序了。
instance = new Singleton() 这行代码,在我们看来是一步操作,但在 JVM 内部,它大致分为三个步骤:
- 分配内存:为
Singleton对象分配一块内存空间。 - 初始化对象:调用
Singleton的构造函数,对对象进行初始化。 - 建立连接:将
instance引用指向分配好的内存地址。
正常情况下,顺序是 1 -> 2 -> 3。但为了性能优化,JVM 可能会对指令进行重排序,把顺序变成 1 -> 3 -> 2。
这时候问题就来了:
- 线程 A 执行
instance = new Singleton()。 - 由于指令重排序,JVM 先执行了步骤 1 和 3,
instance引用被赋值,不再是null。 - 此时,线程 B 调用
getInstance(),执行第一次检查if (instance == null)。它会发现instance已经不是null了,于是直接返回instance。 - 但实际上,线程 A 的步骤 2 (初始化对象) 还没执行完。线程 B 拿到的
instance是一个未完全初始化的对象。如果此时去使用这个对象,就可能引发各种诡异的错误。
而 volatile 关键字有两大作用:
- 禁止指令重排序:确保
instance = new Singleton()的操作按照1 -> 2 -> 3的顺序执行,不会出现上面那种”半成品”对象的情况。 - 保证可见性:当一个线程修改了
instance的值,这个新值会立刻对其他线程可见。
所以,volatile 是确保 DCL 线程安全的最后一道,也是最关键的一道防线。
还有没有更好的选择?
当然有!DCL 虽然高效,但写法相对复杂,容易出错。在现代 Java 中,我们有更简洁、更安全的实现方式。
静态内部类(Lazy Initialization Holder Class)
这是目前最受推荐的单例实现方式之一。它利用了 JVM 类加载机制来保证线程安全。
1 | public class Singleton { |
当 getInstance() 方法第一次被调用时,Holder 类才会被加载,此时 JVM 会保证 INSTANCE 只被初始化一次,并且这个过程是线程安全的。这种方式既实现了懒加载,又无需任何同步锁,代码也更简单。
枚举单例
这是《Effective Java》作者 Joshua Bloch 极力推崇的方式。它不仅写法超级简单,还能天然防止反射和反序列化攻击。
1 | public enum Singleton { |
调用时直接使用 Singleton.INSTANCE 即可。如果你不需要懒加载,这无疑是最佳选择。
总结一下
我们来对比一下这几种方案的优劣:
| 方案 | 优点 | 缺点 |
|---|---|---|
直接 synchronized |
实现简单,绝对线程安全 | 性能差,无论是否需要都会加锁 |
| 双重检查锁定 (DCL) | 性能高,只在首次初始化时加锁 | 写法复杂,必须正确使用 volatile |
| 静态内部类 | 无锁、线程安全、写法简单、懒加载 | 相对DCL代码稍多一点 |
| 枚举单例 | 极简、防反射、防序列化 | 非懒加载 |
总的来说,双重检查锁定(DCL)是一个在特定场景下(例如需要懒加载且追求极致性能)非常经典的解决方案,但我们必须深刻理解其背后的 volatile 和指令重排序原理,才能正确地使用它。
不过,在大多数情况下,静态内部类和枚举通常是更推荐、更安全的选择。作为开发者,了解 DCL 不仅是为了在面试中脱颖而出,更是为了加深我们对并发编程底层原理的理解。











