項目中一次單例模式的優化
封裝了一個 ConsumerClient, 項目中有多個任務需求需要使用到 Kafaka, 為了保證項目中只有一個 Kafka 連接實例, 提供一個全局訪問點, 所以我使用單例模式來創建.
一、線程不安全的懶漢單例
直接使用懶漢單例, 這樣如果系統中沒有連接 Kafka 需求時就不需要 創建連接了.最簡單直接的方法.
假設此時有 A、B 兩個線程需要調用 getInstance(), 并且 A 線程調用執行到代碼 1 處的同時 B 線程執行到 代碼 2 處,
線程 A 看見 consumerClient 沒有被創建, 而線程 B 又正在創建實例, 由此引發線程被創建兩次, 這是一個不安全的線程;
public class ProducerClient {
private static ProducerClient producerClient;
public static ConsumerClient getInstance() {
if (consumerClient == null) { // 1: A 線程 執行
consumerClient = new ConsumerClient(); // 2: B 線程 執行
}
return consumerClient;
}
}
二、線程安全的單例
所以接下來最簡單的方法就是對 getInstance() 做同步處理來實現線程安全, 這樣每次只有一個線程能夠進入 getInstance(), 其他線程就需要等待了;
但是如果getInstance()被多個線程頻繁調用, synchronized 也會隨之帶來性能開銷 (其實這里已經足夠滿足當前項目中使用了, 另外JDK從1.6開始已經做了很大優化);
這里帶來的問題時盡管 ProducerClient 實例已將被創建, 但是后面線程每次調用 getInstance() 都需要獲取鎖.
即如果 B 線程在 getInstance() 中, 則 A 需要在方法外等待;
public class ProducerClient {
private static ProducerClient producerClient;
public synchronized static ConsumerClient getInstance() { // 加鎖處理
if (consumerClient == null) {
consumerClient = new ConsumerClient();
}
return consumerClient;
}
}
參考
三、使用雙重校驗鎖優化
不過我在閱讀 <<Java并發編程的藝術>>第3章 Java內存模型 中提到早期人們為了應對 synchronized 帶來的性能瓶頸問題, 降低同步開銷,
提出了一個"聰明"的技巧: 雙重檢查校驗鎖(Double-Checked Locking).
雙重檢查, 即兩次檢查實例是否創建.
- 加鎖處理保證了只有一個線程能創建對象;
- 第一次檢查的作用是為了實例如果被創建, 執行
getInstance()就不需要獲取鎖了, 直接返回實例對象.
public class ProducerClient {
private static ProducerClient producerClient;
public static ConsumerClient getInstance() {
if (consumerClient == null) { // 1: 第一次檢查對象是否創建
synchronized(ProducerClient.class) { // 2: 沒有創建, 加鎖處理
if (consumerClient == null) { // 3: 再一次檢查對象是否創建
consumerClient = new ConsumerClient(); // 4: 創建對象
}
}
}
return consumerClient;
}
}
這里看著很完美啊, 我也沒有看出有什么問題, 繼續看書;
重排序, 是重排序問題, 代碼 4 可以被一些 JIT 編譯器重排序
如下, 2 和 3 之間沒有數據依賴, 滿足 as-if-serial 語義, 符合happens-before 規則, 可以被重排序.
單程序倒是沒有問題, 這里重排序后就導致問題:
對象實例還沒有完成初始化就返回, 導致別的線程獲取到一個還沒有完成初始化的對象.
解決方案有兩個:
- 不允許 2 3 重排序;
- 允許2、3 重排, 當是不允許別的線程 "看到" 這個重排序.
// 拆解 consumerClient = new ConsumerClient(); // 4: 創建對象
memory = allocate(); // 1:分配對象的內存空間
ctorInstance(memory); // 2:初始化對象
instance = memory; // 3:設置instance指向剛分配的內存地址
// 重排序
memory = allocate(); // 1:分配對象的內存空間
instance = memory; // 3:設置instance指向剛分配的內存地址
ctorInstance(memory); // 2:初始化對象
參考
四、雙重校驗鎖的優化
結局方案有兩個, 一是基于 volatile , 二是基于類初始化的解決方案.
基于 volatile 關鍵字
(JDK 5 之后的JSR-133 內存模型規范, 這個規范增強了 volatile 的語義)
申明之后, 在多線程環境中此重排序會被禁止;
public class ProducerClient {
private volatile static ProducerClient producerClient; // volatile 關鍵字
public static ConsumerClient getInstance() {
if (consumerClient == null) {
synchronized(ProducerClient.class) { // 2: 沒有創建, 加鎖處理
if (consumerClient == null) { // 3: 再一次檢查對象是否創建
consumerClient = new ConsumerClient(); // 4: 創建對象
}
}
}
return consumerClient;
}
}
基于類初始化的方案 (InstanceHolder單例模式 )
JVM在類的初始化階段(即在Class被加載后,且被線程使用之前),會執行類的初始化。在
執行類的初始化期間,JVM會去獲取一個鎖。這個鎖可以同步多個線程對同一個類的初始化。
基于這個特性,可以實現另一種線程安全的延遲初始化方案(這個方案被稱之為
Initialization On Demand Holder idiom)。
這個方案解釋起來比較復雜, 感興趣可以去看看書中的分析, 這里不在贅述.
public class ProducerClient {
private static class InstanceHolder{
public static ProducerClient client = new ProducerClient();
}
public static ConsumerClient getInstance() {
return InstanceHolder.client; // 這里將導致 InstanceHolder 類被初始化
}
}
如何選擇這兩個方案
字段延遲初始化降低了初始化類或創建實例的開銷,但增加了訪問被延遲初始化的字段
的開銷。在大多數時候,正常的初始化要優于延遲初始化。
- 需要對 實例字段 使用線程安全的延遲初始化,基于volatile的方案;
- 需要對 靜態字段 使用線程安全的延遲初始化,基于類初始化的方案.
我最終選用使用 volatile 優化, 而不選類初始化的方案, 原因是因為我在new ProducerClient() 中會去連接 Kafka, 避免造成每次啟動項目都去連接, 有時候不需要連接 Kafka.

浙公網安備 33010602011771號