设计模式之单例模式 深入探究

来源:互联网 发布:卡乐购系统域名 编辑:程序博客网 时间:2024/06/14 07:45
众所周知单例模式有有饿汉式与懒汉式两种。当一个单例类的初始化开销很大,而希望当用户实际上需要的时候才去创建单例类,就会考虑使用懒汉式延迟初始化,来提高程序的启动速度。但懒汉式并不容易使用。 
在多线程的环境下,如果不同步getInstance()方法会出现线程安全的问题,如果同步整个方法,那么getInstance()就完全变成串行,串行效率会降低10倍甚至100倍。因此,有些聪明的程序员就把C中常用的DCL(double-checked locking,中文名双重检查加锁)搬到JAVA上来,看上去很强大。如下: 
双重检查加锁 
Java代码  收藏代码
  1. public class Singleton {  
  2.      private static Singleton instance;  
  3.        
  4.      //私有构造函数  
  5.      private Singleton(){  
  6.            
  7.      }  
  8.        
  9.      //双重检查加锁的代码  
  10.      public static Singleton getInstance(){  
  11.          if(instance==null){  
  12.              synchronized(Singleton.class){      //1  
  13.                  if(instance==null){             //2  
  14.                      instance = new Singleton(); //3  
  15.                  }  
  16.              }  
  17.          }  
  18.          return instance;  
  19.      }  
  20. }  


引用IBM-中国网站上的一段话按DCL(双重检查加锁)的理论进行假设 

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

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

3.线程 1 被线程 2 预占。 

4.线程 2 进入 getInstance() 方法。 

5.由于 instance 仍旧为 null,线程 2 试图获取 //1 处的锁。然而,由于线程 1 持有该锁,线程 2 在 //1 处阻塞。 

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

7.线程 1 执行,由于在 //2 处实例仍旧为 null,线程 1 还创建一个 Singleton 对象并将其引用赋值给 instance。 

8.线程 1 退出 synchronized 块并从 getInstance() 方法返回实例。 

9.线程 1 被线程 2 预占。 

10.线程 2 获取 //1 处的锁并检查 instance 是否为 null。 

11.由于 instance 是非 null 的,并没有创建第二个 Singleton 对象,由线程 1 创建的对象被返回。 

DCL背后的理论是完美的。不幸地是,现实完全不同。双重检查锁定的问题是:并不能保证它会在单处理器或多处理器计算机上顺利运行。 

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

详情资料可以查看:http://www.ibm.com/developerworks/cn/java/j-dcl.html 


上面也说了,究其原因是Java平台内存模型(JMM)-- 
这里简单介绍一下Java的内存模型: 
        不像大多数其它语言,Java定义了它和潜在硬件的关系,通过一个能运行所有Java平台的正式内存模型,能够实现Java“写一次,到处运行”的诺言。通过比较,其它语言像C和C++,缺乏一个正式的内存模型;在这些语言中,程序继承了运行该程序硬件平台的内存模型。 

         当运行在同步(单线程)环境中,一段程序与内存交互相当的简单,或者说至少它的表现如此。程序存贮条目到内存位置上,并且在下一次这些内存位置被检测的时候期望它们仍然在那儿。 

         实际上,原理是完全不同的。但是通过编译器,Java虚拟机(JVM)维持着一个复杂的幻想,并且硬件把它掩饰起来。虽然,我们认为程序将像程序代码中说明的顺序连续执行,但是事情不是总是这样发生的。编译器,处理器和缓存可以自由的随意的应用我们的程序和数据,只要它们不会影响到计算的结果。例如,编译器能用不同的顺序从明显的程序解释中生成指令,并且存贮变量在寄存器中,而不是内存中;处理器可以并行或者颠倒次序的执行指令缓存可以改变顺序的把提交内容写入到主存中。Java内存模型(JMM)所说的只要环境维持了as-if-serial语法,所有的各种各样的再排序和优化是可以接受的。也就是说,只要你完成了同样的结果与你在一个严格的连续环境中指令被执行的结果一样。 
  
          编译器,处理器和缓存为了达到高性能,需要重新安排程序运行的顺序。近年来,我们看到了计算机的计算性能有了极大的提高。处理器时钟频率的提高对高性能有着充分的贡献,并行能力(管道的形式和超标量体系结构执行单元,动态指令分配表和灵活的执行,精密的多级内存缓存)的提高也是主要的贡献者。当今,编写编译器的任务变得极其复杂,因为在这些复杂性中,编译器必须能保护程序员。 

          当编写单线程程序时,你不会看到这些多种多样指令或者内存重新排序运行的结果。然而,在多线程程序中,情况则完全不同——一个线程可以读另一个线程已经写了的内存位置。如果线程A用某一种顺序修改了一些变量,在缺乏同步时,线程B可能用同样的顺序也看不到它们,或者根本看不到它们。那可能是由于编译器把指令重新排序或临时在寄存器中存储了变量,后来把它写到内存以外;或者是由于处理器并行的或与编译器指定的不同的顺序执行指令;或者是由于指令在内存的不同区域,而且缓存用与它们写进时不一样的顺序更新了相应的主存位置。无论何种环境,多线程程序先天的缺乏可预见性。除非你通过使用同步明确地保证线程有一个一致的内存视图。 


          阎宏在《Java与模式》也有精辟的总结:在Java 编译器中,Singleton 类的初始化与instance变量赋值的顺序不可预料。如果一个线程在没有同步化的条件下读取instance 引用,并调用这个对象的方法的话,可能会发现对象的初始化过程尚未完成,从而造成崩溃。 


         由于JMM天生的无序性,导致了在Singleton构造函数执行之前,变量instance可能成为非null !! 
