CP-08-单例模式与双重检测

延迟加载

延迟加载就是等到真真使用的时候才去创建实例,不用时不要去创建。

单例模式可以有非延迟加载和延迟加载方式实现。

  • 从速度和反应时间角度来讲,非延迟加载(又称饿汉式)好
  • 从资源利用效率上说,延迟加载(又称懒汉式)好。

下面看看几种常见的单例的设计方式:

1. 单例嵌套类实现(常用)

利用的原理是:一个类直到被使用时才被初始化,而类初始化的过程是非并行的,这些都由JLS保证。

1
2
3
4
5
6
7
8
9
10
11
public class Singleton3 {

private Singleton3() {}
// 主要是使用了 嵌套类可以访问外部类的静态属性和静态方法 的特性
private static class Holder {
private static Singleton3 instance = new Singleton3();
}
public static Singleton3 getInstance() {
return Holder.instance;
}
}

2. 非延迟加载/饿汉式单例类

1
2
3
4
5
6
7
8
9
10
11
public class Singleton {  
// 1. 私有构造方法
private Singleton() {}

// 2. 初始化单例类
private static final Singleton instance = new Singleton();

public static Singleton getInstance() {
return instance;
}
}

3. 同步延迟加载

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {  
private static Singleton instance = null;

private Singleton() {}

public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

4. 双重检测同步延迟加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {  

private volatile static Singleton instance = null;

private Singleton() {}

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {// 1
if (instance == null) {// 2
instance = new Singleton();// 3
}
}
}
return instance;
}
}

此种方式我们需要对 instance 进行第二次检查,目的是避开过多的同步。因为这里的同步只需在第一次创建实例时才同步,一旦创建成功,以后获取实例时就不需要同获取锁了,因为同步块外面的if (instance == null)可能看到已存在,但不完整的实例。JDK5.0以后版本若instance为volatile则是可行的。

双重检测锁定失败-无序写入

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

为解释该问题,需要重新考察上述清单中的 //3 行。此行代码创建了一个 Singleton 对象并初始化变量 instance 来引用此对象。这行代码的问题是:在 Singleton 构造函数体执行之前,变量 instance 可能成为非 null 的,即赋值语句在对象实例化之前调用,此时别的线程得到的是一个还未初始化的对象,这样会导致系统崩溃。

这一说法可能让您始料未及,但事实确实如此。在解释这个现象如何发生前,请先暂时接受这一事实,我们先来考察一下双重检查锁定是如何被破坏的。假设代码执行以下事件序列:

  1. 线程 1 进入 getInstance() 方法。
  2. 由于 instance 为 null,线程 1 在 //1 处进入 synchronized 块。
  3. 线程 1 前进到 //3 处,但在构造函数执行之前,使实例成为非 null。
  4. 线程 1 被线程 2 预占。
  5. 线程 2 检查实例是否为 null。因为实例不为 null,线程 2 将 instance 引用返回给一个构造完整但部分初始化了的 Singleton 对象。
  6. 线程 2 被线程 1 预占。
  7. 线程 1 通过运行 Singleton 对象的构造函数并将引用返回给它,来完成对该对象的初始化。

为展示此事件的发生情况,假设代码行 instance =new Singleton(); 执行了下列伪代码:

1
2
3
mem = allocate();             //为单例对象分配内存空间.
instance = mem; //注意,instance 引用现在是非空,但还未初始化
ctorSingleton(instance); //为单例对象通过instance调用构造函数

这段伪代码不仅是可能的,而且是一些 JIT 编译器上真实发生的。执行的顺序是颠倒的,但鉴于当前的内存模型,这也是允许发生的。JIT 编译器的这一行为使双重检查锁定的问题只不过是一次学术实践而已。

在JAVA2(以jdk1.2开始)以前对于实例字段是直接在主储区读写的。所以当一个线程对resource进行分配空间、初始化和调用构造方法时,可能在其它线程中对分配空间动作可见了,而初始化和调用构造方法还没有完成。

