从单例模式到Happens-Before

本文已同步到http://liumian.win/2016/12/14/fromsingletontohappens-before/java

本文主要从简单的单例模式为切入点,分析单例模式可能存在的一些问题,以及如何借助Happens-Before分析、检验代码在多线程环境下的安全性。程序员

知识准备

为了后面叙述方便,也为了读者理解文章的须要,先在这里解释一下牵涉到的知识点以及相关概念。编程

线程内表现为串行的语义

Within Thread As-If-Serial Semantics缓存

定义

普通的变量仅仅会保证在该方法的执行过程当中全部依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操做的顺序与程序代码中的执行顺序一致。安全

举个小栗子

看代码多线程

int a = 1;
int b = 2;
int c = a + b;

你们看完代码没准就猜到我想要说什么了。 假如没有重排序这个东西,CPU确定会按照从上往下的执行顺序执行:先执行 a = 1、而后b = 2、最后c = a + b,这也符合咱们的阅读习惯。 可是,上文也说起了:CPU为了提升运行效率,在执行时序上不会按照刚刚所说的时序执行,颇有多是b = 2 a = 1 c = a + b。对,由于只须要在变量c须要变量a``b的时候可以获得正确的值就好了,JVM容许这样的行为。 这种现象就是线程内表现为串行的语义并发

重排序

定义

指令重排序 为了提升运行效率,CPU容许讲多条指令不按照程序规定的顺序分开发送给各相应电路单元处理。 这里须要注意的是指令重排序并非将指令任意的发送给电路单元,而是须要知足线程内表现为串行的语义app

现象

参照线程内表现为串行的语义一节中举的小栗子。函数

注意任何代码都有可能出现指令重排序的现象,与是否多线程条件下无关。在单线程内感觉不到是由于单线程内会有线程内表现为串行的语义的限制。性能

Happens-Before(先行发生)

什么是Happens-Before

Happens-Before原则是判断数据是否存在竞争、线程是否安全的主要依据

为了叙述方便,若是操做X Happens-Before 操做Y,那么咱们记为 hb(X,Y)。

若是存在hb(a,b),那么操做a在内存上面所作的操做(如赋值操做等)都对操做b可见,即操做a影响了操做b。

  • 是Java内存模型中定义的两项操做之间的偏序关系,知足偏序关系的各项性质 咱们都知道偏序关系中有一条很重要的性质:传递性,因此Happens-Before也知足传递性。这个性质很是重要,经过这个性质能够推导出两个没有直接联系的操做之间存在Happens-Before关系,如: 若是存在hb(a,b)和hb(b,c),那么咱们能够推导出hb(a,c),即操做a Happens-Before 操做c。

  • 是判断数据是否存在竞争、线程是否安全的主要依据 这是《深刻理解Java虚拟机》,375页的例子

    i = 1;		//在线程A中执行
    
    	j = i;		//在线程B中执行
    
    	i = 2;		//在线程C中执行

假设线程A中的操做i = 1先行发生线程B的操做j = i,那么能够肯定在线程B的操做执行后,变量j的值必定等于1,得出这个结论的依据有两个:一是根据先行发生原则,i = 1的结果能够被观察到;二是线程C尚未“登场“,线程A操做结束以后没有其余的线程会修改变量i的值。如今再来考虑线程C,咱们依然保持线程A和线程B之间的先行发生关系,而线程C出如今线程A和线程B的操做之间,可是线程C与线程B没有先行发生关系,那j的值会是多少呢?答案是不肯定!1和2都有可能,由于线程C对变量i的影响可能会被线程观察到,也可能不会,这时候线程B就存在读取到过时数据的风险,不具有多线程安全性。 经过这个例子我相信读者对Happens-Before已经有了必定的了解。

这里再重复一下Happens-Before的做用: 若是存在hb(a,b),那么操做a在内存上面所作的操做(如赋值操做等)都对操做b可见,即操做a影响了操做b。

Java 原生存在的Happens-Before

这些是Java 内存模型下存在的原生Happens-Before关系,无需借助任何同步器协助就已经存在,能够在编码中直接使用。

  1. 程序次序规则(Program Order Rule) 在一个线程内,按照程序代码顺序,书写在前面的操做Happens-Before书写在后面的操做

  2. 管程锁定规则(Monitor Lock Rule) An unlock on a monitor happens-before every subsequent lock on that monitor. 一个unlock操做Happens-Before后面对同一个锁的lock操做。

  3. volatile变量规则(volatile Variable Rule) A write to a volatile field happens-before every subsequent read of that volatile. 对一个volatile变量的写入操做Happens-Before后面对这个变量的读操做。

  4. 线程启动规则(Thread Start Rule) Thread对象的start()方法Happens-Before此线程的每个动做。

  5. 线程终止规则(Thread Termination Rule) 线程中的全部操做都Happens-Before对此线程的终止检测。

  6. 线程中断规则(Thread Interruption Rule) 对线程interrupt()方法的调用Happens-Before被中断线程的代码检测到中断事件的发生,能够经过Thread.interrupt()方法检测到是否有中断发生。

  7. 对象终结规则(Finalizer Rule) 一个对象的初始化完成(构造函数执行结束)Happens-Before它的finalize()方法的开始。

  8. 传递性(Transitivity) 偏序关系的传递性:若是已知hb(a,b)和hb(b,c),那么咱们能够推导出hb(a,c),即操做a Happens-Before 操做c。