假设线程1执行到
Java代码  收藏代码
  1. instance = new Singleton();  
这一句,但又未完成初始化时,被线程抢夺掉时间片,这时线程2判断了instance非空并返回一个instance对象,但好可惜,这只是一个未完成初始化的半成品!这个半成品的成员很可能是失效的。 


举一个比较贴近实际的例子 
Java代码  收藏代码
  1. public class Test4 {  
  2.       public static void main(String[] args) {  
  3.          X x = new X();  
  4.     }  
  5. }  
  6. class X {  
  7.     static String name = "rjx";  
  8.   
  9.     static {  
  10.         System.out.println("---------运行静态块的代码------------");  
  11.         name = "Jam";  
  12.     }  
  13.   
  14.     {  
  15.         System.out.println("--------运行实例块/初始化块的代码--------");  
  16.     }  
  17.   
  18.     public X() {  
  19.         System.out.println("--------运行构造函数的代码-------");  
  20.         System.out.println("静态变量name现在的值是" + name);  
  21.     }  
  22. }  

结果显示: 


 


这段代码的结果相信大部分朋友都很清楚,可以得知一般程序的运行程序是:静态代码块/静态变量-->实例块/实例变量-->构造函数。 
但如果将代码稍微改变下: 
Java代码  收藏代码
  1. public class Test4 {  
  2.       public static void main(String[] args) {  
  3.          X x = new X();  
  4.     }  
  5. }  
  6. class X {  
  7.     //增加自己的静态实例变量  
  8.     static X x = new X();  
  9.       
  10.     static String name = "rjx";  
  11.   
  12.     static {  
  13.         System.out.println("---------运行静态块的代码------------");  
  14.         name = "Jam";  
  15.     }  
  16.   
  17.     {  
  18.         System.out.println("--------运行实例块/初始化块的代码--------");  
  19.     }  
  20.   
  21.     public X() {  
  22.         System.out.println("--------运行构造函数的代码-------");  
  23.         System.out.println("静态变量name现在的值是" + name);  
  24.     }  
  25. }  

结果显示: 

 


结果可能有些出乎你的意料。name中间的值为null,这就是之前所说的失效值。 
返回上面的双重检查加锁的单例程序,因为并非整个getInstance()的是同步的。一个线程当执行getInstance()的时候,JVM分配内存,分配Singleton,调用构造函数。在完成分配内存并且instance字段已经设置,但构造函数还没调用之时,被另外一个线程抢夺了时间片,这时它进行getInstance并且进行非空判断,它认为instance不为空,跳过synchronized快,返回一个部分构造的instance对象!不必说,这个结果既不是我们期望的,也不是我们想象的。 


使用Volatile关键字能解决问题但不可取 
《Head First设计模式》中提倡在Java 5的环境下用Volatile修饰instance变量,试图使instance变量被认为是顺序一致,不是重新排序的。因为Java 5更改了Volatile的语义,使Volatile变量的读写与syncrhonized有相当的含义。这样做虽然能使DCL的功能正确,效率却和在 synchronized block 内部单一检查相当,甚至更差一点(因为检查了两次)。在我看来,DCL 已经完全不具有物理意义了。而且大多数的JVM也没有正确地实现Volatile。 


==============================================无敌分界线============================================== 


        貌似只有同步整个方法和使用饿汉式才能解决问题之际,一种全新的思想被提出来,并马上得到了大家的认可 
请看下面的代码
Java代码  收藏代码
  1. public class Singleton {  
  2.   
  3.      private Singleton(){  
  4.            
  5.      }  
  6.      //静态内部类  
  7.      static class SingletonInner{  
  8.         static Singleton instance = new Singleton();  
  9.      }  
  10.        
  11.      public static Singleton getInstance(){  
  12.          return SingletonInner.instance;  
  13.      }  
  14. }  

使用静态内部类!它是由一个叫Bob Lee的人写下来的(最初忘记了是哪两个人提出)。在加载singleton时并不加载它的内部类SingletonInner,而在调用getInstance()时调用SingletonInner时才加载SingletonInner,从而调用singleton的构造函数,实例化singleton,从而在不需要同步的情况下,达到延迟初始化的效果。 

转自:http://rjx2008.iteye.com/blog/342474

0 0