單例模式
單例模式確保某個類在程序中只有一個實例,避免多次創建實例(禁止多次使用new
)。
要實現這一點,關鍵在于將類的所有構造方法聲明為private
。
這樣,在類外部無法直接訪問構造方法,new
操作會在編譯時報錯,從而保證類的實例唯一性。例如,在JDBC中,DataSource
實例通常只需要一個,單例模式非常適合這種場景。
單例模式的實現方式主要有兩種:“餓漢式”和“懶漢式”
餓漢模式
下面這段代碼,是對唯一成員 instance 進行初始化,用 static 修飾 instance,對 instance 的初始化,會在類加載的階段觸發;類加載往往就是在程序一啟動就會觸發;
由于是在類加載的階段,就早早地創建好了實例(static修飾),這也就是“餓漢模式” 名字的由來。
在初始化好 instance 后,后續統一通過調用 getInstance() 方法獲取 instance
單例模式的“點睛之筆”,用 private 修飾類中所有構造方法
,因為可以防止通過 new 關鍵字在類外部創建實例
,只能通過調用內部靜態方法,來獲取單例類實例:
懶漢模式
- 餓漢模式:
在類加載時即創建實例
,通過將構造方法聲明為private
,防止外部創建其他實例。- 懶漢模式:延遲創建
實例,僅在真正需要時才創建。
這種模式在某些情況下無需實例對象時,可避免不必要的實例化,減少開銷并提升效率。
單線程版本
在懶漢模式下,實例的創建時機是在第一次被使用時
,而不是在程序啟動時。
如果程序啟動后立即需要使用實例,那么懶漢模式和餓漢模式的效果相似。
然而,如果程序運行了較長時間仍未使用該實例,懶漢模式會延遲實例的創建,從而減少不必要的開銷
。
多線程版本
單例模式產生線程安全的原因
餓漢模式
懶漢模式
為什么會有單線程版本和多線程版本的懶漢模式寫法呢?我們來看單線程版本,如果運用到多線程的環境下,會出現什么問題:
在懶漢模式中,instance
被聲明為static
,因此多個線程調用getInstance()
時,返回的是同一個實例。
然而,getInstance()
方法中既包含讀操作(檢查instance
是否為null
),也包含寫操作(實例化instance
)。
盡管賦值操作本身是原子的,但整個getInstance()
方法并非原子操作。這意味著在多線程環境下,判斷和賦值操作不能保證緊密執行,從而導致線程安全問題。
在多線程環境下,若兩個線程(如 t1 和 t2)同時執行 getInstance()
方法,可能會導致值覆蓋問題。
如上圖,t2 線程的賦值操作可能會覆蓋 t1 線程新創建的對象,導致第一個線程創建的對象被垃圾回收(GC)
。
這不僅增加了不必要的開銷,還違背了單例模式的核心目標:避免重復創建實例,減少耗時操作,節省資源。
即使第一個對象很快被釋放,其創建過程中的數據加載依然會產生額外開銷。
總結:
- 餓漢模式:僅涉及對實例的讀操作,不涉及寫操作,因此天然線程安全。無論在單線程還是多線程環境下,其基本形式保持不變。
- 懶漢模式:在
getInstance()
中包含緊密相關的讀寫操作(檢查實例是否存在并創建實例),但這些操作無法緊密執行,導致線程安全問題。
解決單例模式的線程安全問題
面試題:
這兩個單例模式的 getInstance() 在多線程環境下調用,是否會出現 bug,如何解決 bug?
1. 通過加鎖讓讀寫操作緊密執行
餓漢模式本身不存在線程安全問題,因為它僅涉及讀操作,不涉及寫操作。
然而,懶漢模式在多線程環境下可能出現線程安全問題,原因在于getInstance()
方法中的讀寫操作(判斷 + 賦值)不能緊密執行。
為解決這一問題,需要對相關操作進行加鎖,以確保線程安全。
方法一:對方法中的讀操作加鎖
這樣加鎖后,如果 t1 和 t2 還出現下圖讀寫邏輯的執行順序:
- t2 會阻塞等待 t1(或 t1 等待 t2)完成對象的創建(讀寫操作結束后),釋放鎖后,第二個線程才能繼續執行。
- 此時,第二個線程發現
instance
已非null
,會直接返回已創建的實例,不再重復創建。
方法二:對整個方法加鎖
直接對getInstance()
方法加鎖,也能確保讀寫操作緊密執行。此時,鎖對象為SingletonLazy.class
。這兩種方法的效果相同
2. 處理加鎖引入的新問題
問題描述
對于當前懶漢模式的代碼,多個線程共享一把鎖,不會導致死鎖
。只需確保第一個線程調用getInstance()
時,讀寫操作緊密執行即可。
后續線程在讀取時發現instance != null
,就不會觸發寫操作
,從而自然保證了線程安全。
然而,若每次調用getInstance()
方法時都進行加鎖解鎖操作,由于synchronized
是重量級鎖,多次加鎖,尤其是重量級鎖會導致顯著的性能開銷,從而降低程序效率
。
拓展:
StringBuffer 就是為了解決,大量拼接字符串時,產生很多中間對象問題而提供的一個類,提供 append 和 insert 方法,可以將字符串添加到,已有序列的 末尾 或 指定位置。
StringBuffer 的本質是一個線程安全的可修改的字符序列,把所有修改數據的方法都加上了synchronized。但是保證了線程安全是需要性能的代價的。
在很多情況下我們的字符串拼接操作,不需要線程安全,這時候 StringBuilder 登場了,
StringBuilder 是 JDK1.5 發布的, StringBuilder 和 StringBuffer 本質上沒什么區別,就是去掉了保證線程安全的那部分,減少了開銷。所以在單線程情況下,優先考慮使用 StringBuilder。
StringBuffer 和 StringBuilder 二者都繼承了 AbstractStringBuilder,底層都是利用可修改的 char數組 (JDK9以后是 byte 數組)。
所以如果我們有大量的字符串拼接,如果能預知大小的話最好在new StringBuffer 或者 new StringBuilder 的時候設置好 capacity ,避免多次擴容的開銷(擴容要拋棄原有數組,還要進行數組拷貝創建新的數組)。
解決方法
再嵌套一次判斷操作,既可以保證線程安全,又可以避免大量加鎖解鎖產生的開銷:
在單線程環境下,嵌套兩層相同的if
語句并無意義,因為單線程只有一個執行流,嵌套與否結果相同。但在多線程環境下,多個并發執行流,可能導致不同線程在執行判斷操作時,因其他線程修改了instance
而得到不同結果。
例如,在懶漢模式下,即使兩個
if
語句形式相同
,其目的和作用卻不同
:
- 第一個
if
用于判斷是否需要加鎖;- 第二個
if
用于判斷是否需要創建對象。這種結構雖看似巧合,但實則必要。
3. 引入 volatile 關鍵字
問題描述
在懶漢模式的單例實現中
,使用volatile
關鍵字修飾instance
至關重要。以下是懶漢模式
的單例實現代碼:
private static SingletonLazy instance = null;public static SingletonLazy getInstance() {if (instance == null) {synchronized (SingletonLazy.class) {if (instance == null) {instance = new SingletonLazy();}}}return instance;
}
如果不使用volatile
修飾instance
,可能會出現以下問題:
內存可見性問題
核心問題
- 在沒有
volatile
修飾時,線程t1
對instance
的寫入可能僅停留在線程本地緩存(CPU緩存或寄存器),而非立即同步到主內存
。 - 此時線程
t2
讀取的可能是自己緩存中的舊值(null
),即使t1
已完成初始化。 - 即使
t2
進入同步塊,第一次判空(if (instance == null)
仍可能讀取到未更新的緩存值,導致不必要的鎖競爭。 - 第二次判空
if (instance == null)
,t2
可能會錯誤地認為instance == null
,并再次執行實例化邏輯,導致又重復創建了新的實例。
內存可見性底層分析
- 硬件層面的原因
存儲層級 | 讀寫速度 | 存儲大小 | 特性 |
---|---|---|---|
寄存器 | 最快 | 最小(幾十字節) | CPU直接計算使用的臨時存儲 |
CPU緩存 (L1/L2/L3) | 快 | 較小(KB~MB級) | 每個CPU核心/多核共享,減少訪問主存延遲 |
主內存 (RAM) | 慢 | 大(GB級) | 所有線程共享,但訪問速度比緩存慢100倍以上 |
- 速度差異:CPU為了避免等待慢速的主內存讀寫,會優先使用緩存和寄存器(如將
instance
的值緩存在核心的L1緩存中)。 - 副作用:線程
t1
修改instance
后,可能僅更新了當前核心的緩存,而其他核心的緩存或主內存未被同步,導致t2
讀取到過期數據。
- Java內存模型(JMM)的抽象
- 硬件差異被
JMM
抽象為工作內存(線程私有)
和主內存(共享)
的分離: 工作內存
:包含CPU寄存器、緩存等線程私有的臨時存儲
。主內存
:所有線程共享的真實內存
。
- 問題本質:
- 當線程
t1
未強制同步(如缺少volatile
或鎖)時,JVM/CPU
可能延遲將工作內存的修改刷回主內存,其他線程也無法感知變更。
指令重排序
指令重排序的具體問題
instance = new SingletonLazy()
的實際操作可分為以下步驟(可能被JVM/CPU重排序):
1. 分配對象內存空間(堆上分配,此時內存內容為默認值0/null)
2. 調用構造函數(初始化對象字段)
3. 將引用賦值給 instance 變量(此時 instance != null)
可能的危險重排序:
- JVM可能將步驟 3(賦值) 和 2(構造) 調換順序,導致:
1. 分配內存
2. 賦值給 instance(此時 instance != null,但對象未初始化!)
3. 執行構造函數
這就是指令重排序問題。
- 多線程場景下指令重排序的后果
- 線程 t1 執行
getInstance()
時發生重排序:- 先執行步驟1和3,
instance
已不為null
,但對象未構造完成。
- 先執行步驟1和3,
- 線程 t2 調用
getInstance()
:- 第一次判空
if (instance == null)
會跳過 - 若 t2 立刻調用
instance.func()
,會訪問未初始化的字段,導致:- 空指針異常(如果
func()
訪問未初始化的引用字段)。 - 數據不一致(如果
func()
依賴構造函數中初始化的值)。
- 空指針異常(如果
- 第一次判空
解決方法
使用volatile
修飾instance
后,不僅能確保每次讀取操作都直接從內存中讀取,還能防止與該變量相關的讀取和修改操作發生重排序。
private volatile static SingletonLazy instance;public static SingletonLazy getInstance() {if (instance == null) { // 第一次無鎖檢查synchronized (locker) { // 同步塊if (instance == null) { // 第二次檢查instance = new SingletonLazy(); // 受volatile保護}}}return instance;
}
volatile
是怎么解決內存可見性
問題的呢?
通過內存屏障(Memory Barrier)
直接操作硬件層
:
- 寫操作:強制將當前核心的緩存行(Cache Line)寫回主內存,并失效其他核心的緩存。
- 讀操作:強制從主內存重新加載數據,跳過緩存。
private static volatile SingletonLazy instance; // 通過volatile禁止緩存優化
總結
- 直接原因:CPU緩存和寄存器的速度優化導致可見性問題。
- 根本原因:硬件架構與編程語言內存模型的設計差異(JMM需在性能與正確性間權衡)。
- 解決方案:
volatile
通過內存屏障強制同步硬件層和JMM的約定。
總結:為什么雙重檢查鎖(DCL)必須用volatile
?
- 可見性:確保
t1
的初始化結果對t2
立即可見。 - 禁止指令重排序:
instance = new SingletonLazy()
的字節碼可能被重排序為:- 分配內存空間
- 將引用寫入
instance
(此時instance != null
但對象未初始化!) - 執行構造函數
volatile
會禁止這種重排序,保證步驟2在3之后執行
。
4. 指令重排序問題
模擬編譯器指令重排序情景
要在超市中買到左邊購物清單的物品,有兩種買法
方法一:根據購物清單的順序買;(按照程序員編寫的代碼順序進行編譯)
方法二:根據物品最近距離購買;(通過指令重排序后再編譯)
兩種方法都能買到購物清單的所有物品,但是比起第一種方法,第二種方法在不改變原有邏輯的情況下,優化執行指令順序,更高效地執行完所有的指令
。
指令重排序概述
指令重排序的定義
指令重排序是指編譯器或處理器為了提高性能,在不改變程序執行結果的前提下,對指令序列進行重新排序的優化技術。這種技術可以讓計算機在執行指令時更高效地利用計算資源,從而提高程序的執行效率。
指令重排序的類型
- 編譯器重排序
編譯器在生成目標代碼時會對源代碼中的指令進行優化和重排,以提高程序的執行效率。這一過程在編譯階段完成,目的是生成更高效的機器代碼。
- 處理器重排序
處理器在執行指令時也可以對指令進行重排序,以最大程度地利用處理器的流水線和多核等特性,從而提高指令的執行效率。
指令重排序引發的問題
盡管指令重排序可以提高程序的執行效率,但在多線程編程中可能會引發內存可見性問題。由于指令重排序可能導致共享變量的讀寫順序與代碼中的順序不一致,當多個線程同時訪問共享變量時,可能會出現數據不一致的情況。
指令重排序解決方案
為了解決指令重排序帶來的問題,可以采取以下措施:
- 編譯器層面:通過禁止特定類型的編譯器重排序,確保指令的執行順序符合預期。
- 處理器層面:通過插入
內存屏障(Memory Barrier)
來禁止特定類型的處理器重排序。內存屏障是一種CPU指令,用來禁止處理器指令發生重排序,從而保障指令執行的有序性。此外,內存屏障還會在處理器寫入或讀取值之前,將主內存的值寫入高速緩存并清空無效隊列,從而保障變量的可見性。