赘暨逢 发表于 2025-6-2 00:29:12

DCL(Double-checked Locking双重校验锁)实现单例模式的原理、问题与解决方案

​好的,要深入理解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一个对象,后面直接用即可。

饿汉模式指在类中直接定义全局的静态对象的实例并初始化,然后提供一个方法获取该实例对象。
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。
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中是唯一的,这就很好地保障了单例对象的唯一性。
静态内部类的单例实现方式同样是线程安全的。
代码如下:
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双重构校验锁模式(双重构校验锁是线程安全的)。
双重校验锁
饿汉模式是不需要加锁来保证单例的,而懒汉模式虽然节省了内存,但是却需要使用锁来保证单例,因此,双重校验锁就是懒汉模式的升级版本。

单线程懒汉模式实现
普通的懒汉模式在单线程场景下是线程安全的,但在多线程场景下是非线程安全的。
先来看看普通的懒汉模式实现:
public class Singleton {
    
      private static Singleton INSTANCE;
  
      private Singleton() {}
      
      public static Singleton getInstance() {
          if (INSTANCE == null) {
              INSTANCE = new Singleton();
        }
          return INSTANCE;
    }
}
单线程懒汉模式的问题
上面这段代码在单线程环境下没有问题,但是在多线程的情况下会产生线程安全问题。

在多个线程同时调用getInstance方法时,由于方法没有加锁,可能会出现以下情况

[*]① 这些线程可能会创建多个对象
[*]② 某个线程可能会得到一个未完全初始化的对象
为什么会出现以上问题?对于 ① 的情况解释如下:
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;
}对于 ② 的情况解释如下:
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() 这个方法加锁。
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() 时就只是简单的返回成员变量,而这里是无需同步的,所以没必要对整个方法加锁。
由于同步一个方法会降低上百倍甚至更高的性能, 每次调用获取和释放锁的开销似乎是可以避免的:一旦初始化完成,获取和释放锁就显得很不必要。所以可以只对方法的部分代码加锁!!
public 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 代码块。如下
public 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可以禁止指令重排序
public 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 关键字,以确保线程安全和对象初始化的正确性。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页: [1]
查看完整版本: DCL(Double-checked Locking双重校验锁)实现单例模式的原理、问题与解决方案