这些规则都很好理解,在这里就不进行过多的解释了。 Java语言中无需任何同步手段保障就能成立的先行发生规则就只有上面这些了。

还存在其它的Happens-Before吗

Java中原生知足Happens-Before关系的规则就只有上述8条,可是咱们还能够经过它们推导出其它的知足Happens-Before的操做,如:

  • 将一个元素放入一个线程安全的队列的操做Happens-Before从队列中取出这个元素的操做
  • 将一个元素放入一个线程安全容器的操做Happens-Before从容器中取出这个元素的操做
  • 在CountDownLatch上的倒数操做Happens-Before CountDownLatch#await()操做
  • 释放Semaphore许可的操做Happens-Before得到许可操做
  • Future表示的任务的全部操做Happens-Before Future#get()操做
  • 向Executor提交一个Runnable或Callable的操做Happens-Before任务开始执行操做

若是两个操做之间不存在上述的Happens-Before规则中的任意一条,而且也不能经过已有的Happens-Before关系推到出来,那么这两个操做之间就没有顺序性的保障,虚拟机能够对这两个操做进行重排序!

重要的事情说三遍:若是存在hb(a,b),那么操做a在内存上面所作的操做(如赋值操做等)都对操做b可见,即操做a影响了操做b。

volatile

初学者很容易将synchronizedvolatile混淆,因此在这里有必要再二者的做用说明一下。 一谈起多线程编程咱们每每会想到原子性可见性,其实还有一个有序性经常被你们忘记。其实也不怪你们,由于只要可以保证原子性可见性,就基本上可以保证有序性了,因此经常被你们忽略。

  • 原子性 是指某个操做要么执行完要不不执行,不会出现执行到一半的状况。 synchronized和java.util.concurrent包中的锁都可以保证操做的原子性。

  • 可见性 即上一个操做所作的更改是否对下一个操做可见,注意:这里讨论的顺序是指时间上的顺序。

    • 一个被volatile修饰的变量可以保证任意一个操做所作的更改都可以对下一个操做可见
    • 上一条中讨论的原子操做都能对下一次相同的原子操做可见

    能够参照Happens-Before原则的第2、第三条规则

  • 有序性 Java中的有序性能够归纳成一句话: 若是再本线程内观察,全部的操做都是有序的;若是再一个线程中观察另外一个线程,全部的操做都是无序的。 前半句是指线程内表现为串行的语义(Within Thread As-If-Serial Semantics),后半句是指指令重排序现象和工做内存与主内存同步延迟现象。 首先volatile关键字自己就包含了禁止指令重排序的语义,而synchronized(及其它的锁)是经过“一个变量在同一时刻只容许一条线程对其进行lock操做”这条规则得到的,这条规则决定了持有同一个锁的两个同步块智能串行的进入。 注意:指令重排序在任什么时候候都有可能发生,与是否为多线程无关,之因此在单线程下感受没有发生重排序,是由于线程内表现为串行的语义的存在。

volatile如何保证可见性

可见性问题的由来

你们都知道CPU的处理速度很是快,快到内存都没法跟上CPU的速度并且差距很是大,而这个地方不加以处理一般会成为CPU效率的瓶颈,为了消除速度差带来的影响,CPU一般自带了缓存:一级、二级甚至三级缓存(咱们能够在电脑描述信息上面看到)。JVM也是出于一样的道理给每一个线程分配了工做内存(Woking Memory,注意:不是主内存)。咱们要知道线程对变量的修改都会反映到工做内存中,而后JVM找一个合适的时刻将工做内存上的更改同步到主内存中。正是因为线程更改变量到工做内存同步到主内存中存在一个时间差,因此这里会形成数据一致性问题,这就是可见性问题的由来。

volatile采起的措施

volatile采起的措施其实很好理解:只要被volatile修饰的变量被更改就当即同步到主内存,同时其它线程的工做内存中变量的值失效,使用时必须从主内存中读取。 换句话说,线程的工做内存“不缓存”被volatile修饰的变量。

volatile如何禁止重排序

