徹頭徹尾理解單例模式及其在多執行緒環境中的應用
摘要:
本文首先概述了單例模式產生動機,揭示了單例模式的本質和應用場景。緊接著,我們給出了單例模式在單執行緒環境下的兩種經典實現:餓漢式 和懶漢式,但是餓漢式是執行緒安全的,而懶漢式是非執行緒安全的。在多執行緒環境下,我們特別介紹了五種方式來在多執行緒環境下建立執行緒安全的單例,即分別使用synchronized方法、synchronized塊、靜態內部類、雙重檢查模式
和ThreadLocal 來實現懶漢式單例,並總結出實現效率高且執行緒安全的懶漢式單例所需要注意的事項。
一. 單例模式概述
單例模式(Singleton),也叫單子模式,是一種常用的設計模式。在應用這個模式時,單例物件的類必須保證只有一個例項存在。
特別地,在計算機系統中,執行緒池、快取、日誌物件、對話方塊、印表機、顯示卡的驅動程式物件常被設計成單例。事實上,這些應用都或多或少具有資源管理器的功能。例如,每臺計算機可以有若干個印表機,但只能有一個 Printer Spooler(單例)
,以避免兩個列印作業同時輸出到印表機中。再比如,每臺計算機可以有若干通訊埠,系統應當集中 (單例)
綜上所述,單例模式就是為確保一個類只有一個例項,併為整個系統提供一個全域性訪問點的一種方法。
二. 單例模式及其單執行緒環境下的經典實現
單例模式應該是23種設計模式中最簡單的一種模式了,下面我們從單例模式的定義、型別、結構和使用要素四個方面來介紹它。
1、單例模式理論基礎
定義: 確保一個類只有一個例項,併為整個系統提供一個全域性訪問點 (向整個系統提供這個例項)。
型別: 建立型模式
結構:
特別地,為了更好地理解上面的類圖,我們以此為契機,介紹一下類圖的幾個知識點:
- 類圖分為三部分,依次是類名、屬性、方法;
- 以<<開頭和以>>結尾的為註釋資訊;
- 修飾符+代表public,-代表private,#代表protected,什麼都沒有代表包可見;
- 帶下劃線的屬性或方法代表是靜態的。
三要素:
-
私有的構造方法;
-
指向自己例項的私有靜態引用;
-
以自己例項為返回值的靜態的公有方法。
2、單執行緒環境下的兩種經典實現
在介紹單執行緒環境中單例模式的兩種經典實現之前,我們有必要先解釋一下 立即載入 和延遲載入 兩個概念。
-
立即載入 : 在類載入初始化的時候就主動建立例項;
-
延遲載入 : 等到真正使用的時候才去建立例項,不用時不去主動建立。
在單執行緒環境下,單例模式根據例項化物件時機的不同,有兩種經典的實現:一種是 餓漢式單例(立即載入),一種是 懶漢式單例(延遲載入)。餓漢式單例在單例類被載入時候,就例項化一個物件並交給自己的引用;而懶漢式單例只有在真正使用的時候才會例項化一個物件並交給自己的引用。程式碼示例分別如下:
餓漢式單例:
// 餓漢式單例
public class Singleton1 {
// 指向自己例項的私有靜態引用,主動建立
private static Singleton1 singleton1 = new Singleton1();
// 私有的構造方法
private Singleton1(){}
// 以自己例項為返回值的靜態的公有方法,靜態工廠方法
public static Singleton1 getSingleton1(){
return singleton1;
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
我們知道,類載入的方式是按需載入,且載入一次。。因此,在上述單例類被載入時,就會例項化一個物件並交給自己的引用,供系統使用;而且,由於這個類在整個生命週期中只會被載入一次,因此只會建立一個例項,即能夠充分保證單例。
懶漢式單例:
// 懶漢式單例
public class Singleton2 {
// 指向自己例項的私有靜態引用
private static Singleton2 singleton2;
// 私有的構造方法
private Singleton2(){}
// 以自己例項為返回值的靜態的公有方法,靜態工廠方法
public static Singleton2 getSingleton2(){
// 被動建立,在真正需要使用時才去建立
if (singleton2 == null) {
singleton2 = new Singleton2();
}
return singleton2;
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
我們從懶漢式單例可以看到,單例例項被延遲載入,即只有在真正使用的時候才會例項化一個物件並交給自己的引用。
總之,從速度和反應時間角度來講,餓漢式(又稱立即載入)要好一些;從資源利用效率上說,懶漢式(又稱延遲載入)要好一些。
3、單例模式的優點
我們從單例模式的定義和實現,可以知道單例模式具有以下幾個優點:
-
在記憶體中只有一個物件,節省記憶體空間;
-
避免頻繁的建立銷燬物件,可以提高效能;
-
避免對共享資源的多重佔用,簡化訪問;
-
為整個系統提供一個全域性訪問點。
4、單例模式的使用場景
由於單例模式具有以上優點,並且形式上比較簡單,所以是日常開發中用的比較多的一種設計模式,其核心在於為整個系統提供一個唯一的例項,其應用場景包括但不僅限於以下幾種:
- 有狀態的工具類物件;
- 頻繁訪問資料庫或檔案的物件;
5、單例模式的注意事項
在使用單例模式時,我們必須使用單例類提供的公有工廠方法得到單例物件,而不應該使用反射來建立,否則將會例項化一個新物件。此外,在多執行緒環境下使用單例模式時,應特別注意執行緒安全問題,我在下文會重點講到這一點。
三. 多執行緒環境下單例模式的實現
在單執行緒環境下,無論是餓漢式單例還是懶漢式單例,它們都能夠正常工作。但是,在多執行緒環境下,情形就發生了變化:由於餓漢式單例天生就是執行緒安全的,可以直接用於多執行緒而不會出現問題;但懶漢式單例本身是非執行緒安全的,因此就會出現多個例項的情況,與單例模式的初衷是相背離的。下面我重點闡述以下幾個問題:
-
為什麼說餓漢式單例天生就是執行緒安全的?
-
傳統的懶漢式單例為什麼是非執行緒安全的?
-
怎麼修改傳統的懶漢式單例,使其執行緒變得安全?
-
執行緒安全的單例的實現還有哪些,怎麼實現?
-
雙重檢查模式、Volatile關鍵字 在單例模式中的應用
-
ThreadLocal 在單例模式中的應用
特別地,為了能夠更好的觀察到單例模式的實現是否是執行緒安全的,我們提供了一個簡單的測試程式來驗證。該示例程式的判斷原理是:
開啟多個執行緒來分別獲取單例,然後列印它們所獲取到的單例的hashCode值。若它們獲取的單例是相同的(該單例模式的實現是執行緒安全的),那麼它們的hashCode值一定完全一致;若它們的hashCode值不完全一致,那麼獲取的單例必定不是同一個,即該單例模式的實現不是執行緒安全的,是多例的。注意,相應輸出結果附在每個單例模式實現示例後。
public class Test {
public static void main(String[] args) {
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new TestThread();
}
for (int i = 0; i < threads.length; i++) {
threads[i].start();
}
}
}
class TestThread extends Thread {
@Override
public void run() {
// 對於不同單例模式的實現,只需更改相應的單例類名及其公有靜態工廠方法名即可
int hash = Singleton5.getSingleton5().hashCode();
System.out.println(hash);
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
1、為什麼說餓漢式單例天生就是執行緒安全的?
// 餓漢式單例
public class Singleton1 {
// 指向自己例項的私有靜態引用,主動建立
private static Singleton1 singleton1 = new Singleton1();
// 私有的構造方法
private Singleton1(){}
// 以自己例項為返回值的靜態的公有方法,靜態工廠方法
public static Singleton1 getSingleton1(){
return singleton1;
}
}/* Output(完全一致):
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
*///:~
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
我們已經在上面提到,類載入的方式是按需載入,且只加載一次。因此,在上述單例類被載入時,就會例項化一個物件並交給自己的引用,供系統使用。換句話說,線上程訪問單例物件之前就已經建立好了。再加上,由於一個類在整個生命週期中只會被載入一次,因此該單例類只會建立一個例項,也就是說,執行緒每次都只能也必定只可以拿到這個唯一的物件。因此就說,餓漢式單例天生就是執行緒安全的。
2、傳統的懶漢式單例為什麼是非執行緒安全的?
// 傳統懶漢式單例
public class Singleton2 {
// 指向自己例項的私有靜態引用
private static Singleton2 singleton2;
// 私有的構造方法
private Singleton2(){}
// 以自己例項為返回值的靜態的公有方法,靜態工廠方法
public static Singleton2 getSingleton2(){
// 被動建立,在真正需要使用時才去建立
if (singleton2 == null) {
singleton2 = new Singleton2();
}
return singleton2;
}
}/* Output(不完全一致):
1084284121
2136955031
2136955031
1104499981
298825033
298825033
2136955031
482535999
298825033
2136955031
*///:~
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
上面發生非執行緒安全的一個顯著原因是,會有多個執行緒同時進入 if (singleton2 == null) {…} 語句塊的情形發生。當這種這種情形發生後,該單例類就會創建出多個例項,違背單例模式的初衷。因此,傳統的懶漢式單例是非執行緒安全的。
3、實現執行緒安全的懶漢式單例的幾種正確姿勢
1)、同步延遲載入 — synchronized方法
// 執行緒安全的懶漢式單例
public class Singleton2 {
private static Singleton2 singleton2;
private Singleton2(){}
// 使用 synchronized 修飾,臨界資源的同步互斥訪問
public static synchronized Singleton2 getSingleton2(){
if (singleton2 == null) {
singleton2 = new Singleton2();
}
return singleton2;
}
}/* Output(完全一致):
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
*///:~
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
該實現與上面傳統懶漢式單例的實現唯一的差別就在於:是否使用 synchronized 修飾 getSingleton2()方法。若使用,就保證了對臨界資源的同步互斥訪問,也就保證了單例。
從執行結果上來看,問題已經解決了,但是這種實現方式的執行效率會很低,因為同步塊的作用域有點大,而且鎖的粒度有點粗。同步方法效率低,那我們考慮使用同步程式碼塊來實現。
2)、同步延遲載入 — synchronized塊
// 執行緒安全的懶漢式單例
public class Singleton2 {
private static Singleton2 singleton2;
private Singleton2(){}
public static Singleton2 getSingleton2(){
synchronized(Singleton2.class){ // 使用 synchronized 塊,臨界資源的同步互斥訪問
if (singleton2 == null) {
singleton2 = new Singleton2();
}
}
return singleton2;
}
}/* Output(完全一致):
16993205
16993205
16993205
16993205
16993205
16993205
16993205
16993205
16993205
16993205
*///:~
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
該實現與上面synchronized方法版本實現類似,此不贅述。從執行結果上來看,問題已經解決了,但是這種實現方式的執行效率仍然比較低,事實上,和使用synchronized方法的版本相比,基本沒有任何效率上的提高。
3)、同步延遲載入 — 使用內部類實現延遲載入
// 執行緒安全的懶漢式單例
public class Singleton5 {
// 私有內部類,按需載入,用時載入,也就是延遲載入
private static class Holder {
private static Singleton5 singleton5 = new Singleton5();
}
private Singleton5() {
}
public static Singleton5 getSingleton5() {
return Holder.singleton5;
}
}
/* Output(完全一致):
482535999
482535999
482535999
482535999
482535999
482535999
482535999
482535999
482535999
482535999
*///:~
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
如上述程式碼所示,我們可以使用內部類實現執行緒安全的懶漢式單例,這種方式也是一種效率比較高的做法。至於其為什麼是執行緒安全的,其與問題 “為什麼說餓漢式單例天生就是執行緒安全的?” 相類似,此不贅述。
四. 單例模式與雙重檢查(Double-Check idiom)
使用雙重檢測同步延遲載入去建立單例的做法是一個非常優秀的做法,其不但保證了單例,而且切實提高了程式執行效率。對應的程式碼清單如下:
// 執行緒安全的懶漢式單例
public class Singleton3 {
//使用volatile關鍵字防止重排序,因為 new Instance()是一個非原子操作,可能建立一個不完整的例項
private static volatile Singleton3 singleton3;
private Singleton3() {
}
public static Singleton3 getSingleton3() {
// Double-Check idiom
if (singleton3 == null) {
synchronized (Singleton3.class) { // 1
// 只需在第一次建立例項時才同步
if (singleton3 == null) { // 2
singleton3 = new Singleton3(); // 3
}
}
}
return singleton3;
}
}/* Output(完全一致):
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
*///:~
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
如上述程式碼所示,為了在保證單例的前提下提高執行效率,我們需要對 singleton3 進行第二次檢查,目的是避開過多的同步(因為這裡的同步只需在第一次建立例項時才同步,一旦建立成功,以後獲取例項時就不需要同步獲取鎖了)。這種做法無疑是優秀的,但是我們必須注意一點:
必須使用volatile關鍵字修飾單例引用。
那麼,如果上述的實現沒有使用 volatile 修飾 singleton3,會導致什麼情形發生呢? 為解釋該問題,我們分兩步來闡述:
(1)、當我們寫了 new 操作,JVM 到底會發生什麼?
首先,我們要明白的是: new Singleton3() 是一個非原子操作。程式碼行singleton3 = new Singleton3(); 的執行過程可以形象地用如下3行虛擬碼來表示:
memory = allocate(); //1:分配物件的記憶體空間
ctorInstance(memory); //2:初始化物件
singleton3 = memory; //3:使singleton3指向剛分配的記憶體地址
- 1
- 2
- 3
- 1
- 2
- 3
但實際上,這個過程可能發生無序寫入(指令重排序),也就是說上面的3行指令可能會被重排序導致先執行第3行後執行第2行,也就是說其真實執行順序可能是下面這種:
memory = allocate(); //1:分配物件的記憶體空間
singleton3 = memory; //3:使singleton3指向剛分配的記憶體地址
ctorInstance(memory); //2:初始化物件
- 1
- 2
- 3
- 1
- 2
- 3
這段虛擬碼演示的情況不僅是可能的,而且是一些 JIT 編譯器上真實發生的現象。
(2)、重排序情景再現
瞭解 new 操作是非原子的並且可能發生重排序這一事實後,我們回過頭看使用 Double-Check idiom 的同步延遲載入的實現:
我們需要重新考察上述清單中的 //3 行。此行程式碼建立了一個 Singleton 物件並初始化變數 singleton3 來引用此物件。這行程式碼存在的問題是,在 Singleton 建構函式體執行之前,變數 singleton3 可能提前成為非 null 的,即賦值語句在物件例項化之前呼叫,此時別的執行緒將得到的是一個不完整(未初始化)的物件,會導致系統崩潰。下面是程式可能的一組執行步驟:
1、執行緒 1 進入 getSingleton3() 方法;
2、由於 singleton3 為 null,執行緒 1 在 //1 處進入 synchronized 塊;
3、同樣由於 singleton3 為 null,執行緒 1 直接前進到 //3 處,但在建構函式執行之前,使例項成為非 null,並且該例項是未初始化的;
4、執行緒 1 被執行緒 2 預佔;
5、執行緒 2 檢查例項是否為 null。因為例項不為 null,執行緒 2 得到一個不完整(未初始化)的 Singleton 物件;
6、執行緒 2 被執行緒 1 預佔。
7、執行緒 1 通過執行 Singleton3 物件的建構函式來完成對該物件的初始化。
顯然,一旦我們的程式在執行過程中發生了上述情形,就會造成災難性的後果,而這種安全隱患正是由於指令重排序的問題所導致的。讓人興奮地是,volatile 關鍵字正好可以完美解決了這個問題。也就是說,我們只需使用volatile關鍵字修飾單例引用就可以避免上述災難。
五. 單例模式 與 ThreadLocal
藉助於 ThreadLocal,我們可以實現雙重檢查模式的變體。我們將臨界資源執行緒區域性化,具體到本例就是將雙重檢測的第一層檢測條件 if (instance == null) 轉換為 執行緒區域性範圍內的操作 。這裡的 ThreadLocal 也只是用作標識而已,用來標識每個執行緒是否已訪問過:如果訪問過,則不再需要走同步塊,這樣就提高了一定的效率。對應的程式碼清單如下:
// 執行緒安全的懶漢式單例
public class Singleton4 {
// ThreadLocal 執行緒區域性變數
private static ThreadLocal<Singleton4> threadLocal = new ThreadLocal<Singleton4>();
private static Singleton4 singleton4 = null; // 不需要是
private Singleton4(){}
public static Singleton4 getSingleton4(){
if (threadLocal.get() == null) { // 第一次檢查:該執行緒是否第一次訪問
createSingleton4();
}
return singleton4;
}
public static void createSingleton4(){
synchronized (Singleton4.class) {
if (singleton4 == null) { // 第二次檢查:該單例是否被建立
singleton4 = new Singleton4(); // 只執行一次
}
}
threadLocal.set(singleton4); // 將單例放入當前執行緒的區域性變數中
}
}/* Output(完全一致):
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
*///:~
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
藉助於 ThreadLocal,我們也可以實現執行緒安全的懶漢式單例。但與直接雙重檢查模式使用,使用ThreadLocal的實現在效率上還不如雙重檢查鎖定。
六. 小結
本文首先介紹了單例模式的定義和結構,並給出了其在單執行緒和多執行緒環境下的幾種經典實現。特別地,我們知道,傳統的餓漢式單例無論在單執行緒還是多執行緒環境下都是執行緒安全的,但是傳統的懶漢式單例在多執行緒環境下是非執行緒安全的。為此,我們特別介紹了五種方式來在多執行緒環境下建立執行緒安全的單例,包括:
-
使用synchronized方法實現懶漢式單例;
-
使用synchronized塊實現懶漢式單例;
-
使用靜態內部類實現懶漢式單例;
-
使用雙重檢查模式實現懶漢式單例;
-
使用ThreadLocal實現懶漢式單例;
當然,實現懶漢式單例還有其他方式。但是,這五種是比較經典的實現,也是我們應該掌握的幾種實現方式。從這五種實現中,我們可以總結出,要想實現效率高的執行緒安全的單例,我們必須注意以下兩點:
-
儘量減少同步塊的作用域;
-
儘量使用細粒度的鎖。