线程的单例模式与延迟加载中的Initialization on Demand Holder

来源:互联网 发布:南山铝业数据 编辑:程序博客网 时间:2024/05/21 10:24
 面试的时候,常常会被问到这样一个问题:请您写出一个单例模式(Singleton Pattern)吧。好吧,写就写,这还不容易。顺手写一个:
  1. public final class EagerSingleton  
  2. {  
  3.     private static EagerSingleton singObj = new EagerSingleton();  
  4.   
  5.     private EagerSingleton(){  
  6.     }  
  7.   
  8.     public static EagerSingleton getSingleInstance(){  
  9.        return singObj;
  10.     }  
  11. }  
    这种写法就是所谓的饥饿模式,每个对象在没有使用之前就已经初始化了。这就可能带来潜在的性能问题:如果这个对象很大呢?没有使用这个对象之前,就把它加载到了内存中去是一种巨大的浪费。针对这种情况,我们可以对以上的代码进行改进,使用一种新的设计思想——延迟加载(Lazy-load Singleton)
  1. public final class LazySingleton  
  2. {  
  3.     private static LazySingleton singObj = null;  
  4.   
  5.     private LazySingleton(){  
  6.     }  
  7.   
  8.     public static LazySingleton getSingleInstance(){  
  9.         if(null == singObj ) singObj = new LazySingleton();
  10.           return singObj;
  11.     }  
  12. }  
    这种写法就是所谓的懒汉模式。它使用了延迟加载来保证对象在没有使用之前,是不会进行初始化的。但是,通常这个时候面试官又会提问新的问题来刁难一下。他会问:这种写法线程安全吗?回答必然是:不安全。这是因为在多个线程可能同时运行到第九行,判断singObj为null,于是同时进行了初始化。所以,这是面临的问题是如何使得这个代码线程安全?很简单,在那个方法前面加一个Synchronized就OK了。
  1. public final class ThreadSafeSingleton  
  2. {  
  3.     private static ThreadSafeSingleton singObj = null;  
  4.   
  5.     private ThreadSafeSingleton(){  
  6.     }  
  7.   
  8.     public static Synchronized ThreadSafeSingleton getSingleInstance(){  
  9.         if(null == singObj ) singObj = new ThreadSafeSingleton();
  10.             return singObj;
  11.     }  
  12. }  
    写到这里,面试官可能仍然会狡猾的看了你一眼,继续刁难到:这个写法有没有什么性能问题呢?答案肯定是有的!同步的代价必然会一定程度的使程序的并发度降低。那么有没有什么方法,一方面是线程安全的,有可以有很高的并发度呢?我们观察到,线程不安全的原因其实是在初始化对象的时候,所以,可以想办法把同步的粒度降低,只在初始化对象的时候进行同步。这里有必要提出一种新的设计思想——双重检查锁(Double-Checked Lock)。


人们想通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码:

public class DoubleCheckedLocking {                 //1    private static Instance instance;                    //2    public static Instance getInstance() {               //3        if (instance == null) {                          //4:第一次检查            synchronized (DoubleCheckedLocking.class) {  //5:加锁                if (instance == null)                    //6:第二次检查                    instance = new Instance();           //7:问题的根源出在这里            }                                            //8        }                                                //9        return instance;                                 //10    }                                                    //11}                                                        //12

如上面代码所示,如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此可以大幅降低synchronized带来的性能开销。上面代码表面上看起来,似乎两全其美:

