簡介
在Java多執行緒中,有時候可能需要采用延遲初始化來降低初始化類和創建物件的開銷,雙重檢查鎖(餓漢式單例中經常用)是常見的延遲初始化方案,但它是一個錯誤的用法,本文將分析雙重檢查鎖定的錯誤根源,以及兩張執行緒安全的延遲初始化方案,
?
1、雙重檢查鎖定的由來
在Java程式中,有時候可能需要推遲一些高開銷的物件初始化操作,并且只有在使用這些物件時才進行初始化,此時,程式員可能會采用延遲初始化,但要爭取實作執行緒安全的延遲化需要一些技巧,以此來避免不必要的問題,
非執行緒安全延遲初始化代碼示例:
package com.lizba.p1;
/**
* <p>
* 實體物件
* </p>
*
* @Author: Liziba
* @Date: 2021/6/12 22:42
*/
public class Instance {
public Instance() {
System.out.println("init...");
}
}
package com.lizba.p1;
/**
* <p>
* 延遲初始化
* </p>
*
* @Author: Liziba
* @Date: 2021/6/12 22:40
*/
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物件未完成初始化(后續會講問題根源),
?
同步處理解決方法:
package com.lizba.p1;
/**
* <p>
*
* </p>
*
* @Author: Liziba
* @Date: 2021/6/12 22:46
*/
public class SafeLazyInitialization {
private static Instance instance;
public synchronized static Instance getInstance() {
if (instance == null) { // 執行緒A執行
instance = new Instance(); // 執行緒B執行
}
return instance;
}
}
給getInstance()方法做了同步處理,synchronized會帶來性能開銷,在getInstance()呼叫不頻繁的情況下,這種解決方案是可以接收的,但是如果getInstance()被頻繁呼叫,程式的整體性能將會下降,(尤其是在早期JVM中,沒有鎖升級策略的時候),
?
雙重檢查鎖解決方法:
package com.lizba.p1;
/**
* <p>
* 雙重檢查鎖
* </p>
*
* @Author: Liziba
* @Date: 2021/6/12 22:51
*/
public class DoubleCheckedLocking {
private static Instance instance;
public static Instance getInstance() {
if (instance == null) { // 第一次檢查
synchronized (DoubleCheckedLocking.class) { // 加鎖
if (instance == null) { // 第二次檢查
instance = new Instance(); // 仍然存在問題的代碼
}
}
}
return instance;
}
}
如上代碼,如果第一次檢查instance不為null,那么久不需要執行加鎖和初始化作業,可以極大的減少synchronized帶來的性能開銷,但是雙重檢查鎖也存在一個問題,就是判斷instance == null這行代碼可能會在Instance未正確初始化的時候成立,這個問題產生的原因是指令重拍,下面會詳細講述,也可以看我往期的文章哈!因此這是一個錯誤的不完美的解決方案,
?
2、問題的根源
2.1 分析 instance = new Instance();
instance = new Instance(); 這行代碼在可以理解為三行偽代碼(JVM中的指令):
- memory = allocate(); // 分配物件的記憶體空間
- ctorInstance(memory); // 初始化物件
- instance = memory; // 設定instance指向剛分配的記憶體地址
?
上述代碼2和3可能會被重排序(部分JIT編譯器真實存在),重排序后如下所示:
- memory = allocate(); // 分配物件的記憶體空間
- instance = memory; // 設定instance指向剛分配的記憶體地址 (未初始化完成)
- ctorInstance(memory); // 初始化物件
由于上述重排序,遵守Java程式執行時必須遵守的intra-thread semantics,重排序并未改變在單執行緒中程式執行結果,且如果該重排序能帶來性能優化則是被Java語言規范《The Java Language Specification》允許的,
?
2.2 分析什么是intra-thread semantics
單執行緒內instance = new Instance(); 執行時序圖:

多執行緒內instance = new Instance(); 可能存在的一種執行時序圖:

由于單執行緒內要遵守intra-thread semantics,從而保證執行緒A的執行結果不會被改變;但是在上圖多執行緒執行中,執行緒B可能讀到一個未正確完成初始化的Instance物件,
回到DoubleCheckedLocking這個示例代碼中,執行緒B可能在第一次instance == null判斷時為真,執行緒B接下來將訪問instance參考指向的物件,但是此時這個物件并沒有初始化完成,
?
多執行緒執行時序表:
| 時間 | 執行緒A | 執行緒B |
|---|---|---|
| t1 | A1:分配物件的記憶體空間 | |
| t2 | A3:設定instance指向記憶體空間 | |
| t3 | B1:判斷instance是否為null | |
| t4 | B2:由于instance不為null,執行緒B將訪問instance參考的物件 | |
| t5 | A2:初始化物件 | |
| t6 | A4:訪問instance參考的物件 |
2.3 分析問題關鍵點
有上述的時序圖表和解釋我們不難發現,出現的問題是物件instance實體化時指令重排序導致物件“逸出”了,因此我們有如下兩種解決思路:
- 不允許2和3重排序
- 運行2和3重排序,但是不允許其他執行緒“看到”這個重排序
下面講述具體實作方案,
3、基于volatile的解決方案
在DoubleCheckedLocking上做小修改即可(需要基于JDK1.5及以上)
package com.lizba.p1;
/**
* <p>
* 雙重檢查鎖正確示例,JDK1.5及以上
* </p>
*
* @Author: Liziba
* @Date: 2021/6/12 22:51
*/
public class DoubleCheckedLocking {
// private static Instance instance;
private volatile static Instance instance;
public static Instance getInstance() {
if (instance == null) { // 第一次檢查
synchronized (DoubleCheckedLocking.class) { // 加鎖
if (instance == null) { // 第二次檢查
instance = new Instance(); // instance為volatile,問題得以解決
}
}
}
return instance;
}
}
宣告instance為volatile參考變數時,2和3的重排序會被禁止,執行時序圖如下:

該方案是通過禁止重排序來實作,
?
4、基于類初始化的解決方案
JVM在類的初始化階段(即在Class被加載后,且被執行緒使用前),會執行類的初始化,在執行類的初始化期間,JVM會去獲取一個鎖,這個鎖可以同步多個執行緒對同一個類的初始化,
基于這個特性實作的方案被稱之為(Initialization On Demand Holder idiom),
示例代碼:
package com.lizba.p1;
/**
* <p>
* 實體工廠
* </p>
*
* @Author: Liziba
* @Date: 2021/6/12 23:52
*/
public class InstanceFactory {
private static class InstanceHolder {
public static Instance instance = new Instance();
}
public static Instance getInstance() {
return InstanceHolder.instance;
}
}
假設執行緒A和執行緒B同時執行getInstance()方法,下面是執行示意圖:

這個方案實質上是運行重排序,但是不允許非構造執行緒B看到未實體化完成的物件,利用了JVM類初始化的特性,
?
初始化一個類包括執行這個類的靜態初始化和初始化在這個類中宣告的靜態欄位,
那么類什么時候會被初始化呢?在Java語言規范中,首次發生如下情況中的任意一種,一個類或者一個介面型別T將會被立即初始化:
- T是一個類,而且一個T型別的實體被創建
- T是一個類,且T中宣告的一個靜態方法被呼叫
- T中宣告的一個靜態欄位被賦值
- T中宣告的一個靜態欄位被使用,而且這個欄位不是一個常量欄位
- T是一個頂級類(Top Level Class),而且一個斷言陳述句嵌套在T內部被執行
在InstanceFactory示例代碼中,符合情況4,InstanceHolder中靜態欄位instance被使用,導致觸發InstanceHolder物件的初始化,從而初始化Instance物件,
在Java代碼執行程序中,會存在多執行緒同時嘗試去初始化一個類或者一個介面,因此在Java語言規范中,會要求具體的JVM實作對這個程序做同步處理,(實作規范是每個類或者介面有一個唯一的初始化鎖LC與之對應,從C到LC的映射,由JVM去實作),
?
5、Java初始化類或介面的具體程序
我們來看看《Java并發編程藝術》的作者是如何通過5個步驟闡述這個程序的,
5.1 第一階段
通過在Class物件上同步(獲取Class物件的初始化鎖),來控制類或介面的初始化,這個獲取鎖的執行緒會一直等待,知道當前執行緒能夠獲取到這個Class物件的初始化鎖,
假設執行緒A和執行緒B同時初始化一個未被初始化的Class物件(初始化狀態state,此時被標記為state=noInitialization),圖示如下:

類初始化-第一階段執行時序表:
| 時間 | 執行緒A | 執行緒B |
|---|---|---|
| t1 | A1:嘗試獲取Class物件的初始化鎖,這里假設執行緒A獲取到初始化鎖, | B1:嘗試獲取Class物件的初始化鎖,由于執行緒A獲取到了鎖,執行緒B等待獲取初始化鎖 |
| t2 | A2:執行緒A看到物件還未被初始化(state=noInitialization),執行緒設定state=noInitializating | |
| t3 | A3:執行緒A釋放初始化鎖 |
5.2 第二階段
執行緒A執行類的初始化,同時執行緒B在初始鎖對應的condition上等待,
圖示如下:

類初始化-第二階段執行時序表:
| 時間 | 執行緒A | 執行緒B |
|---|---|---|
| t1 | A1:執行類的靜態初始化和初始化類中宣告的靜態欄位 | B1:獲取到初始化鎖 |
| t2 | B2:讀取到state=initializing | |
| t3 | B3:釋放初始化鎖 | |
| t4 | B4:在初始化鎖的condition中等待 |
?
5.3 第三階段
執行緒A設定state=initialized,然后喚醒等待在condition上的所有執行緒

類初始化-第三階段執行時序表:
| 時間 | 執行緒A |
|---|---|
| t1 | A1:獲取初始化鎖 |
| t2 | A2:設定state=initialized |
| t3 | A3:喚醒在condition中等待的所有執行緒 |
| t4 | A4:釋放初始化鎖 |
| t5 | A5:執行緒A的初始化程序完成 |
5.4 第四階段
執行緒B結束類的初始化處理

類初始化-第四階段執行時序表:
| 時間 | 執行緒B |
|---|---|
| t1 | B1:獲取初始化鎖 |
| t2 | B2:讀取到state=initialized |
| t3 | B3:釋放初始化鎖 |
| t4 | B4:執行緒B的類的初始化程序完成 |
第五階段
執行緒C執行類的初始化處理

類初始化-第五階段執行時序表:
| 時間 | 執行緒C |
|---|---|
| t1 | C1:獲取初始化鎖 |
| t2 | C2:讀取到state=initialized |
| t3 | C3:釋放初始化鎖 |
| t4 | C4:執行緒B的類的初始化程序完成 |
由于在第三階段已經完成了類的初始化,因此執行緒C執行類的初始化程序相對簡單,
?
6、總結
通過對比基于volatile的雙重鎖定的方案和基于類初始化的方案,發現使用類初始化的方案實作的代碼更加簡潔,但是基于volatile的雙重檢查鎖定的方案有一個額外的優點就是其不僅可以對靜態欄位實作延遲初始化,也可以對實體欄位實作延遲初始化(因為JVM類初始化這個方案只能初始化靜態欄位),欄位延遲初始化降低了初始化類和創建實體帶來的開銷,但也增加了訪問被延遲初始化的欄位的開銷,而在實際開發中正常的初始化要優于延遲初始化,
如果確定要進行延遲初始化,那么具體如何選擇呢?
- 實體欄位延遲初始化使用volatile方案
- 靜態欄位延遲初始化使用類初始化方案
?
文章總結至《Java并發編程藝術》,Java記憶體模型的總結到此就完全結束了,花費了不少晚上,雖然文章知識點來自書本,但是作者也做了如下作業:
- 文章的重點知識做了梳理和標記
- 對黑白圖片做了彩色畫圖,使其更加易懂
- 對部分繁瑣的知識點做了概括
- 對少部分錯誤的知識(主要是錯字)進行了勘誤
- 對每一句代碼做了全部重寫和注釋
碼字不易,多多關注,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/287326.html
標籤:其他
下一篇:前端考核總結2
