单例模式-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 ...
随机推荐
- 【RS】Deep Learning based Recommender System: A Survey and New Perspectives - 基于深度学习的推荐系统:调查与新视角
[论文标题]Deep Learning based Recommender System: A Survey and New Perspectives ( ACM Computing Surveys ...
- MongoDB学习笔记(五)
MongoDB 查看执行计划 MongoDB 中的 explain() 函数可以帮助我们查看查询相关的信息,这有助于我们快速查找到搜索瓶颈进而解决它,本文我们就来看看 explain() 的一些用法及 ...
- CF1200D 【White Lines】
退役快一年了之后又打了场紧张刺激的$CF$(斜眼笑) 然后发现$D$题和题解里的大众做法不太一样 (思路清奇) 题意不再赘述,我们可以看到这个题~~好做~~在只有一次擦除机会,尝试以此为突破口解决问题 ...
- python爬取豆瓣电影首页超链接
什么是爬虫? 我们可以把互联网比作一张大网,而爬虫(即网络爬虫)便是在网上爬行的蜘蛛.把网的节点比作一个个网页,爬虫爬到这就相当于访问了该页面,获取了其信息.可以把节点间的连线比作网页与网页之间的链 ...
- .net 调用 Python脚本中的代码
使用工具:IronPython 工具介绍:是一种在 .NET 及 Mono上的 Python 实现,是一个开源的项目,基于微软的 DLR 引擎.(个人理解就是在 .net上面运行Python代码) 使 ...
- Kubernetes中的Volume介绍
Kubernetes中支持的所有磁盘挂载卷简介发表于 2018年1月26日 Weihai Feb 10,2016 7400 字 | 阅读需要 15 分钟 容器磁盘上的文件的生命周期是短暂的,这就使得在 ...
- Delphi - 鼠标上下滚动基础消息事件
Delphi实现对鼠标上下滚动基础消息的截获并处理 前几天有客户提出需求:由于个人PC界面限制,有时候电子图档显示不全,希望通过鼠标上下滚动用来控制电子图档的放大和缩小. 下面通过一个测试Demo来说 ...
- python跳出多循环
参考https://www.php.cn/python-tutorials-88895.html 备注 Python的循环体自己就有else分支! 如果for循环没有执行break,则执行else,f ...
- 移动端调试神器vconsole,手机端网页的调试工具Eruda
移动端调试神器vconsole,手机端网页的调试工具Eruda 移动端中使用 vConsole调试 移动端调试工具vconsole安装Git地址:https://github.com/WechatFE ...
- CTF——web安全中的一些绕过
function check($number) { $one = ord('1'); $nine = ord('9'); for ($i = 0; $i < strlen($number); $ ...