这个问题稍稍有点复杂,要结合汇编代码观察有无volatile时的区别。 下面结合《深刻理解Java虚拟机》第370页的例子(本想本身生成汇编代码,无奈操做有点复杂): DCL及汇编代码 图中标红的lock指令是只有在被volatile修饰时才会出现,至于做用,书中是这样解释的:这个操做至关于一个内存屏障(Memory Barrier,重排序时不能把后面的指令重排序到内存屏障以前的位置),只有一个CPU访问内存时,并不须要内存屏障;但若是有两个或者更多CPU访问同一块内存,且其中有一个在观测另外一个,就须要内存屏障来保证一致性了。 重复一下:指令重排序在任什么时候候都有可能发生,与是否为多线程无关,之因此在单线程下感受没有发生重排序,是由于线程内表现为串行的语义的存在。

分析双重检测锁(DCL)

哎,说了这么久终于到了双重检测锁(Double Check Lock,DCL)了,口水都说干了。你们是否是火烧眉毛的读下去了呢,嗯,我也火烧眉毛的写下去了。

这篇文章用happen-before规则从新审视DCL的做者在开头说到:

虽然99%的Java程序员都知道DCL不对,可是若是让他们回答一些问题,DCL为何不对?有什么修正方法?这个修正方法是正确的吗?若是不正确,为何不正确?对于此类问题,他们一脸茫然,或者回答也许吧,或者很自信但其实并无抓住根本。

