单例模式-DCL双重锁检查实现及原理刨析
以我的经验为例(如有不对欢迎指正),在生产过程中,经常会遇到下面两种情况:
1.封装的某个类不包含具有具体业务含义的类成员变量,是对业务动作的封装,如MVC中的各层(HTTPRequest对象以Threadlocal方式传递进来的)。
2.某个类具有全局意义,一旦实例化为对象则对象可被全局使用。如某个类封装了全球的地理位置信息及获取某位置信息的方法(不考虑地球爆炸,板块移动),信息不会变动且可被全局使用。
3.许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。如用来封装全局配置信息的类。
上述三种情况下如果每次使用都创建一个新的对象,且使用频率较高或类对象体积较大时,对象频繁的创建和GC会造成极大的资源浪费,同时不利于对系统整体行为的协调。此时便需要考虑使用单例模式来达到对象复用的目的。
在看单例模式的实现前我们先来看一下使用单例模式需要注意的四大原则:
1.构造私有。(阻止类被通过常规方法实例化)
2.以静态方法或者枚举返回实例。(保证实例的唯一性)
3.确保实例只有一个,尤其是多线程环境。(确保在创建实例时的线程安全)
4.确保反序列化时不会重新构建对象。(在有序列化反序列化的场景下防止单例被莫名破坏,造成未考虑到的后果)
目前单例模式的实现方式有很多种,我们仅讨论接受度最为广泛的DCL方式与静态内部类方式(本篇讨论DCL)。
DCL(Double Check Lock)双重锁检查
我们直接来看代码:
我们直接看核心方法getCar(),可以看到我们在new Car()之前用了两次if判断,一个在同步块外、一个在同步块内。我们来模拟一下创建新实例的过程:
1. 第一次判断单例对象的引用是否指向null,如果不为null则直接返回car,为null则进入下面的同步块实例化新的对象。
2. 获取Car.class对象的对象锁,在多线程的情况下同一时间只有一个线程会获得锁并进入同步块,执行同步块内的代码。
3. 进入同步块后,在此判断单例对象的引用是否指向null。这里再次判断的意义是:虽然我们在进入同步块前进行了一次判断,但在我们等待获取锁的过程中,其它某个已经获得锁的线程可能已经执行了同步块内的内容,为car创建了实例。所以在进入同步块后我们需要进行第二次判断,如果本次判断car的指向依然为null,那么此时我们确定,只有当前线程在可以改变car指向的同步块内,且car此时的指向为null。那么此时我们可以放心大胆的执行实例化的动作。
4. 当前线程实例化对象完成并退出同步块,此时其它阻塞在等待锁位置的线程(已经进行了第一次if判断判断结果为true)获得锁并进入同步块,进行第二次if检查,发现car已经指向了某个实例,不在进行实例化动作。
由于Car类的构造方法被我们重写为私有方法,我们只能使用上述Car.getCar()方法来获得实例(不能在其它类中随便new),这样便保证了实例的唯一性。做完这些,我们保证了多线程环境下获取实例的唯一性,但还有一点没有做完:保证反序列化不会破坏实例的唯一性。这点经常被忽略但非常重要,我们在使用单例模式来设计一个类时,从源头认为该类的实例全局只存在一个。但如果在序列化反序列化的场景下破坏了实例的唯一性,会导致难以排查的错误发生。虽然大部分情况下,我们不会让单例的类实现Serializable、Externalizable接口,但在继承关系复杂的情况下我们的单例类难免不是继承自实现了Serializable、Externalizable接口的祖先。
我们跟随反序列化时调用的readObject()的源码看一下调用栈:
private Object readOrdinaryObject(boolean unshared) throws
IOException {
//此处省略部分代码
Object obj;
try {
obj = desc.isInstantiable() ? desc.newInstance() : null;
} catch (Exception ex) {
throw (IOException) new InvalidClassException(
desc.forClass().getName(),
"unable to create instance").initCause(ex);
}
//此处省略部分代码
if (obj != null &&handles.lookupException(passHandle) == null &&desc.hasReadResolveMethod()){
Object rep = desc.invokeReadResolve(obj);
if (unshared && rep.getClass().isArray()) {
rep = cloneArray(rep);
}
if (rep != obj) {
handles.setObject(passHandle, obj = rep);
}
}
return obj;
}
我们可以看到第六行:obj = desc.isInstantiable() ? desc.newInstance() : null;
如果desc是可序列化的,那么通过反射调用无参构造方法来生成一个新的对象,这里提供了除getCar()方法外第二个为对象生成新实例的入口,破坏了我们实例的唯一性。
我们接着往下看,if (obj != null &&handles.lookupException(passHandle) == null &&desc.hasReadResolveMethod())--->如果被实例化对象包含readResolve()方法;
Object rep = desc.invokeReadResolve(obj);
handles.setObject(passHandle, obj = rep);-------->调用被实例化对象的readResolve()生成新的对象并返回。
那么我们重写一下readResolve()方法,使其返回我们的唯一实例,这样就防止了反序列化对单例的破坏。
注意这里我们没有用@Override来修饰该方法,因为我们并不知道该类的继承链上是否实现过Serializable、Externalizable接口,或者暂时没有实现但不确定以后是否会修改为实现序列化接口。实现过便重写,未实现过就当拿这个方法祭天了。
到这里感觉已经做了不少工作,但是还是漏了非常重要的一点:由JVM的指令重排序导致的著名DCL失效问题(JVM的乱序执行功能)。
问题具体是这样的,JVM为了优化运行效率会自以为是的对我们的指令进行重排序或者忽略它认为无效的指令(如空循环),对于对象的创建是分为以下三步的:
1.在堆内存开辟内存空间。
2.在堆内存中实例化Car里面的各个参数。
3.把对象指向堆内存空间。
其中第二步与第三步可能会被乱序执行,此时如果一个线程在同步块内实例化对象,执行的第三步(没有执行第二步),而另一个线程刚好在判断引用的指向是否为null。由于已经执行了三,所以即使对象未被实例化但依然会被返回并被使用,从而出现异常。另一方面,多核多线程情况下,由于CPU多级缓存的存在,变量的修改对于各个线程的可见性不能得到保证。比如A线程已经实例化了对象,但引用的指向只是再执行A线程的CPU多级缓存中,并未同步到主存。那么对于跑在其它CPU的线程来说,car的指向依然为null!
官方也发现了这个问题并在1.5版本修复了该问题,只要将变量声明为volatile,对于volatile变量的读写前后都会加入内存屏障来防止读写操作与前后的其它指令重排序。
同时volatile还保证了多核多线程环境下,变量对各个CPU的缓存一致性即各个线程间变量的可见性。
volatile的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了。为了实现 volatile 内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。但对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。
1. 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
2. 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
3. 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
4. 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
内存屏障 | 说明 |
---|---|
StoreStore 屏障 | 禁止上面的普通写和下面的 volatile 写重排序。 |
StoreLoad 屏障 | 防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。 |
LoadLoad 屏障 | 禁止下面所有的普通读操作和上面的 volatile 读重排序。 |
LoadStore 屏障 | 禁止下面所有的普通写操作和上面的 volatile 读重排序。 |
当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。
以上两点保证了多核多线程环境下变量对各线程的可见性以及对变量读写指令的有序性,这样一个完整的DCL单例类便写完了,完整代码如下:
package learning; import java.io.Serializable; public class Car {
//构造函数私有,禁止通过常规方式实例化
private Car(){}
//单例对象的引用
static volatile Car car=null;
//DCL获取单例对象
static Car getCar(){
if(car==null){
synchronized(Car.class){
if(car==null){
car=new Car();
}
}
}
return car;
} private Object readResolve() {
return getCar();
}
}
我们写个测试样例跑跑看,用三条线程重复调用getCar()方法获取实例,每条获取20000次。如果获得的不是唯一实例则抛出异常。下面是线程实体类:
主函数:
运行结果,三条线程都没有抛出异常,说明该方式在多线程的情况下是保证了单例的正确性的:
总结一下,在使用DCL时应注意的点:
1. volatile修饰实例引用,保证在多核多线程的情况下引用的值对各个线程的可见性,以及禁止为引用赋值时指令的重排序。
2. 在进入同步块前后都要进行判空。进入前判空时逻辑需要,进入后判空时防止等待锁的过程中其它线程改变了引用的值导致第一次判空无效。
3. 注意防止反序列化对单例的破坏,通过重写readResolve()方法实现。
4. 定义私有构造函数,封锁其它类直接创建实例的入口。
END$$
单例模式-DCL双重锁检查实现及原理刨析的更多相关文章
- 单例模式的双重锁为什么要加volatile(转)
单例模式如下: 需要volatile关键字的原因是,在并发情况下,如果没有volatile关键字,在第5行会出现问题. instance = new TestInstance();可以分解为3行伪代码 ...
- Kubernetes(k8s)底层网络原理刨析
目录 1 典型的数据传输流程图 2 3种ip说明 3 Docker0网桥和flannel网络方案 4 Service和DNS 4.1 service 4.2 DNS 5 外部访问集群 5.1 外部访问 ...
- 单例---被废弃的DCL双重检查加锁
被废弃的单例的DCL双重检查加锁/* *单例模式 *单例模式,保证一个类仅有一个实例,并提供一个访问它的全局访问点. *加同步锁的单例模式,适合在多线程中使用. */ class Singleton{ ...
- java中的双重锁定检查(Double Check Lock)
原文:http://www.infoq.com/cn/articles/double-checked-locking-with-delay-initialization#theCommentsSect ...
- C++Singleton的DCLP(双重锁)实现以及性能测评
本文系原创,转载请注明:http://www.cnblogs.com/inevermore/p/4014577.html 根据维基百科,对单例模式的描述是: 确保一个类只有一个实例,并提供对该 ...
- [数据库锁机制] 深入理解乐观锁、悲观锁以及CAS乐观锁的实现机制原理分析
前言: 在并发访问情况下,可能会出现脏读.不可重复读和幻读等读现象,为了应对这些问题,主流数据库都提供了锁机制,并引入了事务隔离级别的概念.数据库管理系统(DBMS)中的并发控制的任务是确保在多个事务 ...
- volatile双重锁实现单例
双重锁实现单例时遭到质疑,既是:双重锁也无法保证单例模式! 原因是:指令会重排序,普通的变量仅仅会保证该方法在执行时,所有依赖的赋值结果是正确的,但不会保证执行顺序! 为什么会重排序:指令重排序是指c ...
- Java多线程系列--“JUC锁”10之 CyclicBarrier原理和示例
概要 本章介绍JUC包中的CyclicBarrier锁.内容包括:CyclicBarrier简介CyclicBarrier数据结构CyclicBarrier源码分析(基于JDK1.7.0_40)Cyc ...
- synchronized锁机制的实现原理
Synchronized 锁机制的实现原理 Synchronized是Java种用于进行同步的关键字,synchronized的底层使用的是锁机制实现的同步.在Java中的每一个对象都可以作为锁. J ...
随机推荐
- fiddler抓包-5-Composer功能进行接口测试
前言 fiddler是个强大的抓接口工具,轻松看出接口的所有参数,这里介绍一个Composer功能它也可以进行接口测试,平时接口可能传参错误,我们可以拖拽接口来改参数直接再请求了,非常方便! 一.Co ...
- 027 奥展项目涉及的javascipt知识点笔记
1.获取指定div标签内的所有input标签 let inputs = document.getElementById("inspect-part1").getElementsBy ...
- 最简单的 kubernetes 高可用安装方式
sealos 项目地址:https://github.com/fanux/sealos 本文教你如何用一条命令构建 k8s 高可用集群且不依赖 haproxy 和 keepalived,也无需 ans ...
- UI事件定位--HitTest
In computer graphics programming, hit-testing (hit detection, picking, or pick correlation) is the p ...
- .net架构的浅谈
,net的架构有以下几种 1.两层架构:UI + 数据层 2.三层架构:UI + 业务层 + 数据层 3.三层 + 接口层 (把相关的业务层抽象成接口,下层来实现接口,中层是依赖) 4.三层 + 接口 ...
- SQL Server merge用法
有两个表名:source 表和 target 表,并且要根据 source 表中匹配的值更新 target 表. 有三种情况: source 表有一些 target 表不存在的行.在这种情况下,需要将 ...
- python排序 基数排序
算法思想 基数排序通过按位比较(一般从最低位开始)将元素按照最低位的数放到10个桶中,当所有的元素都这样被处理一次后,在按从0到9的顺序将每个桶的元素再取出来(不关注其他位的,只关注当前位的)这样就完 ...
- python \r与\b的应用、光标的含义
参考链接:https://www.jianshu.com/p/eb5c23cd6e34 \r 能将光标定位到当前行的行首 \b则是将光标回退一位 光标的含义: 光标后面的输出内容均会消失,光标回退后, ...
- loj#10172 涂抹果酱 (状压DP)
题目: #10172. 「一本通 5.4 练习 1」涂抹果酱 解析: 三进制的状压DP 经过简单的打表发现,在\(m=5\)时最多有\(48\)种合法状态 然后就向二进制一样枚举当前状态和上一层的状态 ...
- 架构师小跟班:推荐一款Java在线诊断工具,arthas入门及使用教程
安装 官方网站: https://alibaba.github.io/arthas/index.html 一.下载arthas-boot.jar,然后用java -jar的方式启动: wget htt ...