但是从JAVA2以后,JMM发生了根本的改变,分配空间、初始化、调用构造方法只会在线程的工作存储区完成,在没有向主存储区复制赋值时,其它线程绝对不可能见到这个过程。而这个字段复制到主存区的过程,更不会有分配空间后没有初始化或没有调用构造方法的可能。

在JAVA中,一切都是按引用的值复制的。向主存储区同步其实就是把线程工作存储区的这个已经构造好的对象有压缩堆地址值COPY给主存储区的那个变量。这个过程对于其它线程,要么是resource为null,要么是完整的对象。绝对不会把一个已经分配空间却没有构造好的对象让其它线程可见。

用happen-before规则重新审视DCL

编写Java多线程程序一直以来都是一件十分困难的事,多线程程序的bug很难测试,DCL(Double Check Lock)就是一个典型,因此对多线程安全的理论分析就显得十分重要,当然这决不是说对多线程程序的测试就是不必要的。传统上,对多线程程序的分析是通过分析操作之间可能的执行先后顺序,然而程序执行顺序十分复杂,它与硬件系统架构,编译器,缓存以及虚拟机的实现都有着很大的关系。仅仅为了分析多线程程序就需要了解这么多底层知识确实不值得,况且当年选择学Java就是因为不用理会烦人的硬件和操作系统,这导致了许多Java程序员不愿也不能从理论上分析多线程程序的正确性。虽然99%的Java程序员都知道DCL不对,但是如果让他们回答一些问题,DCL为什么不对?有什么修正方法?这个修正方法是正确的吗?如果不正确,为什么不正确?对于此类问题,他们一脸茫然,或者回答也许吧,或者很自信但其实并没有抓住根本。

幸好现在还有另一条路可走,我们只需要利用几个基本的happen-before规则就能从理论上分析Java多线程程序的正确性,而且不需要涉及到硬件和编译器的知识。接下来的部分,我会首先说明一下happen-before规则,然后使用happen-before规则来分析DCL,最后我以我自己的例子来说明DCL的问题其实很常见,只是因为对DCL的过度关注反而忽略其问题本身,当然其忽略是有原因的,因为很多人并不知道DCL的问题到底出在哪里。

Happen-Before规则

我们一般说一个操作happen-before另一个操作,这到底是什么意思呢?当说操作A happen-before操作B时,我们其实是在说在发生操作B之前,操作A对内存施加的影响能够被观测到。所谓“对内存施加的影响”就是指对变量的写入,“被观测到”指当读取这个变量时能够得到刚才写入的值(如果中间没有发生其它的写入)。听起来很绕口?这就对了,请你保持耐心,举个例子来说明一下。线程Ⅰ执行了操作A:x=3,线程Ⅱ执行了操作B:y=x。如果操作Ahappen-before操作B,线程Ⅱ在执行操作B之前就确定操作”x=3”被执行了,它能够确定,是因为如果这两个操作之间没有任何对x的写入的话,它读取x的值将得到3,这意味着线程Ⅱ执行操作B会写入y的值为3。如果两个操作之间还有对x的写入会怎样呢?假设线程Ⅲ在操作A和B之间执行了操作C: x=5,并且操作C和操作B之前并没有happen-before关系(后面我会说明时间上的先后并不一定导致happen-before关系)。这时线程Ⅱ执行操作B会讲到x的什么值呢?3还是5?答案是两者皆有可能,这是因为happen-before关系保证一定 能够观测到前一个操作施加的内存影响,只有时间上的先后关系而并没有happen-before关系可能但并不保证 能观测前一个操作施加的内存影响。如果读到了值3,我们就说读到了“陈旧 ”的数据。正是多种可能性导致了多线程的不确定性和复杂性,但是要分析多线程的安全性,我们只能分析确定性部分,这就要求找出happen-before关系,这又得利用happen-before规则。

