单例---被废弃的DCL双重检查加锁
- 被废弃的单例的DCL双重检查加锁
/*- *单例模式
- *单例模式,保证一个类仅有一个实例,并提供一个访问它的全局访问点。
- *加同步锁的单例模式,适合在多线程中使用。
- */
- class Singleton{
- private static Singleton instance;
- private Singleton(){}//构造函数为private,外类不能使用new来创建立此类的实例
- public static Singleton getInstance(){//获得实例的唯一全局访问点
- System.out.println("进入外层");
- if (instance==null){
- synchronized(Singleton.class){
- if(instance==null){
- instance=new Singleton();
- System.out.println("进入里层");
- }//end inner if
- }//end synchronized
- }//end outter if
- return instance;
- }//end getInstance()
- }
- class Instance extends Thread{
- static int count=1;
- public void run(){
- System.out.println("第"+ count++ +"次调用同一个实例!");
- Singleton s1=Singleton.getInstance();
- }//end run
- public static void main(String []args){
- for( int i=1;i<5;i++){
- Instance thread1=new Instance();
- thread1.start();
- }
- }//end main
- }
- 运行结果:
- 第1次调用同一个实例!
- 进入外层
- 进入里层
- 第2次调用同一个实例!
- 进入外层
- 第3次调用同一个实例!
- 进入外层
- 第4次调用同一个实例!
- 进入外层
单例对象(Singleton)是一种常用的设计模式。在Java应用中,单例对象能保证在一个JVM中,该对象只有一个实例存在。正是由于这个特点,单例对象通常作为程序中的存放配置信息的载体,因为它能保证其他对象读到一致的信息。例如在某个服务器程序中,该服务器的配置信息可能存放在数据库或文件中,这些配置数据由某个单例对象统一读取,服务进程中的其他对象如果要获取这些配置信息,只需访问该单例对象即可。这种方式极大地简化了在复杂环境下,尤其是多线程环境下的配置管理,但是随着应用场景的不同,也可能带来一些同步问题。
本文将探讨一下在多线程环境下,使用单例对象作配置信息管理时可能会带来的几个同步问题,并针对每个问题给出可选的解决办法。
问题描述
在多线程环境下,单例对象的同步问题主要体现在两个方面,单例对象的初始化和单例对象的属性更新。
本文描述的方法有如下假设:
- 单例对象的属性(或成员变量)的获取,是通过单例对象的初始化实现的。也就是说,在单例对象初始化时,会从文件或数据库中读取最新的配置信息。
- 其他对象不能直接改变单例对象的属性,单例对象属性的变化来源于配置文件或配置数据库数据的变化。
1.1 单例对象的初始化
首先,讨论一下单例对象的初始化同步。单例模式的通常处理方式是,在对象中有一个静态成员变量,其类型就是单例类型本身;如果该变量为null,则创建该单例类型的对象,并将该变量指向这个对象;如果该变量不为null,则直接使用该变量。
其过程如下面代码所示:
- public class GlobalConfig {
- private static GlobalConfig instance = null;
- private Vector properties = null;
- private GlobalConfig() {
- //Load configuration information from DB or file
- //Set values for properties
- }
- public static GlobalConfig getInstance() {
- if (instance == null) {
- instance = new GlobalConfig();
- }
- return instance;
- }
- public Vector getProperties() {
- return properties;
- }
- }
这种处理方式在单线程的模式下可以很好的运行;但是在多线程模式下,可能产生问题。如果第一个线程发现成员变量为null,准备创建对象;这是第二个线程同时也发现成员变量为null,也会创建新对象。这就会造成在一个JVM中有多个单例类型的实例。如果这个单例类型的成员变量在运行过程中变化,会造成多个单例类型实例的不一致,产生一些很奇怪的现象。例如,某服务进程通过检查单例对象的某个属性来停止多个线程服务,如果存在多个单例对象的实例,就会造成部分线程服务停止,部分线程服务不能停止的情况。
1.2 单例对象的属性更新
通常,为了实现配置信息的实时更新,会有一个线程不停检测配置文件或配置数据库的内容,一旦发现变化,就更新到单例对象的属性中。在更新这些信息的时候,很可能还会有其他线程正在读取这些信息,造成意想不到的后果。还是以通过单例对象属性停止线程服务为例,如果更新属性时读写不同步,可能访问该属性时这个属性正好为空(null),程序就会抛出异常。
解决方法
2.1 单例对象的初始化同步
对于初始化的同步,可以通过如下代码所采用的方式解决。
- public class GlobalConfig {
- private static GlobalConfig instance = null;
- private Vector properties = null;
- private GlobalConfig() {
- //Load configuration information from DB or file
- //Set values for properties
- }
- private static synchronized void syncInit() {
- if (instance == null) {
- instance = new GlobalConfig();
- }
- }
- public static GlobalConfig getInstance() {
- if (instance == null) {
- syncInit();
- }
- return instance;
- }
- public Vector getProperties() {
- return properties;
- }
- }
这种处理方式虽然引入了同步代码,但是因为这段同步代码只会在最开始的时候执行一次或多次,所以对整个系统的性能不会有影响。
2.2 单例对象的属性更新同步
为了解决第2个问题,有两种方法:
1,参照读者/写者的处理方式
设置一个读计数器,每次读取配置信息前,将计数器加1,读完后将计数器减1。只有在读计数器为0时,才能更新数据,同时要阻塞所有读属性的调用。代码如下。
- public class GlobalConfig {
- private static GlobalConfig instance;
- private Vector properties = null;
- private boolean isUpdating = false;
- private int readCount = 0;
- private GlobalConfig() {
- //Load configuration information from DB or file
- //Set values for properties
- }
- private static synchronized void syncInit() {
- if (instance == null) {
- instance = new GlobalConfig();
- }
- }
- public static GlobalConfig getInstance() {
- if (instance==null) {
- syncInit();
- }
- return instance;
- }
- public synchronized void update(String p_data) {
- syncUpdateIn();
- //Update properties
- }
- private synchronized void syncUpdateIn() {
- while (readCount > 0) {
- try {
- wait();
- } catch (Exception e) {
- }
- }
- }
- private synchronized void syncReadIn() {
- readCount++;
- }
- private synchronized void syncReadOut() {
- readCount--;
- notifyAll();
- }
- public Vector getProperties() {
- syncReadIn();
- //Process data
- syncReadOut();
- return properties;
- }
- }
2,采用"影子实例"的办法
具体说,就是在更新属性时,直接生成另一个单例对象实例,这个新生成的单例对象实例将从数据库或文件中读取最新的配置信息;然后将这些配置信息直接赋值给旧单例对象的属性。如下面代码所示。
- public class GlobalConfig {
- private static GlobalConfig instance = null;
- private Vector properties = null;
- private GlobalConfig() {
- //Load configuration information from DB or file
- //Set values for properties
- }
- private static synchronized void syncInit() {
- if (instance = null) {
- instance = new GlobalConfig();
- }
- }
- public static GlobalConfig getInstance() {
- if (instance = null) {
- syncInit();
- }
- return instance;
- }
- public Vector getProperties() {
- return properties;
- }
- public void updateProperties() {
- //Load updated configuration information by new a GlobalConfig object
- GlobalConfig shadow = new GlobalConfig();
- properties = shadow.getProperties();
- }
- }
注意:在更新方法中,通过生成新的GlobalConfig的实例,从文件或数据库中得到最新配置信息,并存放到properties属性中。
上面两个方法比较起来,第二个方法更好,首先,编程更简单;其次,没有那么多的同步操作,对性能的影响也不大。
Singleton模式与双检测锁定(DCL)
看OOP教材时,提到了一个双检测锁定(Double-Checked Lock, DCL)的问题,但是书上没有多介绍,只是说这是一个和底层内存机制有关的漏洞。查阅了下相关资料,对这个问题大致有了点了解。
从头开始说吧。
在多线程的情况下Singleton模式会遇到不少问题,一个简单的例子
- class Singleton {
- private static Singleton instance = null;
- public static Singleton instance() {
- if (instance == null) {
- instance = new Singleton();
- }
- return instance;
- }
- }
假设这样一个场景,有两个线程调用Singleton.instance(),首先线程一判断instance是否等于null,判断完后一瞬间虚拟机把线程二调度为运行线程,线程二再次判断instance是否为null,然后创建一个Singleton实例,线程二的时间片用完后,线程一被唤醒,接下来它执行的代码依然是instance = new Singleton();
两次调用返回了不同的对象,出现问题了。
最简单的方法自然是在类被载入时就初始化这个对象:private static Singleton instance = new Singleton();
JLS(Java Language Specification)中规定了一个类只会被初始化一次,所以这样做肯定是没问题的。
但是如果要实现延迟初始化(Lazy initialization),比如这个实例初始化时的参数要在运行期才能确定,应该怎么做呢?
依然有最简单的方法:使用synchronized关键字修饰初始化方法:
public synchronized static Singleton instance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
这里有一个性能问题:多个线程同时访问这个方法时,会因为同步而导致每次只有一个线程运行,影响程序性能。而事实上初始化完毕后只需要简单的返回instance的引用就行了。
DCL是一个“看似”有效的解决方法,先把对应代码放上来吧:
- class Singleton {
- private static Singleton instance = null ;
- public static Singleton instance() {
- if (instance == null ) {
- synchronized (this) {
- if (instance == null)
- instance = new Singleton();
- }
- }
- return instance;
- }
- }
用JavaWorld上对应文章的标题来评论这种做法就是smart, but broken。来看原因:
Java编译器为了提高程序性能会进行指令调度,CPU在执行指令时同样出于性能会乱序执行(至少现在用的大多数通用处理器都是out-of-order的),另外cache的存在也会改变数据回写内存时的顺序[2]。JMM(Java Memory Model, 见[1])指出所有的这些优化都是允许的,只要运行结果和严格按顺序执行所得的结果一样即可。
Java假设每个线程都跑在自己的处理器上,享有自己的内存,和共享的主存交互。注意即使在单核上这种模型也是有意义的,考虑到cache和寄存器会保存部分临时变量。理论上每个线程修改自己的内存后,必须立即更新对应的主存内容。但是Java设计师们认为这种约束会影响程序性能,他们试着创造了一套让程序跑得更快、但又保证线程之间的交互与预期一致的内存模型。
synchronized关键字便是其中一把利器。事实上,synchronized块的实现和Linux中的信号量(semaphore)还是有区别的,前者过程中锁的获得和释放都会都会引发一次Memory Barrier来强制线程本地内存和主存之间的同步。通过这个机制,Java中的同步机制保证了synchronized块中指令的原子性(atomic)。
好了,回过头来看DCL问题。看起来访问一个未同步的instance字段不会产生什么问题,我们再次来假设一个场景:
线程一进入同步块,执行instance = new Singleton(); 线程二刚开始执行getInstance();
按照顺序的话,接下来应该执行的步骤是 :
1) 分配新的Singleton对象的内存
2) 调用Singleton的构造器,初始化成员字段
3) instance被赋为指向新的对象的引用。
前面说过,编译器或处理器都为了提高性能都有可能进行指令的乱序执行,线程一的真正执行步骤可能是:
1) 分配内存
2) instance指向新对象
3) 初始化新实例。
如果线程二在2完成后3执行前被唤醒,它看到了一个不为null的instance,跳出方法体走了,带着一个还没初始化的Singleton对象。
错误发生的一种情形就是这样,关于更详细的编译器指令调度导致的问题,可以参看这个网页 [4]。
[3] 中提供了一个编译器指令调度的证据
instance = new Singleton(); 这条命令在Symantec JIT中被编译成
0206106A mov eax,0F97E78h
0206106F call 01F6B210 ; 分配空间
02061074 mov dword ptr [ebp],eax ; EBP中保存了instance的地址
02061077 mov ecx,dword ptr [eax] ; 解引用,获得新的指针地址
02061079 mov dword ptr [ecx],100h ; 接下来四行是inline后的构造器
0206107F mov dword ptr [ecx+4],200h
02061086 mov dword ptr [ecx+8],400h
0206108D mov dword ptr [ecx+0Ch],0F84030h
可以看到,赋值完成在初始化之前,而这是JLS允许的。
另一种情形是,假设线程一安稳地完成Singleton对象的初始化,退出了同步块,并同步了和本地内存和主存。线程二来了,看到一个非空的引用,拿走。注意线程二没有执行一个Read Barrier,因为它根本就没进后面的同步块。所以很有可能此时它看到的数据是陈旧的。
还有很多人根据已知的几种提出了一个又一个fix的方法,但最终还是出现了更多的问题。可以参阅[3]中的介绍。
[5]中还说明了即使把instance字段声明为volatile还是无法避免错误的原因。
由此可见,安全的Singleton的构造一般只有两种方法,一是在类载入时就创建该实例,二是使用性能较差的synchronized方法。
[1] Java Language Specification, Second Edition, 第17章介绍了Java中线程和内存交互关系的具体细节。
[2] out-of-order与cache的介绍可以参阅Computer System, A Programmer's Perspective的第四、五章。
[3] The "Double-Checked Locking is Broken" Declaration, http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
[4] Synchronization and the Java Memory Model, http://gee.cs.oswego.edu/dl/cpj/jmm.html
[5] Double-checked locking: Clever, but broken, http://www.javaworld.com/javaworld/jw-02-2001/jw-0209-double.html?page=1
[6] Holub on Patterns, Learning Design Patterns by Looking at Code
单例---被废弃的DCL双重检查加锁的更多相关文章
- 【线程安全】—— 单例类双重检查加锁(double-checked locking)
1. 三个版本单例类的实现 版本1:经典版 public class Singleton { public static Singleton getInstance() { if (instance ...
- 糟糕的双重检查加锁(DCL)
在Java并发编程时,同步都会存在着巨大的性能开销,因此,人们使用了很多的技巧来降低同步的影响,这其中有一些技巧很好,但是也有一些技巧存在一些缺陷,下面要结束的双重检查加锁(DCL)就是有缺陷的一类. ...
- 双重检查加锁机制(并发insert情况下数据重复插入问题的解决方案)
双重检查加锁机制(并发insert情况下数据重复插入问题的解决方案) c#中单例模式和双重检查锁 转:https://blog.csdn.net/zhongliangtang/article/deta ...
- 单例设计模式(Singleton)
一.单例设计模式介绍 所谓类的单例设计模式,就是采取一定的方法保证在整个的软件系统中,对某个类只能存在一个对象实例, 并且该类只提供一个取得其对象实例的方法(静态方法) 例如:Hibernate的Se ...
- Java单例-双重检查锁
问题引入 Java中实现单例模式,一般性的做法是如下方式: class Singleton { private static Singleton INSTANCE = null; private Si ...
- 关于java的volatile关键字与线程栈的内容以及单例的DCL
用volatile修饰的变量,线程在每次使用变量的时候,都会读取变量修改后的最新的值.volatile很容易被误用,用来进行原子性操作. package com.guangshan.test; pub ...
- java双重检测或枚举类实现线程安全单例(懒汉模式)
双重检测实现 /** * 懒汉模式->双重同步锁单例模式 */ public class SingletonExample5 { private SingletonExample5() { } ...
- Java实现单例的5种方式
1. 什么是单例模式 单例模式指的是在应用整个生命周期内只能存在一个实例.单例模式是一种被广泛使用的设计模式.他有很多好处,能够避免实例对象的重复创建,减少创建实例的系统开销,节省内存. 2. 单例模 ...
- Java设计模式之单例
一.Java中的单例: 特点: ① 单例类只有一个实例 ② 单例类必须自己创建自己唯一实例 ③ 单例类必须给所有其他对象提供这一实例 二.两种模式: ①懒汉式单例<线程不安全> 在类加载时 ...
随机推荐
- socket的几个配置函数
body, table{font-family: 微软雅黑; font-size: 13.5pt} table{border-collapse: collapse; border: solid gra ...
- new/delete工作机制
body, table{font-family: 微软雅黑; font-size: 10pt} table{border-collapse: collapse; border: solid gray; ...
- icmp隧道手工操作
ICMP协议被用于检测网络连通状态的协议,通常情况下,防火墙会默认放过该协议. 渗透测试中经常出现一种情况是,我们通过某一种方式取得了一台主机的权限,得到了一些文件,比如域hash,密码文件之类的东西 ...
- Linux:更改hostname主机名
更改hostname主机名 查看主机名 hostname 临时更改主机名 hostname youname 更改永久生效主机名 1)更改配置文件 vi /etc/sysconfig/network 2 ...
- [Python] RuntimeError: Invalid DISPLAY variable
1.问题:在本地用matplotlib绘图可以,但是在ssh远程绘图的时候会报错 RuntimeError: Invalid DISPLAY variable 2.原因:matplotlib的默认ba ...
- SQL基础五(作业代码)
create database stuinfo create table student ( mid ) not null primary key, mname ) not null ) create ...
- enum枚举类型的定义
enum枚举类型的定义方式与某种用法 #include <iostream> using namespace std; int main() { enum TOT{ zero, one, ...
- BZOJ4837:[Lydsy1704月赛]LRU算法(双指针&模拟)
Description 小Q同学在学习操作系统中内存管理的一种页面置换算法,LRU(LeastRecentlyUsed)算法. 为了帮助小Q同学理解这种算法,你需要在这道题中实现这种算法,接下来简要地 ...
- 二次剩余-Cipolla
二次剩余 \(Cipolla\) 算法 概述 大概就是在模 \(p\) 意义下开根号,如求解方程\(x^2\equiv n(mod\ p)\). 这里只考虑 \(p\) 为素数的情况.若 \(p=2\ ...
- MySQL排序_20160926
在工作中对数据进行排序也是最常用的,比如根据用户的下单金额降序 或者对销售业绩进行降序排序 在考核员工KPI时候也经常用到 一.order by 函数 order by 函数默认根据后面字段升序,使 ...