在Java多線程程序中丹弱,有時(shí)候需要采用延遲初始化來(lái)降低初始化類和創(chuàng)建對(duì)象的開(kāi)銷(xiāo)德撬。雙重檢查鎖定是常見(jiàn)的延遲初始化技術(shù),但它是一個(gè)錯(cuò)誤的用法躲胳。本文將分析雙重檢查鎖定的錯(cuò)誤根源蜓洪,以及兩種線程安全的延遲初始化方案。
雙重檢查鎖定的由來(lái)
在Java程序中坯苹,有時(shí)候可能需要推遲一些高開(kāi)銷(xiāo)的對(duì)象初始化操作隆檀,并且只有在使用這些對(duì)象時(shí)才進(jìn)行初始化。此時(shí)粹湃,程序員可能會(huì)采用延遲初始化恐仑。但要正確實(shí)現(xiàn)線程安全的延遲初始化需要一些技巧,否則很容易出現(xiàn)問(wèn)題为鳄。比如裳仆,下面是非線程安全的延遲初始化對(duì)象的示例代碼。
public class UnsafeLazyInitialization {
private static Instance instance;
public static Instance getInstance() {
if (instance == null) { //1: A線程執(zhí)行
instance = new Instance(); //2: B線程執(zhí)行
}
return instance;
}
}
在UnsafeLazyInitialization類中孤钦,假設(shè)A線程執(zhí)行代碼1的同時(shí)歧斟,B線程執(zhí)行代碼2。此時(shí)偏形,線程A可能會(huì)看到instance引用的對(duì)象還沒(méi)有完成初始化构捡。
對(duì)于UnsafeLazyInitialization類,我們可以對(duì)getInstance()方法做同步處理來(lái)實(shí)現(xiàn)線程安全的延遲初始化壳猜。示例代碼如下勾徽。
public class SafeLazyInitialization {
private static Instance instance;
public synchronized static Instance getInstance() {
if (instance == null) {
instance = new Instance();
}
return instance;
}
}
由于對(duì)getInstance()方法做了同步處理,synchronized將導(dǎo)致性能開(kāi)銷(xiāo)统扳。如果getInstance()方法被多個(gè)線程頻繁的調(diào)用喘帚,將會(huì)導(dǎo)致程序執(zhí)行性能的下降。反之咒钟,如果getInstance()方法不會(huì)被多個(gè)線程頻繁的調(diào)用吹由,那么這個(gè)延遲初始化方案將能提供令人滿意的性能。
在早期的JVM中朱嘴,synchronized(甚至是無(wú)競(jìng)爭(zhēng)的synchronized)存在巨大的性能開(kāi)銷(xiāo)倾鲫。因此粗合,人們想出了一個(gè)“聰明”的技巧:雙重檢查鎖定(Double-Checked Locking)。人們想通過(guò)雙重檢查鎖定來(lái)降低同步的開(kāi)銷(xiāo)乌昔。下面是使用雙重檢查鎖定來(lái)實(shí)現(xiàn)延遲初始化的示例代碼隙疚。
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: 問(wèn)題的根源出在這里
} //8
} //9
} //10
return instance; //11
}
}
如上面代碼所示,如果第一次檢查instance不為null磕道,那么就不需要執(zhí)行下面的加鎖和初始化操作供屉。因此,可以大幅降低synchronized帶來(lái)的性能開(kāi)銷(xiāo)溺蕉。上面代碼表面上看起來(lái)伶丐,似乎兩全其美。
- 多個(gè)線程試圖在同一時(shí)間創(chuàng)建對(duì)象時(shí)疯特,會(huì)通過(guò)加鎖來(lái)保證只有一個(gè)線程能創(chuàng)建對(duì)象哗魂。
- 在對(duì)象創(chuàng)建好之后,執(zhí)行g(shù)etInstance()方法將不需要獲取鎖漓雅,直接返回已創(chuàng)建好的對(duì)象录别。
雙重檢查鎖定看起來(lái)似乎很完美,但這是一個(gè)錯(cuò)誤的優(yōu)化故硅!在線程執(zhí)行到第一次檢查時(shí),代碼讀取到instance不為null時(shí)纵搁,instance引用的對(duì)象有可能還沒(méi)有完成初始化吃衅。
問(wèn)題的根源
前面的雙重檢查鎖定示例代碼的第7行(instance = new Singleton();)創(chuàng)建了一個(gè)對(duì)象。這一行代碼可以分解為如下的3行偽代碼腾誉。
memory = allocate(); //1:分配對(duì)象的內(nèi)存空間
ctorInstance(memory); //2:初始化對(duì)象
instance = memory; //3:設(shè)置instance指向剛分配的內(nèi)存地址
上面3行偽代碼中的2和3之間徘层,可能會(huì)被重排序(在一些JIT編譯器上,這種重排序是真實(shí)發(fā)生的)利职。2和3之間重排序之后的執(zhí)行時(shí)序如下趣效。
memory = allocate(); //1:分配對(duì)象的內(nèi)存空間
instance = memory; //3:設(shè)置instance指向剛分配的內(nèi)存地址 //注意,此時(shí)對(duì)象還沒(méi)有被初始化猪贪!
ctorInstance(memory); //2:初始化對(duì)象
根據(jù)《The Java Language Specif ication, Java SE 7 Edition》(簡(jiǎn)稱為Java語(yǔ)言規(guī)范)跷敬,所有線程在執(zhí)行Java程序時(shí)必須要遵守intra-thread semantics。intra-thread semantics保證重排序不會(huì)改變單線程內(nèi)的程序執(zhí)行結(jié)果热押。換句話說(shuō)西傀,intra-thread semantics允許那些在單線程內(nèi),不會(huì)改變單線程程序執(zhí)行結(jié)果的重排序桶癣。上面3行偽代碼的2和3之間雖然被重排序了拥褂,但這個(gè)重排序并不會(huì)違反intra-thread semantics。這個(gè)重排序在沒(méi)有改變單線程程序執(zhí)行結(jié)果的前提下牙寞,可以提高程序的執(zhí)行性能饺鹃。
線程執(zhí)行時(shí)序
1: 分配對(duì)象的內(nèi)存空間
3: 設(shè)置instance指向內(nèi)存空間
2: 初始化對(duì)象
3: 初次訪問(wèn)對(duì)象
雖然這里2和3重排序了,但是只要保證2排在4的前面執(zhí)行,單線程內(nèi)的執(zhí)行結(jié)果就不會(huì)被改變
多線程執(zhí)行時(shí)序如下:
線程A | 線程B |
---|---|
1: 分配對(duì)象的內(nèi)存空間 | |
3:設(shè)置instance指向內(nèi)存空間 | |
- | 判斷instance是否為null |
- | B線程初次訪問(wèn)對(duì)象 |
2:初始化對(duì)象 | |
4:A線程初次訪問(wèn)對(duì)象 |
由于單線程內(nèi)要遵守intra-thread semantics悔详,從而能保證A線程的執(zhí)行結(jié)果不會(huì)被改變镊屎。但是,當(dāng)線程A和B按如上時(shí)序執(zhí)行時(shí)伟端,B線程將看到一個(gè)還沒(méi)有被初始化的對(duì)象杯道。
回到本文的主題,DoubleCheckedLocking示例代碼的第7行(instance=newSingleton();)如果發(fā)生重排序责蝠,另一個(gè)并發(fā)執(zhí)行的線程B就有可能在第4行判斷instance不為null党巾。線程B接下來(lái)將訪問(wèn)instance所引用的對(duì)象,但此時(shí)這個(gè)對(duì)象可能還沒(méi)有被A線程初始化霜医!下表是這個(gè)場(chǎng)景的具體執(zhí)行時(shí)序齿拂。
多線程執(zhí)行時(shí)序表:
時(shí)間 | 線程A | 線程B |
---|---|---|
t1 | A1: 分配對(duì)象的內(nèi)存空間 | - |
t2 | A3: 設(shè)置instance指向內(nèi)存空間 | - |
t3 | - | B1: 判斷instance是否為空 |
t4 | - | B2: 由于instance不為null,線程B將訪問(wèn)instance引用的對(duì)象 |
t5 | A2: 初始化對(duì)象 | - |
t6 | A4: 訪問(wèn)instance引用的對(duì)象 | - |
這里A2和A3雖然重排序了,但Java內(nèi)存模型的intra-thread semantics將確保A2一定會(huì)排在A4前面執(zhí)行肴敛。因此署海,線程A的intra-thread semantics沒(méi)有改變,但A2和A3的重排序医男,將導(dǎo)致線程B在B1處判斷出instance不為空砸狞,線程B接下來(lái)將訪問(wèn)instance引用的對(duì)象。此時(shí)镀梭,線程B將會(huì)訪問(wèn)到一個(gè)還未初始化的對(duì)象刀森。
在知曉了問(wèn)題發(fā)生的根源之后,我們可以想出兩個(gè)辦法來(lái)實(shí)現(xiàn)線程安全的延遲初始化报账。
- 不允許2和3重排序研底。
- 允許2和3重排序,但不允許其他線程“看到”這個(gè)重排序透罢。
后文介紹的兩個(gè)解決方案榜晦,分別對(duì)應(yīng)于上面這兩點(diǎn)。
基于volatile的解決方案
將實(shí)例定義為volatile類型羽圃,實(shí)現(xiàn)線程安全的延遲初始化乾胶。
public class SafeDoubleCheckLocking {
private volatile static Instance instance;
public static Instance getInstance() {
if (instance == null) {
synchronized (SafeDoubleCheckLocking.class) {
if (instance == null) {
instance = new Instance(); //instance為volatile,現(xiàn)在沒(méi)問(wèn)題了
}
}
}
return instance;
}
}
這個(gè)解決方案需要JDK 5或更高版本(因?yàn)閺腏DK 5開(kāi)始使用新的JSR-133內(nèi)存模型規(guī)范,這個(gè)規(guī)范增強(qiáng)了volatile的語(yǔ)義)朽寞。
基于類初始化的解決方案
JVM在類的初始化階段(即在Class被加載后胚吁,且被線程使用之前),會(huì)執(zhí)行類的初始化愁憔。在執(zhí)行類的初始化期間腕扶,JVM會(huì)去獲取一個(gè)鎖。這個(gè)鎖可以同步多個(gè)線程對(duì)同一個(gè)類的初始化吨掌。
基于這個(gè)特性半抱,可以實(shí)現(xiàn)另一種線程安全的延遲初始化方案(這個(gè)方案被稱之為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; //這里導(dǎo)致InstanceHolder初始化
}
}
這個(gè)方案的實(shí)質(zhì)是:允許臨界區(qū)代碼重排序,但不允許非構(gòu)造線程(這里指線程B)“看到”這個(gè)重排序窿侈。
初始化一個(gè)類炼幔,包括執(zhí)行這個(gè)類的靜態(tài)初始化和初始化在這個(gè)類中聲明的靜態(tài)字段。根據(jù)Java語(yǔ)言規(guī)范史简,在首次發(fā)生下列任意一種情況時(shí)乃秀,一個(gè)類或接口類型T將被立即初始化。
- T是一個(gè)類圆兵,而且一個(gè)T類型的實(shí)例被創(chuàng)建跺讯。
- T是一個(gè)類,且T中聲明的一個(gè)靜態(tài)方法被調(diào)用殉农。
- T中聲明的一個(gè)靜態(tài)字段被賦值刀脏。
- T中聲明的一個(gè)靜態(tài)字段被使用,而且這個(gè)字段不是一個(gè)常量字段超凳。
- T是一個(gè)頂級(jí)類(其他類外面聲明的類)愈污,而且一個(gè)斷言語(yǔ)句嵌套在T內(nèi)部被執(zhí)行。
在InstanceFactory示例代碼中轮傍,首次執(zhí)行g(shù)etInstance()方法的線程將導(dǎo)致InstanceHolder類被初始化(符合情況4)暂雹。
由于Java語(yǔ)言是多線程的,多個(gè)線程可能在同一時(shí)間嘗試去初始化同一個(gè)類或接口(比如這里多個(gè)線程可能在同一時(shí)刻調(diào)用getInstance()方法來(lái)初始化InstanceHolder類)创夜。因此杭跪,在Java中初始化一個(gè)類或者接口時(shí),需要做細(xì)致的同步處理挥下。
Java語(yǔ)言規(guī)范規(guī)定揍魂,對(duì)于每一個(gè)類或接口C桨醋,都有一個(gè)唯一的初始化鎖LC與之對(duì)應(yīng)棚瘟。從C到LC的映射,由JVM的具體實(shí)現(xiàn)去自由實(shí)現(xiàn)喜最。JVM在類初始化期間會(huì)獲取這個(gè)初始化鎖偎蘸,并且每個(gè)線程至少獲取一次鎖來(lái)確保這個(gè)類已經(jīng)被初始化過(guò)了(事實(shí)上,Java語(yǔ)言規(guī)范允許JVM的具體實(shí)現(xiàn)在這里做一些優(yōu)化)瞬内。
總結(jié)
通過(guò)對(duì)比基于volatile的雙重檢查鎖定的方案和基于類初始化的方案迷雪,我們會(huì)發(fā)現(xiàn)基于類初始化的方案的實(shí)現(xiàn)代碼更簡(jiǎn)潔。但基于volatile的雙重檢查鎖定的方案有一個(gè)額外的優(yōu)勢(shì):**除了可以對(duì)靜態(tài)字段實(shí)現(xiàn)延遲初始化外虫蝶,還可以對(duì)實(shí)例字段實(shí)現(xiàn)延遲初始化章咧。 **
字段延遲初始化降低了初始化類或創(chuàng)建實(shí)例的開(kāi)銷(xiāo),但增加了訪問(wèn)被延遲初始化的字段的開(kāi)銷(xiāo)能真。在大多數(shù)時(shí)候赁严,正常的初始化要優(yōu)于延遲初始化扰柠。
如果確實(shí)需要對(duì)實(shí)例字段使用線程安全的延遲初始化,請(qǐng)使用上面介紹的基于volatile的延遲初始化的方案疼约;如果確實(shí)需要對(duì)靜態(tài)字段使用線程安全的延遲初始化卤档,請(qǐng)使用上面介紹的基于類初始化的方案。