下面是我列出的三条非常重要的happen-before规则,利用它们可以确定两个操作之间是否存在happen-before关系。

  1. 同一个线程中,书写在前面的操作happen-before书写在后面的操作。这条规则是说,在单线程 中操作间happen-before关系完全是由源代码的顺序决定的,这里的前提“在同一个线程中”是很重要的,这条规则也称为单线程规则 。这个规则多少说得有些简单了,考虑到控制结构和循环结构,书写在后面的操作可能happen-before书写在前面的操作,不过我想读者应该明白我的意思。
  2. 对锁的unlock操作happen-before后续的对同一个锁的lock操作。这里的“后续”指的是时间上的先后关系,unlock操作发生在退出同步块之后,lock操作发生在进入同步块之前。这是条最关键性的规则,线程安全性主要依赖于这条规则。但是仅仅是这条规则仍然不起任何作用,它必须和下面这条规则联合起来使用才显得意义重大。这里关键条件是必须对“同一个锁”的lock和unlock。
  3. 如果操作A happen-before操作B,操作B happen-before操作C,那么操作A happen-before操作C。这条规则也称为传递规则。

现在暂时放下happen-before规则,先探讨一下“一个操作在时间上先于另一个操作发生”和“一个操作happen-before另一个操作之间”的关系。两者有关联却并不相同。关联部分在第2条happen-before规则中已经谈到了,通常我们得假定一个时间上的先后顺序然后据此得出happen-before关系。不同部分体现在,首先,一个操作在时间上先于另一个操作发生,并不意味着一个操作happen-before另一个操作 。看下面的例子:

1
2
3
4
5
6
7
public void setX(int x) {  
this.x = x; // (1)
}

public int getX() {
return x; // (2)
}

假设线程Ⅰ先执行setX方法,接着线程Ⅱ执行getX方法,在时间上线程Ⅰ的操作A:this.x = x先于线程Ⅱ的操作B:return x。但是操作A却并不happen-before操作B,让我们逐条检查三条happen-before规则。第1条规则在这里不适用,因为这时两个不同的线程。第2条规则也不适用,因为这里没有任何同步块,也就没有任何lock和unlock操作。第3条规则必须基于已经存在的happen-before关系,现在没有得出任何happen-before关系,因此第三条规则对我们也任何帮助。通过检查这三条规则,我们就可以得出,操作A和操作B之间没有happen-before关系。这意味着如果线程Ⅰ调用了setX(3),接着线程Ⅱ调用了getX(),其返回值可能不是3,尽管两个操作之间没有任何其它操作对x进行写入,它可能返回任何一个曾经存在的值或者默认值0。“任何曾经存在的值”需要做点解释,假设在线程Ⅰ调用setX(3)之前,还有别的线程或者就是线程Ⅰ还调用过setX(5), setX(8),那么x的曾经可能值为0, 5和8(这里假设setX是唯一能够改变x的方法),其中0是整型的默认值,用在这个例子中,线程Ⅱ调用getX()的返回值可能为0, 3, 5和8,至于到底是哪个值是不确定的。

现在将两个方法都设成同步的,也就是如下:

1
2
3
4
5
6
7
public synchronized void setX(int x) {  
this.x = x; // (1)
}

public synchronized int getX() {
return x; // (2)
}

做同样的假设,线程Ⅰ先执行setX方法,接着线程Ⅱ执行getX方法,这时就可以得出来,线程Ⅰ的操作A happen-before线程Ⅱ的操作B。下面我们来看如何根据happen-before规则来得到这个结论。由于操作A处于同步块中,操作A之后必须定要发生对this锁的unlock操作,操作B也处于同步块中,操作B之前必须要发生对this锁的lock操作,根据假设unlock操作发生lock操作之前,根据第2条happen-before规则,就得到unlock操作happen-before于lock操作;另外根据第1条happen-before规则(单线程规则),操作A happen-before于unlock操作,lock操作happen-before于操作B;最后根据第3条happen-before规则(传递规则),A -> unlock, unlock -> lock, lock -> B(这里我用->表示happen-before关系),有 A -> B,也就是说操作A happen-before操作B。这意味着如果线程Ⅰ调用了setX(3),紧接着线程Ⅱ调用了getX(),如果中间再没有其它线程改变x的值,那么其返回值必定是3。