  • 在多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。
  • 在对象创建好之后,执行getInstance()将不需要获取锁,直接返回已创建好的对象。

双重检查锁定看起来似乎很完美,但这是一个错误的优化!在线程执行到第4行代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。

问题的根源

前面的双重检查锁定示例代码的第7行(instance = new Singleton();)创建一个对象。这一行代码可以分解为如下的三行伪代码:

memory = allocate();   //1:分配对象的内存空间ctorInstance(memory);  //2:初始化对象instance = memory;     //3:设置instance指向刚分配的内存地址

上面三行伪代码中的2和3之间,可能会被重排序(在一些JIT编译器上,这种重排序是真实发生的,详情见参考文献1的“Out-of-order writes”部分)。2和3之间重排序之后的执行时序如下:

memory = allocate();   //1:分配对象的内存空间instance = memory;     //3:设置instance指向刚分配的内存地址                       //注意,此时对象还没有被初始化!ctorInstance(memory);  //2:初始化对象
根据《The Java Language Specification, Java SE 7 Edition》(后文简称为java语言规范),所有线程在执行java程序时必须要遵守intra-thread semantics。intra-thread semantics保证重排序不会改变单线程内的程序执行结果。换句话来说,intra-thread semantics允许那些在单线程内,不会改变单线程程序执行结果的重排序。上面三行伪代码的2和3之间虽然被重排序了,但这个重排序并不会违反intra-thread semantics。这个重排序在没有改变单线程程序的执行结果的前提下,可以提高程序的执行性能。

为了更好的理解intra-thread semantics,请看下面的示意图(假设一个线程A在构造对象后,立即访问这个对象):

如上图所示,只要保证2排在4的前面,即使2和3之间重排序了,也不会违反intra-thread semantics。

下面,再让我们看看多线程并发执行的时候的情况。请看下面的示意图:


由于单线程内要遵守intra-thread semantics,从而能保证A线程的程序执行结果不会被改变。但是当线程A和B按上图的时序执行时,B线程将看到一个还没有被初始化的对象。
※注:本文统一用红色的虚箭线标识错误的读操作,用绿色的虚箭线标识正确的读操作

回到本文的主题,DoubleCheckedLocking示例代码的第7行(instance = new Singleton();)如果发生重排序,另一个并发执行的线程B就有可能在第4行判断instance不为null。线程B接下来将访问instance所引用的对象,但此时这个对象可能还没有被A线程初始化!下面是这个场景的具体执行时序:

时间线程A线程Bt1A1:分配对象的内存空间
t2A3:设置instance指向内存空间
t3
B1:判断instance是否为空t4
B2:由于instance不为null,线程B将访问instance引用的对象t5A2:初始化对象
t6A4:访问instance引用的对象

在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化:这里A2和A3虽然重排序了,但java内存模型的intra-thread semantics将确保A2一定会排在A4前面执行。因此线程A的intra-thread semantics没有改变。但A2和A3的重排序,将导致线程B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。此时,线程B将会访问到一个还未初始化的对象。

  1. 不允许2和3重排序;
  2. 允许2和3重排序,但不允许其他线程“看到”这个重排序。

后文介绍的两个解决方案,分别对应于上面这两点。

基于volatile的双重检查锁定的解决方案

对于前面的基于双重检查锁定来实现延迟初始化的方案(指DoubleCheckedLocking示例代码),我们只需要做一点小的修改(把instance声明为volatile型),就可以实现线程安全的延迟初始化。请看下面的示例代码:

public class SafeDoubleCheckedLocking {    private volatile static Instance instance;    public static Instance getInstance() {        if (instance == null) {            synchronized (SafeDoubleCheckedLocking.class) {                if (instance == null)                    instance = new Instance();//instance为volatile,现在没问题了            }        }        return instance;    }}
注意,这个解决方案需要JDK5或更高版本(因为从JDK5开始使用新的JSR-133内存模型规范,这个规范增强了volatile的语义)。

当声明对象的引用为volatile后,“问题的根源”的三行伪代码中的2和3之间的重排序,在多线程环境中将会被禁止。上面示例代码将按如下的时序执行:

这个方案本质上是通过禁止上图中的2和3之间的重排序,来保证线程安全的延迟初始化。

基于类初始化的解决方案

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。

基于这个特性,可以实现另一种线程安全的延迟初始化方案(这个方案被称之为Initialization On Demand Holder idiom):

public class InstanceFactory {    private static class InstanceHolder {        public static Instance instance = new Instance();    }    public static Instance getInstance() {        return InstanceHolder.instance ;  //这里将导致InstanceHolder类被初始化    }}

假设两个线程并发执行getInstance(),下面是执行的示意图:

这个方案的实质是:允许“问题的根源”的三行伪代码中的2和3重排序,但不允许非构造线程(这里指线程B)“看到”这个重排序。

初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。根据java语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化:

  • T是一个类,而且一个T类型的实例被创建;
  • T是一个类,且T中声明的一个静态方法被调用;
  • T中声明的一个静态字段被赋值;
  • T中声明的一个静态字段被使用,而且这个字段不是一个常量字段;
  • T是一个顶级类(top level class,见java语言规范的§7.6),而且一个断言语句嵌套在T内部被执行。

在InstanceFactory示例代码中,首次执行getInstance()的线程将导致InstanceHolder类被初始化(符合情况4)。

由于java语言是多线程的,多个线程可能在同一时间尝试去初始化同一个类或接口(比如这里多个线程可能在同一时刻调用getInstance()来初始化InstanceHolder类)。因此在java中初始化一个类或者接口时,需要做细致的同步处理。

Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了(事实上,java语言规范允许JVM的具体实现在这里做一些优化,见后文的说明)。

对于类或接口的初始化,java语言规范制定了精巧而复杂的类初始化处理过程。java初始化一个类或接口的处理过程如下(这里对类初始化处理过程的说明,省略了与本文无关的部分;同时为了更好的说明类初始化过程中的同步处理机制,笔者人为的把类初始化的处理过程分为了五个阶段):

第一阶段:通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。

假设Class对象当前还没有被初始化(初始化状态state此时被标记为state = noInitialization),且有两个线程A和B试图同时初始化这个Class对象。下面是对应的示意图:

下面是这个示意图的说明:

时间

线程A

线程B

t1

A1:尝试获取Class对象的初始化锁。这里假设线程A获取到了初始化锁

B1:尝试获取Class对象的初始化锁,由于线程A获取到了锁,线程B将一直等待获取初始化锁

t2

A2:线程A看到线程还未被初始化(因为读取到state == noInitialization),线程设置state = initializing

 

t3

A3:线程A释放初始化锁

 

第二阶段:线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待:

下面是这个示意图的说明:

时间

线程A

线程B

t1

A1:执行类的静态初始化和初始化类中声明的静态字段

B1:获取到初始化锁

t2

 

B2:读取到state == initializing

t3

 

B3:释放初始化锁

t4

 

B4:在初始化锁的condition中等待

第三阶段:线程A设置state = initialized,然后唤醒在condition中等待的所有线程:

下面是这个示意图的说明:

时间

线程A

t1

A1:获取初始化锁

t2

A2:设置state = initialized

t3

A3:唤醒在condition中等待的所有线程

t4

A4:释放初始化锁

t5

A5:线程A的初始化处理过程完成

第四阶段:线程B结束类的初始化处理:

下面是这个示意图的说明:

时间

线程B

t1

B1:获取初始化锁

t2

B2:读取到state == initialized

t3

B3:释放初始化锁

t4

B4:线程B的类初始化处理过程完成

线程A在第二阶段的A1执行类的初始化,并在第三阶段的A4释放初始化锁;线程B在第四阶段的B1获取同一个初始化锁,并在第四阶段的B4之后才开始访问这个类。根据java内存模型规范的锁规则,这里将存在如下的happens-before关系:

这个happens-before关系将保证:线程A执行类的初始化时的写入操作(执行类的静态初始化和初始化类中声明的静态字段),线程B一定能看到。

第五阶段:线程C执行类的初始化的处理:

下面是这个示意图的说明:

时间

线程B

t1

C1:获取初始化锁

t2

C2:读取到state == initialized

t3

C3:释放初始化锁

t4

C4:线程C的类初始化处理过程完成

在第三阶段之后,类已经完成了初始化。因此线程C在第五阶段的类初始化处理过程相对简单一些(前面的线程A和B的类初始化处理过程都经历了两次锁获取-锁释放,而线程C的类初始化处理只需要经历一次锁获取-锁释放)。

线程A在第二阶段的A1执行类的初始化,并在第三阶段的A4释放锁;线程C在第五阶段的C1获取同一个锁,并在在第五阶段的C4之后才开始访问这个类。根据java内存模型规范的锁规则,这里将存在如下的happens-before关系:

这个happens-before关系将保证:线程A执行类的初始化时的写入操作,线程C一定能看到。

※注1:这里的condition和state标记是本文虚构出来的。Java语言规范并没有硬性规定一定要使用condition和state标记。JVM的具体实现只要实现类似功能即可。

※注2:Java语言规范允许Java的具体实现,优化类的初始化处理过程(对这里的第五阶段做优化),具体细节参见java语言规范的12.4.2章。

通过对比基于volatile的双重检查锁定的方案和基于类初始化的方案,我们会发现基于类初始化的方案的实现代码更简洁。但基于volatile的双重检查锁定的方案有一个额外的优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。

总结


延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。如果确实需要对实例字段使用线程安全的延迟初始化,请使用上面介绍的基于volatile的延迟初始化的方案;如果确实需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案

上文中提到了对于单例模式的“Initialization On Demand Holder idiom”实现方案。

这个方案的技术实质是利用Java类初始化的LC锁。相比其他实现方案(如double-checked locking等),该技术方案的实现代码较为简洁,并且在所有版本的编译器中都是可行的。该方案代码如下:

1public class InstanceFactory {
2    private static class InstanceHolder {
3        public static Instance instance = new Instance();
4    }
5 
6    public static Instance getInstance() {
7        return InstanceHolder.instance ;  //这里将导致InstanceHolder类被初始化
8    }
9}

根据wikipedia中的解释,“双重检查锁定与延迟初始化”这篇文章中的实现,实际上是Steve Quirk早期的实现,Bill Pugh在其基础上将LazyHolder.INSTANCE的修饰符修改为private static final,实现代码如下:

01public class Something {
02    private Something() {}
03 
04    private static class LazyHolder {
05        private static final Something INSTANCE = new Something();
06    }
07 
08    public static Something getInstance() {
09        return LazyHolder.INSTANCE;
10    }
11}

然而,在另外一篇wikipedia的说明中,LazyHolder.INSTANCE的修饰符则为public static final

01public class Singleton {
02    // Private constructor prevents instantiation from other classes
03    private Singleton() { }
04 
05    /**
06    * SingletonHolder is loaded on the first execution of Singleton.getInstance()
07    * or the first access to SingletonHolder.INSTANCE, not before.
08    */
09    private static class SingletonHolder {
10        public static final Singleton INSTANCE = new Singleton();
11    }
12 
13    public static Singleton getInstance() {
14        return SingletonHolder.INSTANCE;
15    }
16}

那么上述三种实现,究竟哪一种实现较为合理?

在讨论这个问题前,首先我们需要明确的是,上述三种方案都是可以保证线程安全的。
在JLS12.4中关于一个类或接口将被立即初始化的相关说明:

  1. T is a class and an instance of T is created.
  2. T is a class and a static method declared by T is invoked.
  3. A static field declared by T is assigned.
  4. A static field declared by T is used and the field is not a constant variable (§4.12.4).
  5. T is a top level class (§7.6), and an assert statement (§14.10) lexically nested within T (§8.1.3) is executed.

显然,上述任意一种实现首次执行Singleton.getInstance()时,由于使用InstanceHolder.instance变量,将会导致InstanceHolder初始化,符合第四条。注意final关键字修饰的变量,并不一定是constant variable。引用JLS4.12.4中的相关说明:

A variable of primitive type or type String, that is final and initialized with a compile-time constant expression (§15.28), is called a constant variable.

只有基本类型或者是String类型的变量,才可能成为constant variable。
回到刚才的问题,在stackoverflow上有相关问题的回答。

  1. 首先,关于final关键字:
    http://stackoverflow.com/questions/20995036/is-initialization-on-demand-holder-idiom-thread-safe-without-a-final-modifier
    根据该问题的两个回答,不论有无final关键字,事实上都可以保证线程安全,final关键的作用仅在于防止对INSTANCE做修改。
  2. 然后是关于INSTANCE的访问权限问题:
    http://stackoverflow.com/questions/21604243/correct-implementation-of-initialization-on-demand-holder-idiom
    这里提出的观点是建议如下形式的实现:

    1public class Singleton {
    2    ...
    3    private static class LazyHolder {
    4        static final Singleton INSTANCE = new Singleton();
    5    }
    6    public static Singleton getInstance() {
    7        return LazyHolder.INSTANCE;
    8    }
    9}

    即并不在INSTANCE变量前添加public或者private修饰,即使用默认的package private访问权限。
    不用public的原因是这个修饰不够合理,作者认为事实上在LazyHolder是被限定为private的情况下,使用public来修饰INSTANCE并没有问题,然而如果有人修改LazyHolder为public,那么就可能出现问题。
    不用private的原因是,Java编译器中会保证类内部的private字段无法被外部访问,然而JLS6.6.1中有相应的规定:

Otherwise, if the member or constructor is declared private, then access is permitted if and only if it occurs within the body of the top level class (§7.6) that encloses the declaration of the member or constructor.

所以外部类拥有对内部类中private字段的访问权限,那么在这个情况下,编译器就会有一些小技巧来保证外部类对内部类private字段的访问权限,即在内部类中插入packge private method,使得外部类调用这些getter和setter方法的形式来访问内部类的private字段。

综上所述,从线程安全的角度而言,上述的几种“Initialization On Demand Holder idiom”实现都是没有问题的。如果讨论细节上的合理性,那么更推荐最后一种实现,即:

1public class Singleton {
2    ...
3    private static class LazyHolder {
4        static final Singleton INSTANCE = new Singleton();
5    }
6    public static Singleton getInstance() {
7        return LazyHolder.INSTANCE;
8    }
9}
0 0
原创粉丝点击