好的,要深入理解DCL(Double-Checked Locking)双重校验锁的原理、问题以及解决方法。
首先,我需要回忆一下单例模式的基本概念,因为DCL通常用于实现单例模式。
单例模式确保一个类只有一个实例,并提供一个全局访问点。
接下来,DCL的目的是为了减少同步的开销。通常,如果直接在getInstance方法上加synchronized关键字,
虽然能保证线程安全,但每次获取实例时都需要同步,这在多线程环境下可能会影响性能。
而DCL通过两次检查实例是否已经创建,来避免每次调用都进行同步,只在第一次创建实例的时候同步。
现在,我需要想一下DCL的标准写法。大概是这样的:
public class Singleton {
private static Singleton instance; // 必须使用 volatile
private Singleton() {} // 私有构造函数
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(避免不必要的同步)
synchronized (Singleton.class) { // 同步块
if (instance == null) { // 第二次检查(确保唯一实例)
instance = new Singleton(); // 实例化
}
}
}
return instance;
}
}
不过,这种写法在早期的Java中存在问题,主要涉及到指令重排序的问题。
因为instance = new Singleton(); 这个操作并不是原子性的,它分为三个步骤:
1. 分配内存空间
2. 初始化对象
3. 将instance指向分配的内存地址
但由于指令重排序的可能,步骤2和步骤3可能会被颠倒,导致其他线程在第一次检查时看到instance不为null,但实际上对象还没有初始化完成,从而访问到一个不完整的对象。
为了解决这个问题,Java 5之后引入了volatile关键字,可以禁止指令重排序,
确保写操作之前的操作不会被重排序到写操作之后。因此,正确的DCL实现需要将instance声明为volatile:
private static volatile Singleton instance;
这样,使用volatile修饰instance变量,就能保证在instance被初始化完成之后,
其他线程才能看到它的值,从而避免了指令重排序带来的问题。
不过,需要确认一下volatile在Java内存模型中的具体作用。volatile关键字主要有两个作用:
1. 可见性:保证变量的修改对所有线程立即可见。
2. 禁止指令重排序:通过插入内存屏障,确保编译器和处理器不会对指令进行重排序。
在DCL中,主要是利用了volatile的第二个特性,即禁止指令重排序,确保对象的初始化在赋值操作之后完成。
另外,我还需要思考其他实现单例模式的方式,
比如静态内部类的方式,或者枚举方式,这些方式是否更简单且线程安全。
静态内部类的实现方式:
public class Singleton {
private Singleton() {}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE; // 类加载时初始化,由 JVM 保证线程安全
}
}
这种方式利用了类加载机制,保证在首次访问Holder.INSTANCE时才会加载Holder类,
从而初始化Singleton实例,由JVM保证线程安全。
这种方法不需要显式的同步,且实现简单,但可能无法应对反射攻击或者序列化的问题。
枚举方式:
public enum Singleton {
INSTANCE;
}
这种方式由JVM保证实例的唯一性和线程安全,且能防止反射和序列化破坏单例,是最推荐的方式,
但可能不够灵活,比如无法继承其他类。
回到DCL,虽然它在某些情况下仍然有用,但需要正确使用volatile关键字。
还需要验证一下如果不使用volatile是否一定会出现问题,以及这种问题发生的条件。
比如,在Java 5之前,DCL确实是不安全的,因为volatile关键字的语义不够严格,
无法完全禁止指令重排序。而在Java 5之后,JSR-133增强了volatile的内存语义,确保了DCL的正确性。
另外,我还需要了解内存屏障的概念,以及volatile如何通过内存屏障来禁止重排序。
比如,在写操作之后插入StoreStore屏障,防止写操作之前的指令被重排序到写操作之后;
在读操作之前插入LoadLoad屏障,防止读操作之后的指令被重排序到读操作之前。
总结一下,DCL的正确实现需要注意以下几点:
1. 使用双重检查来减少同步的开销。
2. 使用volatile关键字来禁止指令重排序,保证实例的可见性和初始化顺序。
3. 确保构造函数是私有的,防止外部实例化。
可能的误区是认为只要两次检查就能保证线程安全,而忽略了指令重排序的问题。
因此,volatile的使用是必不可少的。
最后,我需要思考在实际开发中,是否应该优先选择其他单例实现方式,
如枚举或静态内部类,而不是DCL,以避免潜在的复杂性和错误。
但在某些需要延迟初始化且对性能有较高要求的场景下,DCL仍然是一个合理的选择,只要正确实现。
前言:什么是单例模式
单例即单实例,只实例出来一个对象。
一般在创建一些管理器类 、工具类 的时候,需要用到单例模式,比如JDBCUtil类,我们只需要一个实例即可(多个实例也可以实现功能,但是增加了代码量且降低了性能)。
如何实现单例:
- 将构造方法私有化
- 提供一个全局唯一获取该类实例的方法帮助用户获取类的实例
应用场景:
主要被用于一个全局类的对象 在多个地方被使用并且对象的状态是全局变化的场景下。
单例模式的优点:
单例模式为系统资源的优化提供了很好的思路,频繁创建和销毁对象都会增加系统的资源消耗,而单例模式保障了整个系统只有一个对象能被使用,很好地节约了资源。
单例模式的四类写法:
- 饿汉模式
- 懒汉模式
- 静态内部类
- 双重校验锁
在讲双重校验锁之前先来看一下其他模式
饿汉模式
顾名思义,饿汉模式就是加载类的时候直接new一个对象,后面直接用即可。
饿汉模式指在类中 直接定义全局的静态对象的实例 并初始化,然后提供一个方法获取该实例对象。
java
public class Singleton {
// 使用static修饰,类加载的时候new一个对象
private static Singleton INSTANCE = new Singleton(); // 构造器私有化 private Singleton() {} public static Singleton getInstance() { return INSTANCE; } }

懒汉模式
顾名思义,懒汉模式就是加载类的时候只声明变量,不new对象,后面用到的时候再new对象,然后把对象赋给该变量。
定义一个私有的静态对象 INSTANCE,之所以定义INSTANCE为静态,是因为静态属性或方法是属于类的,能够很好地保障单例对象的唯一性;
然后定义一个静态方法获取该对象,如果对象为null,则 new 一个对象并将其赋值给INSTANCE。
java
public class Singleton {
private static Singleton INSTANCE; // 构造器私有化 private Singleton() {} public static Singleton getInstance() { if (INSTANCE == null) { INSTANCE = new Singleton(); } return INSTANCE; } }

饿汉模式和懒汉模式的区别在于:
饿 汉模式是在类加载时将其实例化的,在饿汉模式下,在Class Loader完成后该类的实例便已经存在于JVM中了,即,在getInstance方法第一次被调用前该实例已经存在了,new对象的操作不在getInstance方法内。
而懒 汉模式在类中只是定义了变量但是并未实例化 ,实例化的过程是在获取单例对象的方法中实现的,即,在getInstance方法第一次被调用后该实例才会被创建 ,new对象的操作在getInstance方法内。
此外注意:
饿汉 模式的实例在类加载的时候已经存在于JVM中了,因此是线程安全的;
懒汉 模式通过第一次调用getInstance才实例化,该方法不是线程安全的(后面讲怎么优化)
静态内部类
静态内部类通过在类中定义一个静态内部类,将对象实例的定义和初始化放在内部类中完成,我们在获取对象时要通过静态内部类调用其单例对象。
之所以这样设计,是因为类的静态内部类在JVM中是唯一的,这就很好地保障了单例对象的唯一性。
静态内部类的单例实现方式同样是线程安全的。
代码如下:
java
public class Singleton {
private static class SingletonHolder { private static final Singleton INSTANCE = new Singleton(); } private Singleton(){} public static final Singleton getInstance(){ return SingletonHolder.INSTANCE; } }

饿汉模式和静态 内部类实现单例模式的优点是写法简单 ,缺点是不适合复杂对象的创建。
对于涉及复杂对象创建的单例模式,比较优雅的实现方式是懒汉模式,
但是懒汉模式是非线程安全的,
下面就讲一下懒汉模式的升级版 ------DCL 双重构校验锁模式(双重构校验锁是线程安全的)。
双重校验锁
饿汉模式是不需要加锁来保证单例的,而懒汉模式虽然节省了内存,但是却需要使用锁来保证单例,因此,双重校验锁就是懒汉模式的升级版本。
单线程懒汉模式实现
普通的懒汉模式在单线程场景下是线程安全的,但在多线程场景下是非线程安全的。
先来看看普通的懒汉模式实现:
java
public class Singleton {
private static Singleton INSTANCE; private Singleton() {} public static Singleton getInstance() { if (INSTANCE == null) { INSTANCE = new Singleton(); } return INSTANCE; } }

单线程懒汉模式的问题
上面这段代码在单线程环境下没有问题,但是在多线程的情况下会产生线程安全问题。
在多个线程同时调用getInstance方法时,由于方法没有加锁,可能会出现以下情况
- ① 这些线程 可能会创建多个对象
- ② 某个线程可能会得到一个未完全初始化的对象
为什么会出现以上问题?对于 ① 的情况解释如下:
java
public static Singleton getInstance() {
if (INSTANCE == null) { /** * 由于没有加锁,当线程A刚执行完if判断INSTANCE为null后还没来得及执行INSTANCE = new Singleton() * 此时线程B进来,if判断后INSTANCE为null,且执行完INSTANCE = new Singleton() * 然后,线程A接着执行,由于之前if判断INSTANCE为null,于是执行INSTANCE = new Singleton()重复创建了对象 */ INSTANCE = new Singleton(); } return INSTANCE; }

对于 ② 的情况解释如下:
java
public static Singleton getInstance() {
if (INSTANCE == null) { /** * 由于没有加锁,当线程A刚执行完if判断INSTANCE为null后开始执行 INSTANCE = new Singleton() * 但是注意,new Singleton()这个操作在JVM层面不是一个原子操作 * *(具体由三步组成:1.为INSTANCE分配内存空间;2.初始化INSTANCE;3.将INSTANCE指向分配的内存空间, * 且这三步在JVM层面有可能发生指令重排,导致实际执行顺序可能为1-3-2) * * 因为new操作不是原子化操作,因此,可能会出现线程A执行new Singleton()时发生指令重排的情况, * 导致实际执行顺序变为1-3-2,当执行完1-3还没来及执行2时(虽然还没执行2,但是对象的引用已经有了, * 只不过引用的是一个还没初始化的对象),此时线程B进来进行if判断后INSTANCE不为null, * 然后直接把线程A new到一半的对象返回了 */ INSTANCE = new Singleton(); } return INSTANCE; }

解决问题:加锁
为了解决问题 ①,我们可以对 getInstance() 这个方法加锁。
java
public class Singleton {
private static Singleton INSTANCE; private Singleton() {} public static synchronized Singleton getInstance() { // 加锁 if (INSTANCE == null) { INSTANCE = new Singleton(); } return INSTANCE; } }

仔细看,这里是粗暴地对整个 getInstance() 方法加锁,这样做代价很大,因为,只有当第一次调用 getInstance() 时才需要同步创建对象,创建之后再次调用 getInstance() 时就只是简单的返回成员变量,而这里是无需同步的,所以没必要对整个方法加锁。
由于同步一个方法会降低上百倍甚至更高的性能, 每次调用获取和释放锁的开销似乎是可以避免的:一旦初始化完成,获取和释放锁就显得很不必要。所以可以只对方法的部分代码加锁!!
javapublic class Lock2Singleton { private static Lock2Singleton INSTANCE; private Lock2Singleton() {} public static Lock2Singleton getSingleton() { // 因为INSTANCE是静态变量,所以给Lock2Singleton的Claa对象上锁 synchronized(Lock2Singleton.class) { // 加 synchronized if (INSTANCE == null) { INSTANCE = new Lock2Singleton(); } } return INSTANCE; } }
点击并拖拽以移动 优化后的代码选择了对 if (INSTANCE == null) 和 INSTANCE = new Lock2Singleton()加锁
这样,每个线程进到这个方法中之后先加锁 ,这样就保证了 if (INSTANCE == null) 和 INSTANCE = new Lock2Singleton() 这两行代码被同一个线程执行时不会有另外一个线程进来,由此保证了创建的对象是唯一的。
对象的唯一性保证了,也就是解决了问题①,同时也解决了问题②。
为什么说也解决了问题②呢?synchronized不是不能禁止指令重排序吗?
其实当我们对INSTANCE == null和INSTANCE = new Lock2Singleton();加锁时,也就表示只有一个线程能进来,尽管发生了指令重排序,也只是在持有锁的期间发生了指令重排序,当该线程创建完对象释放锁时,new出来的已经是一个完整的对象。
如此,我们仿佛完美地解决了问题 ① 和 ② ,然而你以为这就结束了吗?NO!这段代码从功能层面来讲确实是已经结束了,但是性能方面呢?是不是还有可以优化的地方?
答案是:有!!
值得优化的地方就在于 synchronized代码块这里。每个线程进来,不管三七二十一,都要先进入同步代码块再说,如果说现在 INSTANCE 已经不为null了,那么,此时当一个线程进来,先获得锁,然后才会执行 if 判断。我们知道加锁是非常影响效率的,所以,如果 INSTANCE 已经不为null,是不是就可以先判断,再进入 synchronized 代码块。如下
javapublic class Lock2Singleton { private static Lock2Singleton INSTANCE; private Lock2Singleton() {} public static Lock2Singleton getSingleton() { if (INSTANCE == null) { // 双重校验:第一次校验 synchronized(Lock2Singleton.class) { // 加 synchronized if (INSTANCE == null) { // 双重校验:第二次校验 INSTANCE = new Lock2Singleton(); } } } return INSTANCE; } }
点击并拖拽以移动 在 synchronized 代码块之外再加一个 if 判断,这样,当 INSTANCE 已经存在时,线程先判断不为null,然后直接返回,避免了进入 synchronized 同步代码块。
那么可能又有人问,好了,我明白了在synchronized 代码块外加一个 if 判断,是不是就意味着里面的那个 if 判断可以去掉?
当然不可以!!
如果把里面的 if 判断去掉,就相当于只对 INSTANCE = new Lock2Singleton() 这一行代码加了个锁,只对一行代码加锁,那你岂不是加了个寂寞(加锁的目的 就是防止在第二个if判断和new操作之间有别的线程进来!!),结果还是会引起问题①。
所以,两次校验,一次都不能少!!
但是,问题又来了,由于我们在外层又加了一层if (INSTANCE == null)的判断,导致原本被我们解决的问题② (即指令重排序问题)又出现了!
比如:线程A拿到锁后刚走到INSTANCE = new Lock2Singleton(),但是还没执行完,因为new Lock2Singleton()不是原子操作,且发生了指令重排序,那么此时INSTANCE就是一个不完整的对象,恰巧此时,线程B来到第一个if (INSTANCE == null)判断,由于INSTANCE不为null,结果获取到一个不完整的对象。
那么怎么解决呢?
答案是加 volatile 关键字,volatile可以禁止指令重排序
javapublic class Lock2Singleton { private volatile static Lock2Singleton INSTANCE; // 加 volatile private Lock2Singleton() {} public static Lock2Singleton getSingleton() { if (INSTANCE == null) { // 双重校验:第一次校验 synchronized(Lock2Singleton.class) { // 加 synchronized if (INSTANCE == null) { // 双重校验:第二次校验 INSTANCE = new Lock2Singleton(); } } } return INSTANCE; } }
点击并拖拽以移动
一、DCL 的基本实现
DCL(Double-Checked Locking)旨在减少同步开销,仅在首次创建实例时使用同步,同时保证线程安全。
标准代码模板
public class Singleton {
private static volatile Singleton instance; // 必须使用 volatile
private Singleton() {} // 私有构造函数
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(避免不必要的同步)
synchronized (Singleton.class) { // 同步块
if (instance == null) { // 第二次检查(确保唯一实例)
instance = new Singleton(); // 实例化
}
}
}
return instance;
}
}

二、DCL 的核心问题
1. 指令重排序问题
-
实例化操作的非原子性 :
instance = new Singleton()
分解为三步:-
分配内存空间
-
初始化对象
-
将引用指向内存地址
-
-
可能的指令重排序 :
若步骤2和3被重排序,其他线程可能访问到未初始化的对象(导致空指针异常)。
2. 可见性问题
未使用 volatile
时,一个线程的写操作可能对其他线程不可见。
三、解决方案:volatile
关键字
volatile
的作用
-
禁止指令重排序
-
通过内存屏障(Memory Barrier)确保:
-
写 操作前的指令不会被重排序到写操作之后。
-
读 操作后的指令不会被重排序到读操作之前。
-
-
-
保证可见性
-
修改
volatile
变量后,强制刷新到主内存。 -
其他线程读取时直接从主内存加载。
-
四、DCL 的演进与 JVM 版本兼容性
Java 版本 | DCL 安全性 | 原因 |
---|---|---|
Java 1.4 及之前 | 不安全 | volatile 语义不完整 |
Java 5(JSR-133)及之后 | 安全 | volatile 增强内存屏障语义 |
五、替代单例实现方案
1. 静态内部类(Holder 模式)
public class Singleton {
private Singleton() {}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE; // 类加载时初始化,由 JVM 保证线程安全
}
}

-
优点:无锁、线程安全、延迟加载。
-
缺点:无法防止反射或反序列化破坏单例。
2. 枚举单例(推荐)
public enum Singleton {
INSTANCE; // 由 JVM 保证唯一性
public void doSomething() {
// 方法实现
}
}

-
优点:
-
线程安全。
-
天然防反射和反序列化破坏。
-
-
缺点:无法继承其他类。
六、DCL 的正确使用场景
-
延迟初始化:仅在需要时创建实例。
-
性能敏感:避免每次调用同步的开销。
-
兼容性要求:需支持 Java 5 及以上版本。
七、常见误区与验证
1. 错误:省略 volatile
private static Singleton instance; // 缺少 volatile

- 后果:可能返回未完全初始化的对象(指令重排序导致)。
2. 错误:单次检查
public static Singleton getInstance() {
if (instance == null) { // 单次检查
synchronized (Singleton.class) {
instance = new Singleton();
}
}
return instance;
}

- 后果:多线程环境下可能创建多个实例。
八、内存屏障与 JVM 实现细节
-
写操作屏障:
-
StoreStore
屏障:禁止普通写与volatile
写重排序。 -
StoreLoad
屏障:强制刷新写缓存到主内存。
-
-
读操作屏障:
-
LoadLoad
屏障:禁止volatile
读与后续普通读重排序。 -
LoadStore
屏障:禁止volatile
读与后续普通写重排序。
-
九、总结
-
DCL 要点:
-
双重检查减少同步开销。
-
volatile
禁止指令重排序,保证可见性。
-
-
适用场景:需要延迟初始化且对性能有要求的单例实现。
-
替代方案:优先考虑枚举或静态内部类实现单例。
正确实现 DCL 需严格遵循代码模板,避免遗漏 volatile
关键字,以确保线程安全和对象初始化的正确性。