前置說明
這是一篇我自已關于使用雙重檢查鎖存在的問題,以及volatile語義在其中的作用的了解。
是一篇個人記錄性文章,可能在語言描述上有些直白,不會引用太多官方專業術語解釋或說明。
有不正确的地方,也請留言指正。
下面開始正文:
一個單例實作
假如現在有一個Earth(地球)類,需要提供一個它的懶漢式單例實作,一種正常寫法如下:
public class Earth {
private static Earth earth = null;
public static Earth getInstance() {
if (earth == null) {
earth = new Earth();
}
return earth;
}
}
這種寫法在單線程環境下,是完全沒有問題的,不用考慮并發問題,不會存在臨界區。
遺憾的是,實際情況,我們的電腦CPU一般是多核的,程式也往往都是需要多線程并發運作。在這樣的環境,如果多條線程同時執行到這個方法内,這段代碼可能new了多個對象,重複配置設定記憶體。同樣,單例模式,在實際應用中也可能伴随着複雜的業務邏輯初始化,是不會允許這種重複初始化發生。
那麼解決的方案,就是需要同步操作。比如:在這個方法上加鎖
public static synchronized Earth getInstance() {
if (earth == null) {
earth = new Earth();
}
return earth;
}
當然,在不考慮性能的情況下,這個代碼一定能保證線程安全,當最先拿到鎖的線程,進入這個同步方法内,如果發現earth對象為空,便會構造一個對象并指派,後續其它線程進入之後,對象已經建立。根據synchronized的語義,這裡不會有任何問題。
但是,實際開發過程中,是期望鎖的粒度盡量小,減少鎖等待的阻塞時間,是以,把synchronized放到方法體内,對部分代碼同步操作。就是接下來說的,雙重檢查鎖。
雙重檢查鎖
一個錯誤的代碼範例:
public class Earth {
private static Earth earth = null;
public static Earth getInstance() {
if (earth == null) {
synchronized (Earth.class) {
if (earth == null) {
earth = new Earth();
}
}
}
return earth;
}
}
這個就是雙重檢查鎖,首先判斷earth是否為空,如果為空,進入同步塊,再判斷是否為空,還為空,便new個新對象給它。這樣其它線程進入這個方法體内的時候,如果發現這個對象已經建立,不為空,便不會阻塞在同步塊裡等待,而是直接執行下面的代碼,當然了,示例上下面的代碼沒什麼邏輯,就是直接傳回earth執行個體了。
p.s. 同步塊的鎖對象是不建議使用Earth.class,即所屬類的類對象的鎖,範圍太大,其實靜态方法同步塊等可能也會用這個。最好是建立一個新對象用作鎖對象。
當然,上面這個代碼示例是不正确,因為聲明的earth屬性,沒有使用volatile關鍵字。是以,這個雙檢鎖是有問題的,為什麼有問題,下面說明。
我也曾如此錯誤的使用
我剛畢業的時候,對于java的一些規範了解的也不夠,寫雙檢鎖就是上面這樣寫的,像聲明earth屬性的時候,也不懂得要使用volatile。某天,我在寫代碼的時候,一個前輩看見我寫雙檢鎖的時候,給我說應該加上volatile關鍵字來聲明這個要建立的單例對象屬性。我就問,為什麼。他給我說了一個,對于當時的我完全不能了解的專業術語,避免“重排序”。更多的也沒告訴我,隻是說了句在一些資料上見到過重排序問題。說的支支吾吾,我聽的也迷迷糊糊!
在之後的一些時光中,我也看了不少JVM相關的資料或書籍,當然了,随着時間過了這麼久,我現在其實記不太清那段時間看的書裡有哪些具體内容了。隻記得寫雙檢鎖要加上volatile聲明。
正确的寫法範式
public class Earth {
private static volatile Earth earth = null;
public static Earth getInstance() {
if (earth == null) {
synchronized (Earth.class) {
if (earth == null) {
earth = new Earth();
}
}
}
return earth;
}
}
關于volatile,腦海裡始終是記得與它相關的兩個術語:可見性、禁止重排序。
那,volatile語義中的可見性和禁止重排序究竟是什麼?
volatile
關于“可見性”和“重排序”對于一些基礎不太好的同學,可能是不太了解這是什麼意思。這裡作一些簡單說明,可能不太專業,但盡量直白希望容易了解。
并發程式設計常見的三個問題:可見性、原子性和有序性。
-
原子性
這幾個術語應該會經常聽到,原子性不是本文關心的,不在讨論範圍内,需要詳細了解的可以找一些相關資料,簡單說就是:我們在java裡的一行代碼,可能是需要解釋為多條機器指令給CPU執行的(比如:num+=1這樣),這幾條指令在執行過程中随時可能發生線程切換執行其它語句的指令,而這條語句還未執行完成,這就是原子性問題。除非這幾個指令操作在CPU執行過程不會中斷,就是原子性。
-
可見性
這個說的是記憶體的可見性。比如,有一個變量:
有線程A和線程B,線程A設定變量num=1,然後線程B讀取num的值的時候,讀取到的值還是0,B的讀操作發生A的寫操作之後,可是B讀到的不是A剛寫入的1。這個問題可能存在麼?是的,存在。
這個屬于硬體緩存帶來的一緻性問題。是以有JMM(java記憶體模型)來解決這個緩存一緻性問題。
在我們常說的JMM中,會提到一個術語,叫java線程的本地記憶體(本地緩存),每個線程都有。有很多資料中,也會說到,在一個線程中執行代碼時,當讀取一個變量的值的時候,會先從主記憶體中加載到本地記憶體,修改完變量的值,修改的是本地記憶體的值,随後才會寫回主記憶體。如果在寫回主記憶體前,如果有其它線程讀取這個變量的值,另外一條線程當然還擷取不到在這條線程中的本地記憶體裡更新的最新值,因為不論另一條線程是從它自己的本地記憶體讀還是主記憶體擷取都不最新的值,這就是上面說的記憶體可見性的問題。
我剛接觸本地記憶體這個概念的時候,其實是不太了解這個本地記憶體是什麼的,很多新手同學應當也是。
這個主記憶體,是我們的實體記憶體。
這個本地記憶體呢,其實在Java中是一個抽象概念,它包含CPU的緩存、寄存器或者是其它的硬體、編譯優化等,并不是每個線程單獨又開辟出來的一塊記憶體空間。然而JMM其實讨論的也是本地記憶體這個抽象概念。
是以,java每次讀取變量值的時候,是先從本地緩存讀取,沒有才從主記憶體加載到本地記憶體,寫入也是先寫到這裡再同步到主記憶體。
volatile的可見性保證的就是每次讀取都從主記憶體擷取,寫完同步到主記憶體,這樣每條線程都能看到其它線程更新的最新值。
-
有序性
有序性問題真的是讓人違背直覺。我們感覺順序執行的代碼就應該是從上往下,從前往後執行,有時候,結果卻不是這麼預期。
原因就是編譯器、運作時或者CPU等的優化導緻的重排序的問題。
下面是幾種可能導緻的重排序:
- 編譯器或運作時的重排序,比如下面這個:
,編譯後的順序可能為:a = 1; b = 2;
(這裡是打個比方,也不是說編譯為位元組碼的重排序)。b = 2; a = 1;
- 硬體的重排序,比如CPU執行機器指令優化時的重排序
- 存儲系統的重排序,這個是因為資料首先寫入緩存,并不是馬上更新到主存,有相應的條件和情況,這可就不保證哪個線程将一前一後哪個變量的值先寫到緩存,它就會先更新到主存。
-
其它情況重排序。
雙檢鎖的這個重排序問題,指的是第2種,CPU的機器指令的一個重排序問題。
雙檢鎖的指令重排序
public static Earth getInstance() {
if (earth == null) {
synchronized (Earth.class) {
if (earth == null) {
earth = new Earth();
}
}
}
return earth;
}
這是前面雙檢鎖建立對象的代碼,假如有A、B兩條線程進入這個方法發現earth對象為空,開始競争這把鎖,A線程拿到了鎖進入同步塊,B線程等待鎖。A建立完對象釋放鎖,B拿到鎖發現earth不為空,便也退出同步塊傳回earth執行個體。這是沒有問題的。
但是,
earth = new Earth();
這行代碼是有多個操作:
- 配置設定記憶體空間
- 在這塊記憶體上初始化對象
- 将記憶體位址指派給earth變量
重排序後:
- 配置設定記憶體空間
- 将記憶體位址指派給earth變量
- 在這塊記憶體上初始化對象
如果線程A執行完第2個動作後,此時earth不為空,線程A進入這個方法,判斷earth是否為空,發現不為空,然後傳回了一個尚未初始化完成的對象,它的屬性可能還都是空。如果這時候通路這個變量的成員屬性,就可能有問題了,比如空指針異常。
下圖是我找的一個類似的測試的可以說明這個問題的截圖,相關資料連結在最後面:
volatile如何禁止重排序
目前,在jdk1.5到1.8,應該隻有volatile的語義包含禁止指令重排序了。synchronized也不支援,它隻是保證有序性,和同步塊的原子性。它的有序性是因為加鎖每次隻有一條線程執行臨界區的代碼,而這個原子性指的是加鎖的這一塊代碼對外是原子性,而這塊代碼裡是否重排序是不管的。
volatile的禁止重排序是使用了記憶體屏障。記憶體屏障就是在需要禁止重排序的前後加入相關指令,我是這樣了解的:CPU會因為優化對相關指令重排序,或者流水線方式的話存在并行執行,加入記憶體屏障,就是告訴CPU,不用下功夫優化了,一步步按順序執行。是以禁止重排序,一些優化就用不上了,性能肯定有下降。
volatile的使用場景
本文主要說明在雙檢鎖中的使用,其它也有場景需要使用volatile。因為volatile的可見性保證,如果是寫操作太頻繁,就要慎用,會導緻性能嚴重下降,盡量使用讀多,寫少的場景。
JDK1.5前的雙檢鎖
volatile的禁止重排序的語義是在1.5(包含1.5)及其之後的版本才有的,在老的JMM中,volatile隻保證可見性。是以,那個時候的雙檢鎖怎麼寫好像都可能有問題,不是一定安全的,我甚至看到下面這種方案,也可能由于一些微妙的原因存在問題。
public class Earth {
private static volatile Earth earth = null;
public static Earth getInstance() {
if (earth == null) {
Earth tmp = null;
synchronized (Earth.class) {
tmp = earth;
if (tmp == null) {
synchronized (Earth.class) {
tmp = new Earth();
}
earth = tmp;
}
}
}
return earth;
}
}
使用餓漢式單例
當然,在多線程環境中,不一定非要使用雙檢鎖這個懶漢式建立單例,推薦使用餓漢式這種靜态單例模式,建立一個外部類,将該單例對象作為它的靜态字段:
public class EarthHolder {
private static final Earth EARTH = new Earth();
public static Earth getInstance() {
return EarthHolder.EARTH;
}
}
推薦閱讀
可以看下下面這幾篇資料,上面的那個測試的指令的截圖便來自這裡:
http://gee.cs.oswego.edu/dl/cpj/jmm.html
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html