单例模式(DCL缺陷以及如何安全发布对象)

1. 单例模式分为懒汉式和饿汉式,那么饿汉式如何在多线程情况下保证单例对象的安全发布呢?

    1.1 同步延迟加载

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

1.2 DCL(Double Check Lock)

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

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;
 }
}

1.3 DCL误区

   编写Java多线程程序一直以来都是一件十分困难的事,多线程程序的Bug很难测试,DCL就是一个典型。传统上对多线程程序的分析是通过分析操作之间可能的执行先后顺序,然而程序执行顺序十分复杂,它与硬件系统架构,编译器,缓存以及虚拟机的实现都有着很大的关系。

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

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。这条规则也称为传递规
  4. 对volatile字段的写操作happen-before后续的对同一个字段的读操作.(Java5 新增)

当说操作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规则分析DCL

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


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,我需要预先陈述上面程序运行时几个事实:语句(5)只会被执行一次,也就是LazySingleton只会存在一个实例,这是由于它和语句(4)被放在同步块中被执行的缘故,如果去掉语句(3)处的同步块,那么这个假设便不成立了。 instance只有两种“曾经可能存在”的值,要么为null,也就是初始值,要么为执行语句(5)时构造的对象引用。这个结论由事实1很容易推出来。 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修正

利用happen-before规则4,我们可以将instance声明为volatile,即:

	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中就能够正确运行了。

总结:所以修正可以是将整个对象的引用声明为volatile,也可以将对象里面的每个元素声明为final来解决不完全发布对象的问题

Java 1.5 关于Final的 特殊含义

class FinalFieldExample {
    final int x;
    int y;
    static FinalFieldExample f;

    public FinalFieldExample() {
      x = 3;
      y = 4;
    }

    static void writer() {
      f = new FinalFieldExample();
    }

    static void reader() {
      if (f != null) {
        int i = f.x; // guaranteed to see 3        
        int j = f.y; // could see 0
      }
    }
}


 

final修饰对象成员时,可以保证当对象发布时候final已经被赋予构造函数中赋予的值了,如上例中的x,y则可能对象发布时候并未完成初始化。

Java并发编程实战中,安全发布对象4中情况总结

为了安全地发布对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确创建的对象可以通过以下条件安全地发布

1. 通过静态初始化器初始化对象的引用

2.将它的引用存储到volatile域或AtomicReference;

3.将它的引用存储到正确创建的对象的final域中;

4.或者将它的引用存储到由锁正确保护的域中。

     在线程安全容器内部的同步意味着,在将对象放入到某个容器中,例如Vector时,将满足上述最后一条需求。如果线程A将对象X放入一个线程安全的容器,随后线程B读取这个对象,那么确保B看到A设置的X状态,即便在这段读/写X的应用程序代码中没有包含显示的同步。

      ①通过将一个键或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地将他发布给任何从这些容器中访问它的线程。

      ②通过将某个元素放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或synchronizedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程

     ③通过将某个元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该元素安全的发布到任何从这些队列中访问该元素的线程

    ④类库中的其他数据传递机制(例如Future和Exchanger)同样能实现安全发布。

下面我分析一下vector,ConcurrentHashMap,CopyOnWriteArrayList三种典型的容器为什么能安全的发布对象。

      1)vector:vector的add和get方法都有synchronized关键字同步,根据happen-before关于lock的第二条,能够保证对象的引用和对象的状态(即完全初始化)在unlock之前全部搞定,另外线程在读的时候先获取锁,之前的unlock操作happen-before  lock操作,因此在get操作中,调用lock获取锁以后,就能知悉add线程所做的全部操作,包括对象初始化完毕

      2)CopyOnWriteArrayList,这个容器是每次修改操作就会更新容器里面持有的volatile修饰的数组,这样对象的初始化happen-before 对volatile的写,volatile对象的写happen-before另外线程的对volatile对象的读,当然能安全发布了

      3)ConCurrentHashMap最为复杂,需要对源码有较深研究,这样暂时抛砖引玉,大致说一下:

          ①修改操作是put,并且key值原来存在,不改变桶里面结构,只是Entry对象的value进行更改,这个时候因为value是volatile修饰的,用happen-before推倒,新加的value对象当然能够正常发布

        ②修改了桶中Entry数组的某个元素,即结构变了。这种情况下,ConcurrentHashMap具有弱一致性,可能看不到修改,或者看到修改了,但是新加入的Entry的value对象为空。至于看不到修改,忍了!但是看到了修改,但是value为空是怎么产生的呢?——见我对ConCurrentHashMap的分析,为什么要调用

readValueUnderLock(e)方法。为空的话,直接加锁读,当然回到vector里面,解决了Value对象的安全发布问题,如果value不为空,为何可以确保value对象是安全发布呢?因为value不是默认的null了,而value是volatile修饰的,说明修改线程已经执行完对volatile修饰的value对象的write操作,而现在是该对象的read操作,根据happen-before操作,在根据Value对象的初始化happen-before于value的write操作,可知对象能够正常发布。

下面解释一下java并发编程实战中总结出来4中安全发布对象的方法

1.不需要解释了,JVM对类的初始化保证了这一点

2.将对象引用存储到volatile域:这样对象的初始化happen-before volatile的写,volatile的写happen-before对其的读,则保证了读线程看到完全发布的对象,可以安全发布

3.将它的引用存储到正确创建的对象的final域中:因为当这个对象被发布时候,它的final域肯定已经初始化了(java5 新增对final关键词的支持),所以可以保证读线程看到我们要发布对象完全初始化后的结果,从而安全发布

4. 它的引用存储到由锁正确保护的域中:这样读线程要读取的时候首先是lock操作,而写线程的unlock是happen-before读线程的lock操作,要发布对象的初始化happen-before写线程的unlock操作,因此可以安全发布。

单例模式使用内部类实现延迟加载
为了做到真正的延迟加载,除了修正版的双重检测,还可以借助于另一类的类加载延迟加载

public class Singleton {
 private Singleton() {}
 public static class Holder {
  // 这里的私有没有什么意义
  /* private */static Singleton instance = new Singleton();
 }
 public static Singleton getInstance() {
  // 外围类能直接访问内部类(不管是否是静态的)的私有变量
  return Holder.instance;
 }
}

最后总结一点:happen-before原则是Java程序中,分析并发问题的一把利刃!

 














评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值