首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

懒汉式单例中为什么要使用双重检测

一、前言

本文的目的是探讨懒汉式单例为什么一定要使用双层if (instance == null)来保证多线程情况下安全运行,文章第二部分是双层检测的合理性,第三部分是双层检测的局限性,相互对应,从浅到深。

二、双层检测的合理性

2.1 双层检测的理论解释

问题1:单例模式在多线程环境下的lazy模式为什么要加两个if(instance == null)?

回答1:第一层 if (instance == null)是为了减少线程对同步锁锁的竞争,第二层if(instance==nul)是保证单例。即

(1)?的懒汉式多线程下是不安全的;

(2)?的懒汉式多线程下是安全的;

(3)?的懒汉式在(2)的情况下减少对同步锁的竞争,提高效率。

问题2:如回答1,既然第二种方式已经看保证线程安全了,为什么需要从第二种方式变为第三种方式?或者为什么要使用双层同步锁?或者第三种方式??的懒汉式是如何减少对同步锁的竞争?

回答2:如果使用第二种方式,即第三种方式没有第一层?,每次调用newInstance()方法都会先synchronized/lock 然后判断??,对于一共 n 次调用newInstance静态方法,对于第二层的?,只有第一次创建在为true,后面都是为false,不需要再次新建了,因为是单例,所以对于后面的 n-1 次调用newInstance,都是先获取到同步锁,然后?为false,这样白白的消耗性能,后面的 n-1 次,每个线程辛辛苦苦的获取到的同步锁,发现没卵用,还不如不要获取同步锁,尝试的解决方法有两个:

??变为?,但是这样修改后,第一批进入的线程破坏单例模式。

??变为?,在保证线程安全的第二种方式前面加一层 if (instance == null)判断,变为双层检测,这样在保证单例的情况下,提高效率,减少性能浪费。

问题3:为什么说刚才的第一种方式??变为??,无法保证第一批进入的线程仅创建一个对象?

回答3:虽然??实现后面调用阻止进入,提高了效率,同时 synchronized/lock 保证内部新建单例的原子性,但是由于没有内层??,第一批进入的每一个线程都会创建一个对象,破坏单例模式 。

2.2 双层检测的实践证明

运行结果:

对运行结果的解释:虽然线程6先进入的第一个?,但创建对象的确实线程7,而此时,线程6已经判断了instance==null,所以他还会再创建一个对象,因为并没有人告诉线程6,线程7已经创建过对象了,而内部的这个?,就是为了告诉别人,对象我已经创建过了!其他人就不要再创建了!

解释:

对于??结构,假设一个有m个线程,一共发出n次调用newInstance()方法,则:

(1)外层??保证,除了第一批进入的 t(t

(2)?保证对于第一批进入的 t 次调用,对于同步锁的竞争,只有一个线程可以得到同步锁,其他的 t - 1 个线程都在等待,唯一的线程新建对象的原子性。

(3)内层??保证,第一个获取同步锁进来的线程创建完对象之后,释放同步锁之后,第一批进来的 t-1 线程,即使得到同步锁,也不能再创建对象了,所有对于后面的 t - 1 个线程,它们会一个线程得到同步锁,然后内层??为false,然后释放同步锁,然后又一个线程得到同步锁,然后内层??为false,然后释放同步锁,直至 t-1 个都完成。

所以,在?结构中,三层各有用处,缺一不可。

值得注意的是,即使是完成??三层也不能保证线程安全,因为无法一定保证多线程有序性,在满足happen-before先行发生原则的基础上,存在指令重排序,所以,一定要在instance引用上使用volidate关键字,即

所以,?四层各有用处,缺一不可。

三、双层检测的局限性

首先要解释一下什么是延迟加载,延迟加载就是等到真真使用的时候才去创建实例,不用时不要去创建。

从速度和反应时间角度来讲,非延迟加载(又称饿汉式)好;从资源利用效率上说,延迟加载(又称懒汉式)好。

下面看看几种常见的单例的设计方式:

3.1 第一种:非延迟加载单例类(饿汉式,定义时初始化)

3.2 第二种:同步延迟加载(懒汉式,静态方法中初始化)

注意,这里的同步锁对象不是this,最明显的是,这是一个static方法,所以锁一定不是this,同步锁对象是Singleton.class,等同于下面:

3.3 第三种:双重检测同步延迟加载(instance一定要加上volatile关键字修饰,禁止底层操作重排序)

为处理原版非延迟加载方式瓶颈问题,我们需要对 instance 进行第二次检查,目的是避开过多的同步(因为这里的同步只需在第一次创建实例时才同步,一旦创建成功,以后获取实例时就不需要同获取锁了,就是上面所有的后面 n-t 进不来了),但在Java中行不通,因为同步块外面的if (instance == null)可能看到已存在,但不完整的实例。JDK5.0以后版本若instance为volatile则可行:

双重检测锁定失败的问题并不归咎于 JVM 中的实现 bug,而是归咎于 Java 平台内存模型。JVM内存模型允许所谓的“无序写入”,这也是失败的一个主要原因。

嵌入知识点:无序写入

为解释该问题,需要重新考察上述清单中的 //3 行。此行代码创建了一个 Singleton 对象并初始化变量 instance 来引用此对象。这行代码的问题是:在 Singleton 构造函数体执行之前,变量 instance 可能成为非 null 的,即赋值语句在对象实例化之前调用,此时别的线程得到的是一个还会初始化的对象,这样会导致系统崩溃。这一说法可能让您始料未及,但事实确实如此。在解释这个现象如何发生前,请先暂时接受这一事实,我们先来考察一下双重检查锁定是如何被破坏的。假设代码执行以下事件序列:

1、线程 1 进入 getInstance() 方法。

2、由于 instance 为 null,线程 1 在 //1 处进入 synchronized 块。

3、线程 1 前进到 //3 处,但在构造函数执行之前,使实例成为非 null。

4、线程 1 被线程 2 预占。

5、线程 2 检查实例是否为 null。因为实例不为 null,线程 2 将 instance 引用返回给一个构造完整但部分初始化了的 Singleton 对象。

6、线程 2 被线程 1 预占。

7、线程 1 通过运行 Singleton 对象的构造函数并将引用返回给它,来完成对该对象的初始化。

为展示此事件的发生情况,假设代码行 instance =new Singleton(); 执行了以下三句伪代码:

这段伪代码不仅是可能的,而且是一些 JIT 编译器上真实发生的。执行的顺序是颠倒的,但鉴于当前的内存模型,这也是允许发生的。JIT 编译器的这一行为使双重检查锁定的问题只不过是一次学术实践而已,在底层运行还是无法保证线程线程安全,理由是:

(1)在JAVA2(以jdk1.2开始)以前对于实例字段是直接在主储区读写的.所以当一个线程对resource进行分配空间,初始化和调用构造方法时,可能在其它线程中分配空间动作可见了,而初始化和调用构造方法还没有完成.

(2)但是从JAVA2以后,JMM发生了根本的改变,分配空间,初始化,调用构造方法只会在线程的工作存储区完成,在没有向主存储区复制赋值时,其它线程绝对不可能见到这个过程.而这个字段复制到主存区的过程,更不会有分配空间后没有初始化或没有调用构造方法的可能.在JAVA中,一切都是按引用的值复制的.向主存储区同步其实就是把线程工作存储区的这个已经构造好的对象有压缩堆地址值COPY给主存储区的那个变量.这个过程对于其它线程,要么是resource为null,要么是完整的对象.绝对不会把一个已经分配空间却没有构造好的对象让其它线程可见.

解决方法就是对instance引用加上volatile关键字修饰,禁止instance = new Singleton();底层三步操作的重排序。

3.4 第四种:使用ThreadLocal修复双重检测

借助于ThreadLocal,将临界资源(需要同步的资源)线程局部化,具体到本例就是将双重检测的第一层检测条件 if (instance == null) 转换为了线程局部范围内来作。这里的ThreadLocal也只是用作标示而已,用来标示每个线程是否已访问过,如果访问过,则不再需要走同步块,这样就提高了一定的效率。但是ThreadLocal在1.4以前的版本都较慢,但这与volatile相比却是安全的。

3.5 第五种:使用内部类实现延迟加载

为了做到真真的延迟加载,双重检测在Java中是行不通的,所以只能借助于另一类的类加载加延迟加载:

四、尾声

单例模式的双层检测,完成了。

天天打码,天天进步!!!

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20201220A038ZS00?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券
http://www.vxiaotou.com