作爲被面試官最喜歡問到的23種設計模式之一,我們不得不熟練掌握單例模式以及洞悉多線程環境下,單例模式所存在的非線程安全問題以及它的解決方式。
注:這篇文章主要講述多線程環境下單例模式存在的非線程安全問題,並不詳細講述單例模式。
何爲單例模式
首先我們先大概瞭解一下單例模式的定義:
- 單例類只能有一個實例。
- 單例類必須自己創建自己的唯一實例。
- 單例類必須給所有其他對象提供這一實例。
單例模式的應用非常廣泛,例如在計算機系統中,線程池、緩存、日誌對象、對話框、打印機、顯卡的驅動程序對象常被設計成單例。這些應用都或多或少具有資源管理器的功能。每臺計算機可以有若干個打印機,但只能有一個Printer Spooler,以避免兩個打印作業同時輸出到打印機中。選擇單例模式就是爲了避免不一致狀態。
單例模式的實現有三種方式:餓漢式(天生線程安全),懶漢式,登記式(可忽略)。
對於上面單例模式的實現方式我在這裏不做過多介紹,我們着重來看一下懶漢式在多線程環境下出現的問題以及它的解決策略。
設計線程安全的單例模式
DCL雙檢查鎖機制
其實我覺得能看這篇文章的夥伴們對設計線程安全的單例模式都是有一定的瞭解,所以對於解決非線程安全的單例模式的3種方式也應該有些瞭解。我們再來總結一下這三種方式:聲明synchronized關鍵字(同步代碼塊),DCL雙檢查鎖機制,靜態內置類的實現。
關於第一種方式,我覺得大家應該沒有什麼疑惑,所以我在這裏也就不再講述了,咱們來看一下我在學習雙檢查鎖機制過程中遇到的問題,是否和你一樣。
這是單例類,注意private volatile static MyObject myObject
這句話。
public class MyObject {
private volatile static MyObject myObject;
private MyObject() {
}
public static MyObject getInstance() {
try {
if (myObject != null) {
} else {
//模擬在創建對象之前做的一些準備工作
Thread.sleep(3000);
synchronized (MyObject.class) {
if (myObject == null) {
myObject = new MyObject();
}
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return myObject;
}
}
線程類:
public class MyThread extends Thread {
@Override
public void run() {
out.println(MyObject.getInstance().hashCode());
}
}
測試類:
public class Run {
public static void main(String[] args) {
MyThread thread1 = new MyThread();
MyThread thread2 = new MyThread();
MyThread thread3 = new MyThread();
thread1.start();
thread2.start();
thread3.start();
}
}
最終結果:
773715418
773715418
773715418
我們可以看到,使用了Double-Check,使得在多線程環境下,也只能取得類的唯一實例。但是不知道你有沒有和我一樣的疑惑,看我上面着重提出來的那句話,我們爲什麼在聲明MyObject對象的時候還要給它加上volatile關鍵字?我們在Double-Check下已經加入了synchronized關鍵字,既然synchronized已經起到了多線程下原子性、有序性、可見性的作用,爲什麼還要加volatile呢?要解決這個問題,我們需要深入瞭解volatile關鍵字的特性,它不僅可以使變量在多個線程之間可見,而且它還具有禁止JVM進行指令重排序的功能,具體請參見JVM–從volatile深入理解Java內存模型這篇文章。
首先,我們需要明白的是:創建一個對象可以分解爲如下的3行僞代碼:
memory=allocate(); //1.分配對象的內存空間
ctorInstance(memory); //2.初始化對象
instance=memory; //3.設置instance指向剛分配的內存地址。
//上面3行代碼中的2和3之間,可能會被重排序導致先3後2
也就是說,myObject = new MyObject()
這句話並不是一個原子性操作,在多線程環境下有可能出現非線程安全的情況。
現在我們先假設一下,如果此時不設置volatile關鍵字會發生什麼。
假設兩個線程A、B,都是第一次調用該單例方法,線程A先執行myObject = new MyObject()
,該構造方法是一個非原子操作,編譯後生成多條字節碼指令,由於JAVA的指令重排序,可能會先執行myObject的賦值操作,該操作實際只是在內存中開闢一片存儲對象的區域後直接返回內存的引用,之後myObject便不爲空了,但是實際的初始化操作卻還沒有執行,如果就在此時線程B進入,就會看到一個不爲空的但是不完整(沒有完成初始化)的MyObject對象,所以需要加入volatile關鍵字,禁止指令重排序優化,從而安全的實現單例。
因此我們以後應該記得,在使用Double-Check的時候,那個volatile至關重要。並不是可要可不要的。