如果将两个方法的任何一个synchronized关键字去掉又会怎样呢?这时能不能得到线程Ⅰ的操作A happen-before线程Ⅱ的操作B呢?答案是得不到。这里因为第二条happen-before规则的条件已经不成立了,这时因为要么只有线程Ⅰ的unlock操作(如果去掉getX的synchronized),要么只有线程Ⅱ的lock操作(如果去掉setX的synchronized关键字)。这里也告诉我们一个原则,必须对同一个变量的 所有 读写同步,才能保证不读取到陈旧的数据,仅仅同步读或写是不够的 。

其次,一个操作happen-before另一个操作 也并不意味着 一个操作在时间上先于另一个操作发生 。看下面的例子:

1
2
x = 3;      (1)  
y = 2; (2)

同一个线程执行上面的两个操作,操作A:x = 3和操作B:y = 2。根据单线程规则,操作A happen-before操作B,但是操作A却不一定在时间上先于操作B发生,这是因为编译器的重新排序等原因,操作B可能在时间上后于操作B发生。这个例子也说明了,分析操作上先后顺序是多么地不靠谱,它可能完全违反直观感觉。

最后,一个操作和另一个操作必定存在某个顺序,要么一个操作或者是先于或者是后于另一个操作,或者与两个操作同时发生。同时发生是完全可能存在的,特别是在多CPU的情况下。而两个操作之间却可能没有happen-before关系,也就是说有可能发生这样的情况,操作A不happen-before操作B,操作B也不happen-before操作A,用数学上的术语happen-before关系是个偏序关系。两个存在happen-before关系的操作不可能同时发生,一个操作A happen-before操作B,它们必定在时间上是完全错开的,这实际上也是同步的语义之一(独占访问)。

在运用happen-before规则分析DCL之前,有必要对“操作”澄清一下,在前面的叙述中我一直将语句是操作的同义词,这么讲是不严格的,严格上来说这里的操作应该是指单个虚拟机的指令,如moniterenter, moniterexit, add, sub, store, load等。使用语句来代表操作并不影响我们的分析,下面我仍将延续这一传统,并且将直接用语句来代替操作。唯一需要注意的是单个语句实际上可能由多个指令组成,比如语句x=i++由两条指令(inc和store)组成。现在我们已经完成了一切理论准备,你一定等不及要动手开干了(我都写烦了)。

利用Happen-Before规则分析DCL

下面是一个典型的使用DCL的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class LazySingleton {  
private int someField;

private static LazySingleton instance;

private LazySingleton() {
this.someField = new Random().nextInt(200)+1; // (1)
}

public static LazySingleton getInstance() {
if (instance == null) { // (2)
synchronized(LazySingleton.class) { // (3)
if (instance == null) { // (4)
instance = new LazySingleton(); // (5)
}
}
}
return instance; // (6)
}

public int getSomeField() {
return this.someField; // (7)
}
}

为了分析DCL,我需要预先陈述上面程序运行时几个事实:

  1. 语句(5)只会被执行一次,也就是LazySingleton只会存在一个实例,这是由于它和语句(4)被放在同步块中被执行的缘故,如果去掉语句(3)处的同步块,那么这个假设便不成立了。
  2. instance只有两种“曾经可能存在”的值,要么为null,也就是初始值,要么为执行语句(5)时构造的对象引用。这个结论由事实1很容易推出来。
  3. getInstance()总是返回非空值,并且每次调用返回相同的引用。如果getInstance()是初次调用,它会执行语句(5)构造一个LazySingleton实例并返回,如果getInstance()不是初次调用,如果不能在语句(2)处检测到非空值,那么必定将在语句(4)处就能检测到instance的非空值,因为语句(4)处于同步块中,对instance的写入–语句(5)也处于同一个同步块中。

