從單例模式到Happens-Before

本文主要從簡單的單例模式爲切入點,分析單例模式可能存在的一些問題,以及如何藉助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允許講多條指令不按照程序規定的順序分開發送給各相應電路單元處理。 這裏需要注意的是指令重排序並不是將指令任意的發送給電路單元,而是需要滿足線程內表現爲串行的語義

現象

參照線程內表現爲串行的語義一節中舉的小栗子。

注意任何代碼都有可能出現指令重排序的現象,與是否多線程條件下無關。在單線程內感受不到是因爲單線程內會有線程內表現爲串行的語義的限制。

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原則的第二、第三條規則
  • 有序性 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示例代碼): 假設有兩條線程:T1、T2,當前時刻T1執行到語句1、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關鍵字而保證線程安全的另一種方法。

發佈了36 篇原創文章 · 獲贊 12 · 訪問量 6萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章