在多线程编程中,我们经常需要延迟初始化(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()
的线程,无论实例是否已创建,都得排队等待获取锁。在高并发场景下,这里会成为一个巨大的性能瓶颈,大量的线程都在做无意义的等待。
为了解决上述问题,前辈们想出了一个更巧妙的办法——双重检查锁定。它的核心思想是:只有在实例未被创建时才进行同步,一旦创建成功,就再也不用锁了。
直接上代码:
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;
}
}
这个实现看起来复杂了一些,但逻辑非常清晰:
if (instance == null)
。这是一个无锁的读操作。如果实例已经存在,线程就直接返回,完全避免了锁的开销。这是 DCL 高性能的关键。instance
为 null
时,线程才会尝试进入 synchronized
代码块。这确保了同一时间只有一个线程能执行实例的创建逻辑。if (instance == null)
。这是 DCL 的精髓所在。它防止了多个线程在第一次检查都通过后,重复创建实例。你可能会问,既然外面已经检查过一次了,为什么进了同步块还要再检查一次?
想象一下这个场景:线程 A 和 B 同时执行到第一次检查,都发现 instance
是 null
。它们都想进入同步块,假设线程 A 抢到了锁,进入代码块,创建了实例,然后释放锁。此时线程 B 拿到了锁,如果同步块里没有第二层检查,线程 B 就会毫不知情地再次创建一个新的实例,这就破坏了单例的初衷。第二次检查正是为了防止这种情况发生。
下面这个流程图能帮你更好地理解这个过程:
volatile
到底在干嘛?在 DCL 的实现中,volatile
关键字是绝对不能少的。如果少了它,看似正常的代码在多线程环境下可能会出现致命问题。这就要提到 JVM 的指令重排序了。
instance = new Singleton()
这行代码,在我们看来是一步操作,但在 JVM 内部,它大致分为三个步骤:
Singleton
对象分配一块内存空间。Singleton
的构造函数,对对象进行初始化。instance
引用指向分配好的内存地址。正常情况下,顺序是 1 -> 2 -> 3
。但为了性能优化,JVM 可能会对指令进行重排序,把顺序变成 1 -> 3 -> 2
。
这时候问题就来了:
instance = new Singleton()
。instance
引用被赋值,不再是 null
。getInstance()
,执行第一次检查 if (instance == null)
。它会发现 instance
已经不是 null
了,于是直接返回 instance
。instance
是一个未完全初始化的对象。如果此时去使用这个对象,就可能引发各种诡异的错误。而 volatile
关键字有两大作用:
instance = new Singleton()
的操作按照 1 -> 2 -> 3
的顺序执行,不会出现上面那种"半成品"对象的情况。instance
的值,这个新值会立刻对其他线程可见。所以,volatile
是确保 DCL 线程安全的最后一道,也是最关键的一道防线。
当然有!DCL 虽然高效,但写法相对复杂,容易出错。在现代 Java 中,我们有更简洁、更安全的实现方式。
这是目前最受推荐的单例实现方式之一。它利用了 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 不仅是为了在面试中脱颖而出,更是为了加深我们对并发编程底层原理的理解。