摘要:基于的雙重檢查鎖定的解決方案對于前面的基于雙重檢查鎖定來實現延遲初始化的方案指示例代碼,我們只需要做一點小的修改把聲明為型,就可以實現線程安全的延遲初始化。
雙重檢查鎖定的由來
在java程序中,有時候可能需要推遲一些高開銷的對象初始化操作,并且只有在使用這些對象時才進行初始化。此時程序員可能會采用延遲初始化。但要正確實現線程安全的延遲初始化需要一些技巧,否則很容易出現問題。比如,下面是非線程安全的延遲初始化對象的示例代碼:
public class UnsafeLazyInitialization { private static Instance instance; public static Instance getInstance() { if (instance == null) //1:A線程執行 instance = new Instance(); //2:B線程執行 return instance; } }
在UnsafeLazyInitialization中,假設A線程執行代碼1的同時,B線程執行代碼2。此時,線程A可能會看到instance引用的對象還沒有完成初始化(出現這種情況的原因見后文的“問題的根源”)。
對于UnsafeLazyInitialization,我們可以對getInstance()做同步處理來實現線程安全的延遲初始化。示例代碼如下:
遲初始化。示例代碼如下:
public class SafeLazyInitialization { private static Instance instance; public synchronized static Instance getInstance() { if (instance == null) instance = new Instance(); return instance; } }
由于對getInstance()做了同步處理,synchronized將導致性能開銷。如果getInstance()被多個線程頻繁的調用,將會導致程序執行性能的下降。反之,如果getInstance()不會被多個線程頻繁的調用,那么這個延遲初始化方案將能提供令人滿意的性能。
在早期的JVM中,synchronized(甚至是無競爭的synchronized)存在這巨大的性能開銷。因此,人們想出了一個“聰明”的技巧:雙重檢查鎖定(double-checked locking)。人們想通過雙重檢查鎖定來降低同步的開銷。下面是使用雙重檢查鎖定來實現延遲初始化的示例代碼:
public class DoubleCheckedLocking { //1 private static Instance instance; //2 public static Instance getInstance() { //3 if (instance == null) { //4:第一次檢查 synchronized (DoubleCheckedLocking.class) { //5:加鎖 if (instance == null) //6:第二次檢查 instance = new Instance(); //7:問題的根源出在這里 } //8 } //9 return instance; //10 } //11 }
如上面代碼所示,如果第一次檢查instance不為null,那么就不需要執行下面的加鎖和初始化操作。因此可以大幅降低synchronized帶來的性能開銷。上面代碼表面上看起來,似乎兩全其美:
在多個線程試圖在同一時間創建對象時,會通過加鎖來保證只有一個線程能創建對象。
在對象創建好之后,執行getInstance()將不需要獲取鎖,直接返回已創建好的對象。
雙重檢查鎖定看起來似乎很完美,但這是一個錯誤的優化!在線程執行到第4行代碼讀取到instance不為null時,instance引用的對象有可能還沒有完成初始化。
問題的根源前面的雙重檢查鎖定示例代碼的第7行(instance = new Singleton();)創建一個對象。這一行代碼可以分解為如下的三行偽代碼:
memory = allocate(); //1:分配對象的內存空間 ctorInstance(memory); //2:初始化對象 instance = memory; //3:設置instance指向剛分配的內存地址
上面三行偽代碼中的2和3之間,可能會被重排序(在一些JIT編譯器上,這種重排序是真實發生的,詳情見參考文獻1的“Out-of-order writes”部分)。2和3之間重排序之后的執行時序如下:
memory = allocate(); //1:分配對象的內存空間 instance = memory; //3:設置instance指向剛分配的內存地址 //注意,此時對象還沒有被初始化! ctorInstance(memory); //2:初始化對象
根據《The Java Language Specification, Java SE 7 Edition》(后文簡稱為java語言規范),所有線程在執行java程序時必須要遵守intra-thread semantics。intra-thread semantics保證重排序不會改變單線程內的程序執行結果。換句話來說,intra-thread semantics允許那些在單線程內,不會改變單線程程序執行結果的重排序。上面三行偽代碼的2和3之間雖然被重排序了,但這個重排序并不會違反intra-thread semantics。這個重排序在沒有改變單線程程序的執行結果的前提下,可以提高程序的執行性能。
為了更好的理解intra-thread semantics,請看下面的示意圖(假設一個線程A在構造對象后,立即訪問這個對象):
如上圖所示,只要保證2排在4的前面,即使2和3之間重排序了,也不會違反intra-thread semantics。
下面,再讓我們看看多線程并發執行的時候的情況。請看下面的示意圖:
由于單線程內要遵守intra-thread semantics,從而能保證A線程的程序執行結果不會被改變。但是當線程A和B按上圖的時序執行時,B線程將看到一個還沒有被初始化的對象。
※注:本文統一用紅色的虛箭線標識錯誤的讀操作,用綠色的虛箭線標識正確的讀操作。
回到本文的主題,DoubleCheckedLocking示例代碼的第7行(instance = new Singleton();)如果發生重排序,另一個并發執行的線程B就有可能在第4行判斷instance不為null。線程B接下來將訪問instance所引用的對象,但此時這個對象可能還沒有被A線程初始化!下面是這個場景的具體執行時序:
|時間 | 線程A| 線程B|
|t1| A1:分配對象的內存空間||
|t2| A3:設置instance指向內存空間|
|t3|| B1:判斷instance是否為空|
|t4|| B2:由于instance不為null,線程B將訪問instance引用的對象|
|t5| A2:初始化對象||
|t6| A4:訪問instance引用的對象||
這里A2和A3雖然重排序了,但java內存模型的intra-thread semantics將確保A2一定會排在A4前面執行。因此線程A的intra-thread semantics沒有改變。但A2和A3的重排序,將導致線程B在B1處判斷出instance不為空,線程B接下來將訪問instance引用的對象。此時,線程B將會訪問到一個還未初始化的對象。
在知曉了問題發生的根源之后,我們可以想出兩個辦法來實現線程安全的延遲初始化:
不允許2和3重排序;
允許2和3重排序,但不允許其他線程“看到”這個重排序。
后文介紹的兩個解決方案,分別對應于上面這兩點。
對于前面的基于雙重檢查鎖定來實現延遲初始化的方案(指DoubleCheckedLocking示例代碼),我們只需要做一點小的修改(把instance聲明為volatile型),就可以實現線程安全的延遲初始化。請看下面的示例代碼:
public class SafeDoubleCheckedLocking { private volatile static Instance instance; public static Instance getInstance() { if (instance == null) { synchronized (SafeDoubleCheckedLocking.class) { if (instance == null) instance = new Instance();//instance為volatile,現在沒問題了 } } return instance; } }
注意,這個解決方案需要JDK5或更高版本(因為從JDK5開始使用新的JSR-133內存模型規范,這個規范增強了volatile的語義)。
當聲明對象的引用為volatile后,“問題的根源”的三行偽代碼中的2和3之間的重排序,在多線程環境中將會被禁止。上面示例代碼將按如下的時序執行:
這個方案本質上是通過禁止上圖中的2和3之間的重排序,來保證線程安全的延遲初始化。
基于類初始化的解決方案JVM在類的初始化階段(即在Class被加載后,且被線程使用之前),會執行類的初始化。在執行類的初始化期間,JVM會去獲取一個鎖。這個鎖可以同步多個線程對同一個類的初始化。
基于這個特性,可以實現另一種線程安全的延遲初始化方案(這個方案被稱之為Initialization On Demand Holder idiom):
public class InstanceFactory { private static class InstanceHolder { public static Instance instance = new Instance(); } public static Instance getInstance() { return InstanceHolder.instance ; //這里將導致InstanceHolder類被初始化 } }
假設兩個線程并發執行getInstance(),下面是執行的示意圖:
這個方案的實質是:允許“問題的根源”的三行偽代碼中的2和3重排序,但不允許非構造線程(這里指線程B)“看到”這個重排序。
初始化一個類,包括執行這個類的靜態初始化和初始化在這個類中聲明的靜態字段。根據java語言規范,在首次發生下列任意一種情況時,一個類或接口類型T將被立即初始化:
T是一個類,而且一個T類型的實例被創建;
T是一個類,且T中聲明的一個靜態方法被調用;
T中聲明的一個靜態字段被賦值;
T中聲明的一個靜態字段被使用,而且這個字段不是一個常量字段;
T是一個頂級類(top level class,見java語言規范的§7.6),而且一個斷言語句嵌套在T內部被執行。
在InstanceFactory示例代碼中,首次執行getInstance()的線程將導致InstanceHolder類被初始化(符合情況4)。
由于java語言是多線程的,多個線程可能在同一時間嘗試去初始化同一個類或接口(比如這里多個線程可能在同一時刻調用getInstance()來初始化InstanceHolder類)。因此在java中初始化一個類或者接口時,需要做細致的同步處理。
Java語言規范規定,對于每一個類或接口C,都有一個唯一的初始化鎖LC與之對應。從C到LC的映射,由JVM的具體實現去自由實現。JVM在類初始化期間會獲取這個初始化鎖,并且每個線程至少獲取一次鎖來確保這個類已經被初始化過了(事實上,java語言規范允許JVM的具體實現在這里做一些優化,見后文的說明)。
對于類或接口的初始化,java語言規范制定了精巧而復雜的類初始化處理過程。java初始化一個類或接口的處理過程如下(這里對類初始化處理過程的說明,省略了與本文無關的部分;同時為了更好的說明類初始化過程中的同步處理機制,筆者人為的把類初始化的處理過程分為了五個階段):
第一階段:通過在Class對象上同步(即獲取Class對象的初始化鎖),來控制類或接口的初始化。這個獲取鎖的線程會一直等待,直到當前線程能夠獲取到這個初始化鎖。
假設Class對象當前還沒有被初始化(初始化狀態state此時被標記為state = noInitialization),且有兩個線程A和B試圖同時初始化這個Class對象。下面是對應的示意圖:
下面是這個示意圖的說明:
|時間| 線程A| 線程B|
|t1| A1:嘗試獲取Class對象的初始化鎖。這里假設線程A獲取到了初始化鎖| B1:嘗試獲取Class對象的初始化鎖,由于線程A獲取到了鎖,線程B將一直等待獲取初始化鎖|
|t2| A2:線程A看到線程還未被初始化(因為讀取到state == noInitialization),線程設置state = initializing||
|t3| A3:線程A釋放初始化鎖||
第二階段:線程A執行類的初始化,同時線程B在初始化鎖對應的condition上等待:
下面是這個示意圖的說明:
|時間| 線程A| 線程B|
|t1| A1:執行類的靜態初始化和初始化類中聲明的靜態字段| B1:獲取到初始化鎖|
|t2|| B2:讀取到state == initializing|
|t3|| B3:釋放初始化鎖|
|t4|| B4:在初始化鎖的condition中等待|
第三階段:線程A設置state = initialized,然后喚醒在condition中等待的所有線程:
下面是這個示意圖的說明:
|時間| 線程A|
|t1| A1:獲取初始化鎖|
|t2| A2:設置state = initialized|
|t3| A3:喚醒在condition中等待的所有線程|
|t4| A4:釋放初始化鎖|
|t5| A5:線程A的初始化處理過程完成|
第四階段:線程B結束類的初始化處理:
下面是這個示意圖的說明:
|時間| 線程B|
|t1| B1:獲取初始化鎖|
|t2| B2:讀取到state == initialized|
|t3| B3:釋放初始化鎖|
|t4| B4:線程B的類初始化處理過程完成|
線程A在第二階段的A1執行類的初始化,并在第三階段的A4釋放初始化鎖;線程B在第四階段的B1獲取同一個初始化鎖,并在第四階段的B4之后才開始訪問這個類。根據java內存模型規范的鎖規則,這里將存在如下的happens-before關系:
這個happens-before關系將保證:線程A執行類的初始化時的寫入操作(執行類的靜態初始化和初始化類中聲明的靜態字段),線程B一定能看到。
第五階段:線程C執行類的初始化的處理:
下面是這個示意圖的說明:
|時間| 線程C|
|t1| C1:獲取初始化鎖|
|t2| C2:讀取到state == initialized|
|t3| C3:釋放初始化鎖|
|t4| C4:線程C的類初始化處理過程完成|
在第三階段之后,類已經完成了初始化。因此線程C在第五階段的類初始化處理過程相對簡單一些(前面的線程A和B的類初始化處理過程都經歷了兩次鎖獲取-鎖釋放,而線程C的類初始化處理只需要經歷一次鎖獲取-鎖釋放)。
線程A在第二階段的A1執行類的初始化,并在第三階段的A4釋放鎖;線程C在第五階段的C1獲取同一個鎖,并在在第五階段的C4之后才開始訪問這個類。根據java內存模型規范的鎖規則,這里將存在如下的happens-before關系:
這個happens-before關系將保證:線程A執行類的初始化時的寫入操作,線程C一定能看到。
※注1:這里的condition和state標記是本文虛構出來的。Java語言規范并沒有硬性規定一定要使用condition和state標記。JVM的具體實現只要實現類似功能即可。
※注2:Java語言規范允許Java的具體實現,優化類的初始化處理過程(對這里的第五階段做優化),具體細節參見java語言規范的12.4.2章。
通過對比基于volatile的雙重檢查鎖定的方案和基于類初始化的方案,我們會發現基于類初始化的方案的實現代碼更簡潔。但基于volatile的雙重檢查鎖定的方案有一個額外的優勢:除了可以對靜態字段實現延遲初始化外,還可以對實例字段實現延遲初始化。
總結延遲初始化降低了初始化類或創建實例的開銷,但增加了訪問被延遲初始化的字段的開銷。在大多數時候,正常的初始化要優于延遲初始化。如果確實需要對實例字段使用線程安全的延遲初始化,請使用上面介紹的基于volatile的延遲初始化的方案;如果確實需要對靜態字段使用線程安全的延遲初始化,請使用上面介紹的基于類初始化的方案。
參考文獻Double-checked locking and the Singleton pattern
The Java Language Specification, Java SE 7 Edition
JSR-133: Java Memory Model and Thread Specification
Java Concurrency in Practice
Effective Java (2nd Edition)
JSR 133 (Java Memory Model) FAQ
The JSR-133 Cookbook for Compiler Writers
Java theory and practice: Fixing the Java Memory Model, Part 2
感謝方騰飛對本文的審校。
by 程曉明 via ifeve
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/64056.html
摘要:注意,禁止指令重排序在之后才被修復使用局部變量優化性能重新查看中雙重檢查鎖定代碼。幫助文檔雙重檢查鎖定與延遲初始化有關雙重檢查鎖定失效的說明 雙重檢查鎖定(Double check locked)模式經常會出現在一些框架源碼中,目的是為了延遲初始化變量。這個模式還可以用來創建單例。下面來看一個 Spring 中雙重檢查鎖定的例子。 showImg(https://segmentfaul...
摘要:非線程安全的雙重檢查鎖這里看起來很完美,但是是一個錯誤的優化,代碼在讀取到不為的時候,引用的對象有可能換沒有完成初始化,這樣返回的是有問題的。 在Java多線程程序中,有時需要采用延遲初始化來降低初始化類和創建對象的開銷,雙重檢查鎖定是常見的延遲初始化技術,但它是一種錯誤的用法 雙重檢查鎖的演進以及問題 使用syncronized實現 public synchronized stati...
摘要:雙重檢查鎖定以下稱為已被廣泛當做多線程環境下延遲初始化的一種高效手段。由于沒有對這些做出明確規定,很難說是否有效。可以在中使用顯式的內存屏障來使生效,但中并沒有這些屏障。如果改變鎖釋放的語義釋放時執行一個雙向的內存屏障將會帶來性能損失。 雙重檢查鎖定(以下稱為DCL)已被廣泛當做多線程環境下延遲初始化的一種高效手段。 showImg(http://segmentfault.com/i...
摘要:對于而言,它執行的是一個個指令。在指令中創建對象和賦值操作是分開進行的,也就是說語句是分兩步執行的。此時線程打算使用實例,卻發現它沒有被初始化,于是錯誤發生了。 1.餓漢式單例 public class Singleton { private static Singleton instance = new Singleton(); ...
閱讀 3096·2021-09-28 09:42
閱讀 3448·2021-09-22 15:21
閱讀 1122·2021-07-29 13:50
閱讀 3564·2019-08-30 15:56
閱讀 3367·2019-08-30 15:54
閱讀 1196·2019-08-30 13:12
閱讀 1172·2019-08-29 17:03
閱讀 1198·2019-08-29 10:59