我以为很对,记得一年前学习单例模式时,我也不懂为何要加上volatile关键字,只是依葫芦画瓢跟着你们分析了一番,其实当时是不知道缘由的。我相信有不少程序员也是我那时的心态。(偷笑

为了叙述方便,先把DCL的示例代码放在这里,后面分析时须要用到

/**
 * Created by liumian on 2016/12/13.
 */
public class DCL {
    
    private static volatile DCL instance;
    
    private int status;
    
    private DCL(){
        status = 1;                         //1
    }
    
    private DCL getInstance(){
        if (instance == null){              //2
            synchronized (DCL.class){       //3
                if (instance == null){      //4
                    instance = new DCL();   //5
                }
            }
        }
        return instance;                    //6
    }
    
    public int getStatus(){
        return status;                      //7
    }
}

在volatile的视角审视DCL

若是获取实例的方法使用synchronized修饰

private synchronized DCL getInstance()

这样在多线程下确定是没有问题的并且不须要加volatile修饰变量,可是会丧失部分性能,由于每次调用方法获取实例时JVM都须要执行monitorenter、monitorexit指令来进入和推出同步块,而咱们真正须要同步的时刻只有一个:第一次建立实例,其他由于同步而花费的时间纯属浪费。因此缩小同步范围成为了提升性能的手段:只须要在建立实例时进行同步!因而将synchronized放入第一个if判断语句中并在同步代码块中在进行一次判空操做。那么问题来了: 假如没有volatile修饰变量会怎样? 你们可能会说应该没啥问题啊,就是一行代码嘛:建立一个对象并把引用赋值给变量。没错,在咱们看来就是一行代码,它的功能也很简单,可是,可是对于JVM来讲可没那么简单了,至少有三个步骤(指令):

  1. 在堆中开辟一块内存(new)
  2. 而后调用对象的构造函数对内存进行初始化(invokespecial)
  3. 最后将引用赋值给变量(astore)

情形是否是跟上面重排序的例子很类似了呢?没错,假如没有volatile修饰,这些操做有可能发生重排序!JVM有可能这样作:

  1. 先在堆中开辟一块内存(new)
  2. 立刻将引用赋值给变量(astore)
  3. 最后才是调用对象的构造方法进行初始化(invokespecial)

好像在单线程下仍是没问题,那咱们把问题放在多线程状况下考虑(结合上面的DCL示例代码): 假设有两条线程:T一、T2,当前时刻T1执行到语句一、T2执行到语句4,有可能会发生下面这个执行时序:

  1. T2先执行,执行到语句5,可是此时JVM将三条指令进行了重排序:在时间上先执行new、astore、最后才是invokespecial
  2. 执行线程T2的CPU刚刚执行完new、astore指令,尚未来得及执行invokespecial指令就被切换出去了
  3. 线程T1如今登场了,执行 if (instance == null),由于线程T2已经执行了astore指令:将引用赋值给了变量,因此该判断语句有可能返回为false。若是返回为false,那么成功拿到对象引用。由于该引用所指向的内存地址尚未进行初始化(执行invokespecial指令),因此只要调用对象的任何方法,就会出错(会不会是NullPointerException?)

这就是不加volatile修饰为何出错的一个过程。这时候有同窗就会有疑问,按道理我不加volatile其它线程应该对我刚刚所作的修改(赋值操做)不可见才对呀。若是同窗们这么想,我猜刚刚必定是把你们绕糊涂了:线程作的修改不该该对其它线程可见么?应该可见才对,理应可见。而volatile只是保证了可见性,就算没有它,可见性依然存在(不会保证必定可见)。

若是不了解volatile在DCL中的做用,很容易漏写volatile。这是我查资料时在百度百科上面发现的: 百度百科单例模式无volatile

后面我给它加上去了:

加上volatile

利用Happens-Before分析DCL

通过前面的铺垫终于到了本片博客的第二个主题:利用Happens-Before分析DCL。

先举个例子

在这篇文章中(happens-before俗解),做者说起到没有volatile修饰的DCL是不安全的,缘由是(为了读者阅读方便,特将原文章的解释结合本文的代码):语句1和语句7之间不存在Happens-Before的关系,大意是构造方法与普通方法之间不存在Happens-Before关系。为何该篇文章做者提出这样的观点?咱们来分析一下(注意此时没有volatile修饰): 先抛出一个问题:语句7和哪些语句存在Happens-Before关系? 我认为在线程T1中语句2与语句7存在Happens-Before关系,为何?(这里只考虑发生线程安全问题的状况,若是执行到语句4了,就必定不会出现线程安全问题)请参照Happens-Before的第一条规则:程序次序规则(Program Order Rule),在一个线程内,按照程序代码顺序,书写在前面的操做Happens-Before 书写在后面的操做。准确的说,应该是控制流顺序而不是程序代码顺序,由于要考虑分支、循环等结构。 而语句2与语句7知足第一条规则,由于要执行语句7必须得语句2返回为false才能获取到对象的实例。而后语句2与语句6存在Happens-Before关系,缘由同上。根据偏序关系的传递性,语句7与语句6存在Happens-Before关系,此外不再能推出其它语句与语句7之间是否存在Happens-Before关系了,读者能够尝试推导一下。由于语句7与语句1,换句话说,普通方法与构造方法之间不存在Happens-Before关系,就算构造方法执行了,调用普通方法(如本例的getStatus())也依然有可能得不到正确的返回值!JVM不保证构造方法所作的更改对普通方法(如本例的getStatus())可见!

volatile对Happens-Before的影响

既然咱们已经找到无volatile的DCL出现线程安全问题的缘由了,解决起来就很轻松了,最简单的一个办法就是用volatile关键字修饰单例对象。(难道还有不使用volatile的解决办法?嗯,固然有,具体操做请留意后续博客)

如今咱们来分析一下拥有volatile修饰的DCL带来了哪些不一样? 最显著的变化就是给变量(instance)带来了Happens-Before关系!请参考Happens-Before的第三条规则:volatile变量规则(Volatile Variable Rule),对一个volatile变量的写操做Happens-Before后面对这个变量的读操做,这里的“后面”指的是时间上的前后顺序。

有了volatile的加持,咱们就能够推导出语句2 Happens-Before 语句5,只要执行了instance = new DCL();必定会被语句2instance == null观察到。读者此时可能又有疑问,上面就是由于语句5对语句2“可见”才出现问题的呀?怎么如今由于一样的缘由反倒变成线程安全的了?别急,听我慢慢分析。嗯,刚刚的“可见”是打了双引号的,其实并非整个语句5对语句2可见,而是语句5中的一条指令 - astore对语句2可见,并不包含invokespecial指令!由于volatile具备禁止重排序的语义,因此invokespecial必定在astore前面执行,换句话说构造方法必定在赋值语句以前执行,因此存在hb(语句1,语句5),又由于hb(语句5,语句2)、hb(语句2,语句7),因此推出hb(语句1,语句7) ——语句1 Happens-Before 语句7。如今将本例中的getStatus()方法和构造方法连接起来了,同理能够推出构造方法Happens-Before其它普通方法。

总结

本文分为两部分。

第一部分

介绍了这几个知识点及相关概念:

  • 线程内表现为串行的语义
  • 重排序
  • Happens-Before

第二部分

经过两个角度(volatile、Happens-Before)对双重检测锁(DCL)进行了分析,分析为何无volatile时会存在线程安全问题:

  • volatile 由于指令重排序,而形成尚未构造完成就将对象发布了
  • Happens-Before 由于普通方法与构造方法之间不存在Happens-Before关系

双重检测锁(DCL)所出现的安全问题的根本缘由是对象没有正确(安全)的发布出去。 而解决这个问题的一种简单的方法就是使用volatile关键字修饰单例对象,从而解决线程安全问题。 读者可能会问,听你这么说,难道还有其它解决办法?我在上面也提到过,确实是还有其它方法,请留意后续博客,我将给你们带来不使用volatile关键字而保证线程安全的另外一种方法。

参考资料

相关文章
相关标签/搜索