有读者可能要问了,既然根据第3条事实getInstance()总是返回相同的正确的引用,为什么还说DCL有问题呢?这里的关键是 尽管得到了LazySingleton的正确引用,但是却有可能访问到其成员变量 的 不正确值 ,具体来说LazySingleton.getInstance().getSomeField()有可能返回someField的默认值0。如果程序行为正确的话,这应当是不可能发生的事,因为在构造函数里设置的someField的值不可能为0。为也说明这种情况理论上有可能发生,我们只需要说明语句(1)和语句(7)并不存在happen-before关系。

假设线程Ⅰ是初次调用getInstance()方法,紧接着线程Ⅱ也调用了getInstance()方法和getSomeField()方法,我们要说明的是线程Ⅰ的语句(1)并不happen-before线程Ⅱ的语句(7)。线程Ⅱ在执行getInstance()方法的语句(2)时,由于对instance的访问并没有处于同步块中,因此线程Ⅱ可能观察到也可能观察不到线程Ⅰ在语句(5)时对instance的写入,也就是说instance的值可能为空也可能为非空。我们先假设instance的值非空,也就观察到了线程Ⅰ对instance的写入,这时线程Ⅱ就会执行语句(6)直接返回这个instance的值,然后对这个instance调用getSomeField()方法,该方法也是在没有任何同步情况被调用,因此整个线程Ⅱ的操作都是在没有同步的情况下调用 ,这时我们无法利用第1条和第2条happen-before规则得到线程Ⅰ的操作和线程Ⅱ的操作之间的任何有效的happen-before关系,这说明线程Ⅰ的语句(1)和线程Ⅱ的语句(7)之间并不存在happen-before关系,这就意味着线程Ⅱ在执行语句(7)完全有可能观测不到线程Ⅰ在语句(1)处对someFiled写入的值,这就是DCL的问题所在。很荒谬,是吧?DCL原本是为了逃避同步,它达到了这个目的,也正是因为如此,它最终受到惩罚,这样的程序存在严重的bug,虽然这种bug被发现的概率绝对比中彩票的概率还要低得多,而且是转瞬即逝,更可怕的是,即使发生了你也不会想到是DCL所引起的。

前面我们说了,线程Ⅱ在执行语句(2)时也有可能观察空值,如果是种情况,那么它需要进入同步块,并执行语句(4)。在语句(4)处线程Ⅱ还能够读到instance的空值吗?不可能。这里因为这时对instance的写和读都是发生在同一个锁确定的同步块中,这时读到的数据是最新的数据。为也加深印象,我再用happen-before规则分析一遍。线程Ⅱ在语句(3)处会执行一个lock操作,而线程Ⅰ在语句(5)后会执行一个unlock操作,这两个操作都是针对同一个锁–LazySingleton.class,因此根据第2条happen-before规则,线程Ⅰ的unlock操作happen-before线程Ⅱ的lock操作,再利用单线程规则,线程Ⅰ的语句(5) -> 线程Ⅰ的unlock操作,线程Ⅱ的lock操作 -> 线程Ⅱ的语句(4),再根据传递规则,就有线程Ⅰ的语句(5) -> 线程Ⅱ的语句(4),也就是说线程Ⅱ在执行语句(4)时能够观测到线程Ⅰ在语句(5)时对LazySingleton的写入值。接着对返回的instance调用getSomeField()方法时,我们也能得到线程Ⅰ的语句(1) -> 线程Ⅱ的语句(7),这表明这时getSomeField能够得到正确的值。但是仅仅是这种情况的正确性并不妨碍DCL的不正确性,一个程序的正确性必须在所有的情况下的行为都是正确的,而不能有时正确,有时不正确。

