一、什么是雙重檢查鎖
雙重檢查鎖(Double-Check Locking),顧名思義,通過兩次檢查,并基于加鎖機制,實現某個功能。
要理解什么是雙重檢查鎖,我們從常見的單例模式說起。看第一個例子:
上圖中的單例模式一看就知道存在線程問題,如果兩個線程:線程A和線程B,同時訪問該類,線程A訪問到第6行,在還沒有實例化完成的時候,線程B訪問到第5行,此時也會判斷到instance=null,同樣會執行實例化的代碼,那么線程A和線程B就都會創建一個Singleton實例。
怎么解決上面出現的多線程并發訪問導致的問題呢?
加鎖!大家都知道,加鎖是解決并發訪問的方案,于是對代碼進行修改,修改后如下:
基于鎖的相關特性,可以保證線程A和線程B對getInstance()方法的互斥性和原子性,線程A獲取到鎖,訪問getInstance方法,判斷到instance=null,然后創建instance對象實例,完成后釋放鎖;線程B獲取到鎖,進入getInstance方法,判斷到instance!=null,直接返回instance對象。這樣,保證了線程安全性。
但是,加鎖是會產生性能開銷的,如果getInstance方法被多個線程頻繁調用,將會導致程序性能下降。為了解決加鎖導致的性能開銷,想到了通過延遲加鎖,采用塊級鎖的方法提高性能降低開銷,代碼示例如下:
此時,線程A和線程B同時訪問getInstance方法,線程A和線程B同時在第5行判斷到instance=null,然后獲取到鎖,執行實例化代碼后釋放鎖,線程B也會獲取到鎖執行實例化代碼,所以這個并沒有解決線程同步的問題。那么,在線程B獲取到鎖之后,再判斷一次instance對象是否為null呢?
線程A和線程B同時訪問到第5行,然后線程A獲取到鎖,判斷到instance=null,執行實例化代碼然后釋放鎖;線程B獲取到鎖,判斷到instance!=null,直接返回instance對象;線程C在進入第5行后判斷到instance!=null,直接返回。這樣就減少了鎖的開銷,提升了性能。
此時看上去雙重檢查鎖機制很完美,創建單例實例沒有問題。但是,從JAVA內存模型來講,這其實是存在問題的,問題就在于,線程C在進入第5行后判斷到的不為null的instance對象,可能還沒有初始化完成!這就要從執行指令的重排序講起。
二、什么是重排序機制
為了提高程序執行性能,編譯器和處理器會對指令的處理過程重排序。重排序分為3種類型:
1、編譯器優化的重排序。在不改變單線程程序語義的前提下,可以重新安排語句的執行順序;
2、指令級并行的重排序。現代處理器采用了指令級并行技術將多條指令重疊執行,如果不存在數據依賴性,處理器可以改變代碼語句對應指令的執行順序;
3、內存系統的重排序。由于處理器使用緩存和讀/寫緩沖區,使得加載和存儲操作看上去可能是在亂序執行。
從Java源代碼到最終實際執行的指令序列,會經過如下步驟:
重排序可能會出現內存可見性問題,JMM通過內存屏障禁止了一些情況下的處理器重排序,保證了內存可見性問題。同時,基于as-if-serial語義,不管怎么重排序,單線程執行結果不能被改變。
對于具有數據依賴性的多個代碼指令,JMM會禁止重排序。但是對于不具有數據依賴性的代碼指令,JMM允許執行順序的重排序,只要保證單線程內執行結果一致即可。見下面的代碼:
第3行由于使用了第1行的執行結果和第2行的執行結果,因此與第1行和第2行存在數據依賴性,因此JMM保證第1行和第2行肯定會在第3行之前執行,不允許重排序。但是第1行和第2行不存在數據依賴性,是允許重排序的,他們之間只要保證執行結果的可見性即可。
三、回到雙重檢查鎖上來
為什么說線程C在第5行判斷到的instance!=null,可能是還未被初始化完成的對象實例呢?因為JMM在創建對象時會分為如下三步:
從上面可以看到,第2行和第3行執行需要依賴于第1行的執行結果,分配內存空間,存在數據依賴性。
但是第2行和第3行卻不存在數據依賴性,這里可能會發生重排序!
也就是說第3行可能會優先于第2行執行,先設置instance指向內存地址,此時instance就!=null;然后再初始化對象。如果初始化對象耗時較長,在還沒有初始化完成時,線程C訪問getInstance方法,在第5行會判斷到instance!=null,直接返回未初始化完成的instance實例對象。
怎么來解決這個問題?這又要說到JMM的happens-before規則。
四、什么是happens-before規則
從JDK5開始,Java使用新的JSR-133內存模型,使用happens-before的概念來闡述操作之間的內存可見性。在JMM中,如果一個操作的結果需要對另一個結果可見,那么這兩個操作之間必須存在happens-before關系。這兩個操作既可以是一個線程內,也可以是不同線程。
happens-before規則具有如下語義:
1、一個線程中的每個操作,happens-before于該線程中的任意后續操作;
2、對一個鎖的解鎖,happens-before于隨后對這個鎖的加鎖;
3、對一個 volatile修飾的變量的寫,happens-before于任意后續對該變量的讀;
4、如果A happens-before B,B happens-before C,那么A happens-before C。
我們看第3點,如果用volatile修飾instance,那么是不是可以保證程序按照我們的想法執行呢?答案是的!
五、什么是volatile
要講volatile,我們從一個例子開始講起。
上面的代碼執行后,按照我們的理解應該是線程run方法里面應該最后跳出循環。但是事實上并不是,這是因為基于JMM,每個線程具有自己的工作內存空間,臨界區變量flag處于主內存空間中,每個線程從主內存空間中讀flag,在工作空間中寫flag,寫完后再刷入到主內存空間。這是JMM的基于內存模型,也正是因為這個模型,才會導致多線程的一致性問題。因為如果有多個線程同時從主內存中讀取臨界區變量,然后修改,就會保證數據的不一致性,所以只能通過加鎖來保證主內存區的訪問的互斥性。
上面的例子,主線程從主內存區讀取到flag=false,同時子線程也從主內存區讀取到flag=false,開始執行循環操作。即使主線程修改了flag=true,但是卻不能改變子線程工作內存區域的flag的值。要解決這個問題,一是通過加鎖實現可見性,二是通過volatile。
volatile的語義是:
1、保證線程之間的可見性,基于JMM內存模型,經過volatile修飾的變量,如果一個線程修改了該變量的值,會立刻刷新到主內存區域,此時基于happens-before規則,其他線程要讀該變量的值,必須要寫完之后。事實上,基于內存模型,主要是因為在修改了該變量的值后,內存模型會通知其他讀取了該值得線程,將值設置為無效,要使用該變量的值,必須從主內存區域重新讀取。從而保證了可見性。上例中如果用volatile修改flag后,子線程在遍歷時就會重新讀取flag的值。
2、volatile修飾的變量禁止重排序。
六、再次回到雙重檢查鎖
基于volatile修飾的變量禁止重排序的特性,以及volatile的happens-before規則,我們可以在上面的單例代碼中通過volatile修改,來最終實現線程安全的單例模式。
更多技術分享,歡迎訪問個人站點:技術驛站