有关双检测锁定(DCL)和Singleton模式的问题

开发 后端
本文解释了一个有关双检测锁定(DCL)和Singleton模式的问题。在多线程的情况下Singleton模式会遇到不少问题,一个解决方案是双检测锁定,不过事实上这种方法在JDK5之前会导致问题。

看OOP教材时,提到了一个双检测锁定(Double-Checked Lock, DCL)的问题,但是书上没有多介绍,只是说这是一个和底层内存机制有关的漏洞。查阅了下相关资料,对这个问题大致有了点了解。

从头开始说吧。

在多线程的情况下Singleton模式会遇到不少问题,一个简单的例子

1:  class Singleton {      
   2:      private static Singleton instance = null;     
   3:        
   4:      public static Singleton instance() {     
   5:          if (instance == null) {     
   6:              instance = new Singleton();     
   7:          }     
   8:          return instance;    
   9:      }    
   10:  }
  

假设这样一个场景,有两个线程调用Singleton.instance(),首先线程一判断instance是否等于null,判断完后一瞬间虚拟机把线程二调度为运行线程,线程二再次判断instance是否为null,然后创建一个Singleton实例,线程二的时间片用完后,线程一被唤醒,接下来它执行的代码依然是instance = new Singleton();
两次调用返回了不同的对象,出现问题了。

最简单的方法自然是在类被载入时就初始化这个对象:private static Singleton instance = new Singleton();

JLS(Java Language Specification)中规定了一个类只会被初始化一次,所以这样做肯定是没问题的。

但是如果要实现延迟初始化(Lazy initialization),比如这个实例初始化时的参数要在运行期才能确定,应该怎么做呢?

依然有最简单的方法:使用synchronized关键字修饰初始化方法:

  1. public synchronized static Singleton instance() {          
  2.     if (instance == null) {  
  3.         instance = new Singleton();  
  4.     }  
  5.     return instance;  
  6. }   

这里有一个性能问题:多个线程同时访问这个方法时,会因为同步而导致每次只有一个线程运行,影响程序性能。而事实上初始化完毕后只需要简单的返回instance的引用就行了。

双检测锁定解决方案

DCL是一个“看似”有效的解决方法,先把对应代码放上来吧:

    1 :   class Singleton {   
    2 :       private static Singleton instance = null ;   
    3 :      
    4 :       public static Singleton instance() {   
    5 :           if (instance == null ) {
    6 :               synchronized (this) {   
    7 :                   if (instance == null)
    8 :                      instance = new Singleton();
    9 :              }
    10 :          }
    11 :          return instance;
    12 :      }
    13 :  }

用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(); 线程二刚开始执行getResource();

按照顺序的话,接下来应该执行的步骤是 1) 分配新的Singleton对象的内存 2) 调用Singleton的构造器,初始化成员字段 3) instance被赋为指向新的对象的引用。

前面说过,编译器或处理器都为了提高性能都有可能进行指令的乱序执行,线程一的真正执行步骤可能是1) 分配内存 2) instance指向新对象 3) 初始化新实例。如果线程二在2完成后3执行前被唤醒,它看到了一个不为null的instance,跳出方法体走了,带着一个还没初始化的Singleton对象。

错误发生的一种情形就是这样,关于更详细的编译器指令调度导致的问题,可以参看这个网页 [4]。

[3] 中提供了一个编译器指令调度的证据

instance = new Singleton(); 这条命令在Symantec JIT中被编译成

  1. 0206106A   mov         eax,0F97E78h  
  2. 0206106F   call        01F6B210                  ; 分配空间  
  3. 02061074   mov         dword ptr [ebp],eax       ; EBP中保存了instance的地址   
  4.  
  5. 02061077   mov         ecx,dword ptr [eax]       ; 解引用,获得新的指针地址   
  6.  
  7. 02061079   mov         dword ptr [ecx],100h      ; 接下来四行是inline后的构造器  
  8. 0206107F   mov         dword ptr [ecx+4],200h      
  9. 02061086   mov         dword ptr [ecx+8],400h  
  10. 0206108D   mov         dword ptr [ecx+0Ch],0F84030h   
  11.  

可以看到,赋值完成在初始化之前,而这是JLS允许的。
 
另一种情形是,假设线程一安稳地完成Singleton对象的初始化,退出了同步块,并同步了和本地内存和主存。线程二来了,看到一个非空的引用,拿走。注意线程二没有执行一个Read Barrier,因为它根本就没进后面的同步块。所以很有可能此时它看到的数据是陈旧的。

还有很多人根据已知的几种提出了一个又一个fix的方法,但最终还是出现了更多的问题。可以参阅[3]中的介绍。

[5]中还说明了即使把instance字段声明为volatile还是无法避免错误的原因。

由此可见,安全的Singleton的构造一般只有两种方法,一是在类载入时就创建该实例,二是使用性能较差的synchronized方法。

本文来自ZelluX的BlogJava博客《Singleton模式与双检测锁定(DCL) 》一文

参考资料:
[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
[4] Synchronization and the Java Memory Model
[5] Double-checked locking: Clever, but broken
[6] Holub on Patterns, Learning Design Patterns by Looking at Code

【编辑推荐】

  1. 最简单的设计模式学习:Singleton模式
  2. 详解Java Singleton模式的好处
  3. Java设计模式与应用浅谈
  4. 设计模式在EJB中的应用
  5. 探讨JSF框架中使用的设计模式
责任编辑:yangsai 来源: ZelluX的博客
相关推荐

2009-07-09 17:30:59

Singleton模式C++ SingletJava Single

2013-04-19 09:16:56

2009-07-08 17:25:05

Java Single

2010-02-06 10:04:10

Android启动模式

2009-08-31 16:12:02

C#使用Singlet

2009-09-02 16:23:27

C# Singleto

2009-08-31 15:48:02

C# Singleto

2009-08-25 18:04:30

C#实现Singlet

2012-08-22 10:10:25

单态单态设计设计模式

2011-07-18 16:51:51

Cocoa 单态 模式

2009-08-12 13:22:44

Singleton模式

2010-07-14 14:20:47

IMAP协议服务

2021-03-18 18:07:57

JavaScript开发代码

2009-09-24 16:55:27

策略模式

2016-12-26 17:57:13

戴尔成就篇

2018-10-30 16:39:12

双频WiFi双路

2009-07-20 18:03:26

Scala程序Singleton对象

2013-12-23 09:48:43

C++锁定模式

2010-07-08 12:49:12

SIP会话发起协议

2009-06-11 13:00:08

Java数组赋值
点赞
收藏

51CTO技术栈公众号