单例模式之「双重校验锁」

单例模式

单例即单实例,只实例出来一个对象。

一般在创建一些管理器类、工具类的时候,需要用到单例模式,比如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;
    }
}

饿汉模式和静态内部类实现单例模式的优点是写法简单,缺点是不适合复杂对象的创建。对于涉及复杂对象创建的单例模式,比较优雅的实现方式是懒汉模式,但是懒汉模式是非线程安全的,下面就讲一下懒汉模式的升级版——双重构校验锁模式(双重构校验锁是线程安全的)。

双重校验锁

饿汉模式是不需要加锁来保证单例的,而懒汉模式虽然节省了内存,但是却需要使用锁来保证单例,因此,双重校验锁就是懒汉模式的升级版本。

单线程懒汉模式实现

普通的懒汉模式在单线程场景下是线程安全的,但在多线程场景下是非线程安全的。

先来看看普通的懒汉模式实现:

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;
    }
}

过程如下:

判断 INSTANCE 是否为null,检查变量是否被初始化(不去获得锁),如果已被初始化立即返回这个变量;

  • 不为null,直接返回,不用去竞争锁
  • 为null,获取锁,然后再次判断(虽然已经判断过,但是在第一个if和synchronized之间仍有可能被另外线程插入导致第一个if判断为null时,当进入同步代码块之后再次判断时已经不为null了,所以需要再次判断)
    • 是否为null
      • 为null,创建并返回
      • 不为null,直接直接返回

为什么是双重校验 ?

第二次校验是为了解决问题①,即避免多个线程重复创建对象。

第一次校验是为了提高效率,避免 INSTANCE 不为null时仍然去竞争锁。

为什么加 volatile ?

加 volatile 是为了禁止指令重排序,也就是为了解决问题②,即避免某个线程获取到其他线程没有初始化完全的对象。

  • 90
    点赞
  • 180
    收藏
    觉得还不错? 一键收藏
  • 16
    评论
双重检验是一种实现单例模式的机制。该模式的主要目的是在多线程环境下确保只有一个实例被创建。它结合了懒汉模式和饿汉模式的优点。 在双重检验机制中,使用了两个判空操作。第一次判空是为了避免不必要的同步操作,如果实例已经被创建,就不需要再进入同步代码块。第二次判空是在synchronized块内部进行的,这样可以确保在多线程环境下只有一个线程可以创建实例。 具体实现中,使用了volatile关键字来保证多线程下的可见性。而synchronized关键字则保证了在多线程环境下只有一个线程可以进入同步代码块,避免了多个线程同时创建实例的问题。 总结来说,双重检验模式是一种在多线程环境下安全且高效地创建单例实例的方式。它通过两次判空和使用volatile和synchronized关键字来保证线程安全和可见性。这种模式在需要延迟实例化的场景下非常适用。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* [单例模式----双重检查](https://blog.csdn.net/weixin_50005657/article/details/115803998)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *2* *3* [单例模式之「双重校验」](https://blog.csdn.net/weixin_44471490/article/details/108929289)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 16
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值