对DCL的分析也告诉我们一条经验原则,对引用(包括对象引用和数组引用)的非同步访问,即使得到该引用的最新值,却并不能保证也能得到其成员变量(对数组而言就是每个数组元素)的最新值。

再稍微对DCL探讨一下,这个例子中的LazySingleton是一个不变类,它只有get方法而没有set方法。由对DCL的分析我们知道,即使一个对象是不变的,在不同的线程中它的同一个方法也可能返回不同的值 。之所以会造成这个问题,是因为LazySingleton实例没有被安全发布,所谓“被安全的发布”是指所有的线程应该在同步块中获得这个实例。这样我们又得到一个经验原则,即使对于不可变对象,它也必须被安全的发布,才能被安全地共享。 所谓“安全的共享”就是说不需要同步也不会遇到数据竞争的问题。在Java5或以后,将someField声明成final的,即使它不被安全的发布,也能被安全地共享,而在Java1.4或以前则必须被安全地发布。

关于DCL的修正

既然理解了DCL的根本原因,或许我们就可以修正它。

既然原因是线程Ⅱ执行getInstance()可能根本没有在同步块中执行,那就将整个方法都同步吧。这个毫无疑问是正确的,但是这却回到最初的起点(返朴归真了),也完全违背了DCL的初衷,尽可能少的减少同步。虽然这不能带任何意义,却也说明一个道理,最简单的往往是最好的。

如果我们尝试不改动getInstance()方法,而是在getSomeField()上做文章,那么首先想到的应该是将getSomeField设置成同步,如下所示:

1
2
3
public synchronized int getSomeField() {  
return this.someField; // (7)
}

这种修改是不是正确的呢?答案是不正确。这是因为,第2条happen-before规则的前提条件并不成立。语句(5)所在同步块和语句(7)所在同步块并不是使用同一个锁。像下面这样修改才是对的:

1
2
3
4
5
public int getSomeField() {  
synchronized(LazySingleton.class) {
return this.someField;
}
}

但是这样的修改虽然能保证正确性却不能保证高性能。因为现在每次读访问getSomeField()都要同步,如果使用简单的方法,将整个getInstance()同步,只需要在getInstance()时同步一次,之后调用getSomeField()就不需要同步了。另外getSomeField()方法也显得很奇怪,明明是要返回实例变量却要使用Class锁。这也再次验证了一个道理,简单的才是好的。

步入Java5

前面所讲的都是基于Java1.4及以前的版本,java5对内存模型作了重要的改动,其中最主要的改动就是对volatile和final语义的改变。本文使用的happen-before规则实际上是从Java5中借鉴而来,然后再移花接木到Java1.4中,因此也就不得不谈下Java5中的多线程了。

在java 5中多增加了一条happen-before规则:

对volatile字段的写操作happen-before后续的对同一个字段的读操作。
利用这条规则我们可以将instance声明为volatile,即:

1
private volatile static LazySingleton instance;  

根据这条规则,我们可以得到,线程Ⅰ的语句(5) -> 语线程Ⅱ的句(2),根据单线程规则,线程Ⅰ的语句(1) -> 线程Ⅰ的语句(5)和语线程Ⅱ的句(2) -> 语线程Ⅱ的句(7),再根据传递规则就有线程Ⅰ的语句(1) -> 语线程Ⅱ的句(7),这表示线程Ⅱ能够观察到线程Ⅰ在语句(1)时对someFiled的写入值,程序能够得到正确的行为。

在java5之前对final字段的同步语义和其它变量没有什么区别,在java5中,final变量一旦在构造函数中设置完成(前提是在构造函数中没有泄露this引用),其它线程必定会看到在构造函数中设置的值。而DCL的问题正好在于看到对象的成员变量的默认值,因此我们可以将LazySingleton的someField变量设置成final,这样在java5中就能够正确运行了。

http://www.iteye.com/topic/260515