双重判定锁是什么
在实现单例模式时,我们希望对象只在第一次被需要时才创建(延迟初始化),同时还要保证在多线程环境下只有一个实例被创建(线程安全)。
一个最简单直接的线程安全方法是这样的:
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;
}
}
-
第一次检查 (
if (instance == null)):- 这是DCL的第一个“判定”。
- 它的作用是:如果实例已经创建好了,那么后续的线程调用
getInstance()时,会直接通过这个检查并立即返回已经创建好的实例,完全不会进入synchronized同步块。这极大地提高了性能,避免了绝大多数情况下的同步开销。
-
进入同步块 (
synchronized (Singleton.class)):- 只有当
instance为null时,线程才会尝试进入这个同步代码块。 - 这保证了在任何一个时间点,只有一个线程可以进入内部代码去创建实例。
- 只有当
-
第二次检查 (
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。
出现问题的场景:
- 线程A进入同步块,执行
instance = new Singleton();。 - 由于指令重排序,JVM先执行了步骤1(分配内存)和步骤3(将instance指向内存地址)。此时,
instance引用已经不为null了,但它指向的内存中的对象还没有被初始化(步骤2还没执行)。 - 就在这一瞬间,线程B调用
getInstance()。 - 线程B执行第一次检查
if (instance == null)。它发现instance不为null,于是直接返回instance。 - 但是,线程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() {
// ...
}
}