從單例模式到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關係,無需借助任何同步器協助就已經存在,可以在編碼中直接使用。
- 程序次序規則(Program Order Rule) 在一個線程內,按照程序代碼順序,書寫在前麵的操作Happens-Before書寫在後麵的操作
- 管程鎖定規則(Monitor Lock Rule) An unlock on a monitor happens-before every subsequent lock on that monitor. 一個unlock操作Happens-Before後麵對同一個鎖的lock操作。
- volatile變量規則(volatile Variable Rule) A write to a volatile field happens-before every subsequent read of that volatile. 對一個volatile變量的寫入操作Happens-Before後麵對這個變量的讀操作。
- 線程啟動規則(Thread Start Rule) Thread對象的start()方法Happens-Before此線程的每一個動作。
- 線程終止規則(Thread Termination Rule) 線程中的所有操作都Happens-Before對此線程的終止檢測。
- 線程中斷規則(Thread Interruption Rule) 對線程interrupt()方法的調用Happens-Before被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupt()方法檢測到是否有中斷發生。
- 對象終結規則(Finalizer Rule) 一個對象的初始化完成(構造函數執行結束)Happens-Before它的finalize()方法的開始。
- 傳遞性(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
初學者很容易將synchronized
和volatile
混淆,所以在這裏有必要再兩者的作用說明一下。 一談起多線程編程我們往往會想到原子性
、可見性
,其實還有一個有序性
常常被大家忘記。其實也不怪大家,因為隻要能夠保證原子性
和可見性
,就基本上能夠保證有序性
了,所以常常被大家忽略。
- 原子性 是指某個操作要麼執行完要不不執行,不會出現執行到一半的情況。 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頁的例子(本想自己生成匯編代碼,無奈操作有點複雜): 圖中標紅的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來說可沒那麼簡單了,至少有三個步驟(指令):
- 在堆中開辟一塊內存(new)
- 然後調用對象的構造函數對內存進行初始化(invokespecial)
- 最後將引用賦值給變量(astore)
情形是不是跟上麵重排序的例子很相似了呢?沒錯,假如沒有volatile修飾,這些操作有可能發生重排序!JVM有可能這樣做:
- 先在堆中開辟一塊內存(new)
- 馬上將引用賦值給變量(astore)
- 最後才是調用對象的構造方法進行初始化(invokespecial)
好像在單線程下還是沒問題,那我們把問題放在多線程情況下考慮(結合上麵的DCL示例代碼): 假設有兩條線程:T1、T2,當前時刻T1執行到語句1、T2執行到語句4,有可能會發生下麵這個執行時序:
- T2先執行,執行到語句5,但是此時JVM將三條指令進行了重排序:在時間上先執行new、astore、最後才是invokespecial
- 執行線程T2的CPU剛剛執行完new、astore指令,還沒有來得及執行invokespecial指令就被切換出去了
- 線程T1現在登場了,執行
if (instance == null)
,因為線程T2已經執行了astore指令:將引用賦值給了變量,所以該判斷語句有可能返回為false。如果返回為false,那麼成功拿到對象引用。因為該引用所指向的內存地址還沒有進行初始化(執行invokespecial指令),所以隻要調用對象的任何方法,就會出錯(會不會是NullPointerException?)
這就是不加volatile修飾為什麼出錯的一個過程。這時候有同學就會有疑問,按道理我不加volatile其它線程應該對我剛剛所做的修改(賦值操作)不可見才對呀。如果同學們這麼想,我猜剛剛一定是把大家繞煳塗了:線程做的修改不應該對其它線程可見麼?應該可見才對,理應可見。而volatile隻是保證了可見性,就算沒有它,可見性依然存在(不會保證一定可見)。
如果不了解volatile在DCL中的作用,很容易漏寫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關鍵字而保證線程安全的另一種方法。
參考資料
- 《深入理解Java虛擬機》第二版
- 《Java並發編程實戰》
- 用happen-before規則重新審視DCL
- happens-before俗解
最後更新:2017-05-19 10:31:12