從單例模式到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關鍵字而保證線程安全的另外一種方法。

參考資料

相關文章
相關標籤/搜索