DCL(Double-checked Locking雙重校驗(yàn)鎖)實(shí)現(xiàn)單例模式的原理、問題與解決方案
好的,要深入理解DCL(Double-Checked Locking)雙重校驗(yàn)鎖的原理、問題以及解決方法。 首先,我需要回憶一下單例模式的基本概念,因?yàn)镈CL通常用于實(shí)現(xiàn)單例模式。 單例模式確保一個(gè)類只有一個(gè)實(shí)例,并提供一個(gè)全局訪問點(diǎn)。 接下來,DCL的目的是為了減少同步的開銷。通常,如果直接在getInstance方法上加synchronized關(guān)鍵字, 雖然能保證線程安全,但每次獲取實(shí)例時(shí)都需要同步,這在多線程環(huán)境下可能會(huì)影響性能。 而DCL通過兩次檢查實(shí)例是否已經(jīng)創(chuàng)建,來避免每次調(diào)用都進(jìn)行同步,只在第一次創(chuàng)建實(shí)例的時(shí)候同步。 現(xiàn)在,我需要想一下DCL的標(biāo)準(zhǔn)寫法。大概是這樣的: public class Singleton { private static Singleton instance; // 必須使用 volatile private Singleton() {} // 私有構(gòu)造函數(shù) public static Singleton getInstance() { if (instance == null) { // 第一次檢查(避免不必要的同步) synchronized (Singleton.class) { // 同步塊 if (instance == null) { // 第二次檢查(確保唯一實(shí)例) instance = new Singleton(); // 實(shí)例化 } } } return instance; } } 不過,這種寫法在早期的Java中存在問題,主要涉及到指令重排序的問題。 因?yàn)閕nstance = new Singleton(); 這個(gè)操作并不是原子性的,它分為三個(gè)步驟: 1. 分配內(nèi)存空間 2. 初始化對(duì)象 3. 將instance指向分配的內(nèi)存地址 但由于指令重排序的可能,步驟2和步驟3可能會(huì)被顛倒,導(dǎo)致其他線程在第一次檢查時(shí)看到instance不為null,但實(shí)際上對(duì)象還沒有初始化完成,從而訪問到一個(gè)不完整的對(duì)象。 為了解決這個(gè)問題,Java 5之后引入了volatile關(guān)鍵字,可以禁止指令重排序, 確保寫操作之前的操作不會(huì)被重排序到寫操作之后。因此,正確的DCL實(shí)現(xiàn)需要將instance聲明為volatile: private static volatile Singleton instance; 這樣,使用volatile修飾instance變量,就能保證在instance被初始化完成之后, 其他線程才能看到它的值,從而避免了指令重排序帶來的問題。 不過,需要確認(rèn)一下volatile在Java內(nèi)存模型中的具體作用。volatile關(guān)鍵字主要有兩個(gè)作用: 1. 可見性:保證變量的修改對(duì)所有線程立即可見。 2. 禁止指令重排序:通過插入內(nèi)存屏障,確保編譯器和處理器不會(huì)對(duì)指令進(jìn)行重排序。 在DCL中,主要是利用了volatile的第二個(gè)特性,即禁止指令重排序,確保對(duì)象的初始化在賦值操作之后完成。 另外,我還需要思考其他實(shí)現(xiàn)單例模式的方式, 比如靜態(tài)內(nèi)部類的方式,或者枚舉方式,這些方式是否更簡(jiǎn)單且線程安全。 靜態(tài)內(nèi)部類的實(shí)現(xiàn)方式: public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; // 類加載時(shí)初始化,由 JVM 保證線程安全 } } 這種方式利用了類加載機(jī)制,保證在首次訪問Holder.INSTANCE時(shí)才會(huì)加載Holder類, 從而初始化Singleton實(shí)例,由JVM保證線程安全。 這種方法不需要顯式的同步,且實(shí)現(xiàn)簡(jiǎn)單,但可能無法應(yīng)對(duì)反射攻擊或者序列化的問題。 枚舉方式: public enum Singleton { INSTANCE; } 這種方式由JVM保證實(shí)例的唯一性和線程安全,且能防止反射和序列化破壞單例,是最推薦的方式, 但可能不夠靈活,比如無法繼承其他類。 回到DCL,雖然它在某些情況下仍然有用,但需要正確使用volatile關(guān)鍵字。 還需要驗(yàn)證一下如果不使用volatile是否一定會(huì)出現(xiàn)問題,以及這種問題發(fā)生的條件。 比如,在Java 5之前,DCL確實(shí)是不安全的,因?yàn)関olatile關(guān)鍵字的語義不夠嚴(yán)格, 無法完全禁止指令重排序。而在Java 5之后,JSR-133增強(qiáng)了volatile的內(nèi)存語義,確保了DCL的正確性。 另外,我還需要了解內(nèi)存屏障的概念,以及volatile如何通過內(nèi)存屏障來禁止重排序。 比如,在寫操作之后插入StoreStore屏障,防止寫操作之前的指令被重排序到寫操作之后; 在讀操作之前插入LoadLoad屏障,防止讀操作之后的指令被重排序到讀操作之前。 總結(jié)一下,DCL的正確實(shí)現(xiàn)需要注意以下幾點(diǎn): 1. 使用雙重檢查來減少同步的開銷。 2. 使用volatile關(guān)鍵字來禁止指令重排序,保證實(shí)例的可見性和初始化順序。 3. 確保構(gòu)造函數(shù)是私有的,防止外部實(shí)例化。 可能的誤區(qū)是認(rèn)為只要兩次檢查就能保證線程安全,而忽略了指令重排序的問題。 因此,volatile的使用是必不可少的。 最后,我需要思考在實(shí)際開發(fā)中,是否應(yīng)該優(yōu)先選擇其他單例實(shí)現(xiàn)方式, 如枚舉或靜態(tài)內(nèi)部類,而不是DCL,以避免潛在的復(fù)雜性和錯(cuò)誤。 但在某些需要延遲初始化且對(duì)性能有較高要求的場(chǎng)景下,DCL仍然是一個(gè)合理的選擇,只要正確實(shí)現(xiàn)。
前言:什么是單例模式
單例即單實(shí)例,只實(shí)例出來一個(gè)對(duì)象。
一般在創(chuàng)建一些管理器類、工具類的時(shí)候,需要用到單例模式,比如JDBCUtil 類,我們只需要一個(gè)實(shí)例即可(多個(gè)實(shí)例也可以實(shí)現(xiàn)功能,但是增加了代碼量且降低了性能)。
如何實(shí)現(xiàn)單例:
- 將構(gòu)造方法私有化
- 提供一個(gè)全局唯一獲取該類實(shí)例的方法幫助用戶獲取類的實(shí)例
應(yīng)用場(chǎng)景:
主要被用于一個(gè)全局類的對(duì)象在多個(gè)地方被使用并且對(duì)象的狀態(tài)是全局變化的場(chǎng)景下。
單例模式的優(yōu)點(diǎn):
單例模式為系統(tǒng)資源的優(yōu)化提供了很好的思路,頻繁創(chuàng)建和銷毀對(duì)象都會(huì)增加系統(tǒng)的資源消耗,而單例模式保障了整個(gè)系統(tǒng)只有一個(gè)對(duì)象能被使用,很好地節(jié)約了資源。
單例模式的四類寫法:
- 餓漢模式
- 懶漢模式
- 靜態(tài)內(nèi)部類
- 雙重校驗(yàn)鎖
在講雙重校驗(yàn)鎖之前先來看一下其他模式
餓漢模式
顧名思義,餓漢模式就是加載類的時(shí)候直接new一個(gè)對(duì)象,后面直接用即可。
餓漢模式指在類中直接定義全局的靜態(tài)對(duì)象的實(shí)例并初始化,然后提供一個(gè)方法獲取該實(shí)例對(duì)象。
懶漢模式
顧名思義,懶漢模式就是加載類的時(shí)候只聲明變量,不new對(duì)象,后面用到的時(shí)候再new對(duì)象,然后把對(duì)象賦給該變量。
定義一個(gè)私有的靜態(tài)對(duì)象INSTANCE,之所以定義INSTANCE為靜態(tài),是因?yàn)?strong>靜態(tài)屬性或方法是屬于類的,能夠很好地保障單例對(duì)象的唯一性;
然后定義一個(gè)靜態(tài)方法獲取該對(duì)象,如果對(duì)象為null,則 new 一個(gè)對(duì)象并將其賦值給INSTANCE。
餓漢模式和懶漢模式的區(qū)別在于:餓漢模式是在類加載時(shí)將其實(shí)例化的,在餓漢模式下,在Class Loader完成后該類的實(shí)例便已經(jīng)存在于JVM中了,即,在getInstance方法第一次被調(diào)用前該實(shí)例已經(jīng)存在了,new對(duì)象的操作不在getInstance方法內(nèi)。
而懶漢模式在類中只是定義了變量但是并未實(shí)例化,實(shí)例化的過程是在獲取單例對(duì)象的方法中實(shí)現(xiàn)的,即,在getInstance方法第一次被調(diào)用后該實(shí)例才會(huì)被創(chuàng)建,new對(duì)象的操作在getInstance方法內(nèi)。
此外注意:餓漢模式的實(shí)例在類加載的時(shí)候已經(jīng)存在于JVM中了,因此是線程安全的;
懶漢模式通過第一次調(diào)用getInstance才實(shí)例化,該方法不是線程安全的(后面講怎么優(yōu)化)
靜態(tài)內(nèi)部類
靜態(tài)內(nèi)部類通過在類中定義一個(gè)靜態(tài)內(nèi)部類,將對(duì)象實(shí)例的定義和初始化放在內(nèi)部類中完成,我們?cè)讷@取對(duì)象時(shí)要通過靜態(tài)內(nèi)部類調(diào)用其單例對(duì)象。
之所以這樣設(shè)計(jì),是因?yàn)轭惖撵o態(tài)內(nèi)部類在JVM中是唯一的,這就很好地保障了單例對(duì)象的唯一性。
靜態(tài)內(nèi)部類的單例實(shí)現(xiàn)方式同樣是線程安全的。
代碼如下:
餓漢模式和靜態(tài)內(nèi)部類實(shí)現(xiàn)單例模式的優(yōu)點(diǎn)是寫法簡(jiǎn)單,缺點(diǎn)是不適合復(fù)雜對(duì)象的創(chuàng)建。對(duì)于涉及復(fù)雜對(duì)象創(chuàng)建的單例模式,比較優(yōu)雅的實(shí)現(xiàn)方式是懶漢模式,
但是懶漢模式是非線程安全的,
下面就講一下懶漢模式的升級(jí)版——DCL雙重構(gòu)校驗(yàn)鎖模式(雙重構(gòu)校驗(yàn)鎖是線程安全的)。
雙重校驗(yàn)鎖
餓漢模式是不需要加鎖來保證單例的,而懶漢模式雖然節(jié)省了內(nèi)存,但是卻需要使用鎖來保證單例,因此,雙重校驗(yàn)鎖就是懶漢模式的升級(jí)版本。
單線程懶漢模式實(shí)現(xiàn)
普通的懶漢模式在單線程場(chǎng)景下是線程安全的,但在多線程場(chǎng)景下是非線程安全的。
先來看看普通的懶漢模式實(shí)現(xiàn):
單線程懶漢模式的問題
上面這段代碼在單線程環(huán)境下沒有問題,但是在多線程的情況下會(huì)產(chǎn)生線程安全問題。
在多個(gè)線程同時(shí)調(diào)用getInstance方法時(shí),由于方法沒有加鎖,可能會(huì)出現(xiàn)以下情況
- ① 這些線程可能會(huì)創(chuàng)建多個(gè)對(duì)象
- ② 某個(gè)線程可能會(huì)得到一個(gè)未完全初始化的對(duì)象
為什么會(huì)出現(xiàn)以上問題?對(duì)于 ① 的情況解釋如下:
對(duì)于 ② 的情況解釋如下:
解決問題:加鎖
為了解決問題 ①,我們可以對(duì) getInstance() 這個(gè)方法加鎖。
仔細(xì)看,這里是粗暴地對(duì)整個(gè) getInstance() 方法加鎖,這樣做代價(jià)很大,因?yàn)椋挥挟?dāng)?shù)谝淮握{(diào)用 getInstance() 時(shí)才需要同步創(chuàng)建對(duì)象,創(chuàng)建之后再次調(diào)用 getInstance() 時(shí)就只是簡(jiǎn)單的返回成員變量,而這里是無需同步的,所以沒必要對(duì)整個(gè)方法加鎖。
由于同步一個(gè)方法會(huì)降低上百倍甚至更高的性能, 每次調(diào)用獲取和釋放鎖的開銷似乎是可以避免的:一旦初始化完成,獲取和釋放鎖就顯得很不必要。所以可以只對(duì)方法的部分代碼加鎖!!
優(yōu)化后的代碼選擇了對(duì) if (INSTANCE == null) 和 INSTANCE = new Lock2Singleton()加鎖
這樣,每個(gè)線程進(jìn)到這個(gè)方法中之后先加鎖,這樣就保證了 if (INSTANCE == null) 和 INSTANCE = new Lock2Singleton() 這兩行代碼被同一個(gè)線程執(zhí)行時(shí)不會(huì)有另外一個(gè)線程進(jìn)來,由此保證了創(chuàng)建的對(duì)象是唯一的。
對(duì)象的唯一性保證了,也就是解決了問題①,同時(shí)也解決了問題②。
為什么說也解決了問題②呢?synchronized不是不能禁止指令重排序嗎?
其實(shí)當(dāng)我們對(duì)INSTANCE == null和INSTANCE = new Lock2Singleton();加鎖時(shí),也就表示只有一個(gè)線程能進(jìn)來,盡管發(fā)生了指令重排序,也只是在持有鎖的期間發(fā)生了指令重排序,當(dāng)該線程創(chuàng)建完對(duì)象釋放鎖時(shí),new出來的已經(jīng)是一個(gè)完整的對(duì)象。
如此,我們仿佛完美地解決了問題 ① 和 ② ,然而你以為這就結(jié)束了嗎?NO!這段代碼從功能層面來講確實(shí)是已經(jīng)結(jié)束了,但是性能方面呢?是不是還有可以優(yōu)化的地方?
答案是:有!!
值得優(yōu)化的地方就在于 synchronized 代碼塊這里。每個(gè)線程進(jìn)來,不管三七二十一,都要先進(jìn)入同步代碼塊再說,如果說現(xiàn)在 INSTANCE 已經(jīng)不為null了,那么,此時(shí)當(dāng)一個(gè)線程進(jìn)來,先獲得鎖,然后才會(huì)執(zhí)行 if 判斷。我們知道加鎖是非常影響效率的,所以,如果 INSTANCE 已經(jīng)不為null,是不是就可以先判斷,再進(jìn)入 synchronized 代碼塊。如下
在 synchronized 代碼塊之外再加一個(gè) if 判斷,這樣,當(dāng) INSTANCE 已經(jīng)存在時(shí),線程先判斷不為null,然后直接返回,避免了進(jìn)入 synchronized 同步代碼塊。
那么可能又有人問,好了,我明白了在 synchronized 代碼塊外加一個(gè) if 判斷,是不是就意味著里面的那個(gè) if 判斷可以去掉?
當(dāng)然不可以!!
如果把里面的 if 判斷去掉,就相當(dāng)于只對(duì) INSTANCE = new Lock2Singleton() 這一行代碼加了個(gè)鎖,只對(duì)一行代碼加鎖,那你豈不是加了個(gè)寂寞(加鎖的目的就是防止在第二個(gè)if判斷和new操作之間有別的線程進(jìn)來!!),結(jié)果還是會(huì)引起問題①。
所以,兩次校驗(yàn),一次都不能少!!
但是,問題又來了,由于我們?cè)谕鈱佑旨恿艘粚觟f (INSTANCE == null)的判斷,導(dǎo)致原本被我們解決的問題② (即指令重排序問題)又出現(xiàn)了!
比如:線程A拿到鎖后剛走到INSTANCE = new Lock2Singleton(),但是還沒執(zhí)行完,因?yàn)閚ew Lock2Singleton()不是原子操作,且發(fā)生了指令重排序,那么此時(shí)INSTANCE就是一個(gè)不完整的對(duì)象,恰巧此時(shí),線程B來到第一個(gè)if (INSTANCE == null)判斷,由于INSTANCE不為null,結(jié)果獲取到一個(gè)不完整的對(duì)象。
那么怎么解決呢?
答案是加 volatile 關(guān)鍵字,volatile可以禁止指令重排序
一、DCL 的基本實(shí)現(xiàn)
DCL(Double-Checked Locking)旨在減少同步開銷,僅在首次創(chuàng)建實(shí)例時(shí)使用同步,同時(shí)保證線程安全。
標(biāo)準(zhǔn)代碼模板
二、DCL 的核心問題
1. 指令重排序問題
-
實(shí)例化操作的非原子性:
instance = new Singleton()分解為三步:-
分配內(nèi)存空間
-
初始化對(duì)象
-
將引用指向內(nèi)存地址
-
-
可能的指令重排序:
若步驟2和3被重排序,其他線程可能訪問到未初始化的對(duì)象(導(dǎo)致空指針異常)。
2. 可見性問題
未使用 volatile 時(shí),一個(gè)線程的寫操作可能對(duì)其他線程不可見。
三、解決方案:volatile 關(guān)鍵字
volatile 的作用
-
禁止指令重排序
-
通過內(nèi)存屏障(Memory Barrier)確保:
-
寫操作前的指令不會(huì)被重排序到寫操作之后。
-
讀操作后的指令不會(huì)被重排序到讀操作之前。
-
-
-
保證可見性
-
修改
volatile變量后,強(qiáng)制刷新到主內(nèi)存。 -
其他線程讀取時(shí)直接從主內(nèi)存加載。
-
四、DCL 的演進(jìn)與 JVM 版本兼容性
| Java 版本 | DCL 安全性 | 原因 |
|---|---|---|
| Java 1.4 及之前 | 不安全 | volatile 語義不完整 |
| Java 5(JSR-133)及之后 | 安全 | volatile 增強(qiáng)內(nèi)存屏障語義 |
五、替代單例實(shí)現(xiàn)方案
1. 靜態(tài)內(nèi)部類(Holder 模式)
-
優(yōu)點(diǎn):無鎖、線程安全、延遲加載。
-
缺點(diǎn):無法防止反射或反序列化破壞單例。
2. 枚舉單例(推薦)
-
優(yōu)點(diǎn):
-
線程安全。
-
天然防反射和反序列化破壞。
-
-
缺點(diǎn):無法繼承其他類。
六、DCL 的正確使用場(chǎng)景
-
延遲初始化:僅在需要時(shí)創(chuàng)建實(shí)例。
-
性能敏感:避免每次調(diào)用同步的開銷。
-
兼容性要求:需支持 Java 5 及以上版本。
七、常見誤區(qū)與驗(yàn)證
1. 錯(cuò)誤:省略 volatile
-
后果:可能返回未完全初始化的對(duì)象(指令重排序?qū)е拢?/p>
2. 錯(cuò)誤:?jiǎn)未螜z查
-
后果:多線程環(huán)境下可能創(chuàng)建多個(gè)實(shí)例。
八、內(nèi)存屏障與 JVM 實(shí)現(xiàn)細(xì)節(jié)
-
寫操作屏障:
-
StoreStore屏障:禁止普通寫與volatile寫重排序。 -
StoreLoad屏障:強(qiáng)制刷新寫緩存到主內(nèi)存。
-
-
讀操作屏障:
-
LoadLoad屏障:禁止volatile讀與后續(xù)普通讀重排序。 -
LoadStore屏障:禁止volatile讀與后續(xù)普通寫重排序。
-
九、總結(jié)
-
DCL 要點(diǎn):
-
雙重檢查減少同步開銷。
-
volatile禁止指令重排序,保證可見性。
-
-
適用場(chǎng)景:需要延遲初始化且對(duì)性能有要求的單例實(shí)現(xiàn)。
-
替代方案:優(yōu)先考慮枚舉或靜態(tài)內(nèi)部類實(shí)現(xiàn)單例。
正確實(shí)現(xiàn) DCL 需嚴(yán)格遵循代碼模板,避免遺漏 volatile 關(guān)鍵字,以確保線程安全和對(duì)象初始化的正確性。
浙公網(wǎng)安備 33010602011771號(hào)