單例模式各版本的原理與實踐

作者:曹豐斌

連結:https://www。jianshu。com/p/fc7fc57d4360

1。單例模式概述

(1)引言

單例模式是應用最廣的模式之一,也是23種設計模式中最基本的一個。本文旨在總結透過Java實現單例模式的各個版本的優缺點及適用場景,詳細分析如何實現執行緒安全的單例模式,並探討單例模式的一些擴充套件。

(2)單例模式的定義

Ensure a class has only one instance,and provide a global point of access to it.(確保某一個類只有一個例項,並且自行例項化並向整個系統提供這個例項)

通用類圖為:

單例模式各版本的原理與實踐

Singleton類稱為單例類,透過使用private的建構函式確保了在一個應用中只產生一個例項,並且是自行例項化的(在Singleton中自己使用new Singleton())。

(3)使用場景

在一個系統中,要求一個類有且僅有一個物件,如果出現多個物件就會出現“不良反應”,可以採用單例模式,具體的場景如下:

要求生成唯一序列號的環境;

在整個專案中需要一個共享訪問點或共享資料,例如一個Web頁面上的計數器,可以不用把每次重新整理都記錄到資料庫中,使用單例模式保持計數器的值,並確保是執行緒安全的;

建立一個物件需要消耗的資源過多,如要訪問IO和資料庫等資源;

需要定義大量的靜態常量和靜態方法(如工具類)的環境,可以採用單例模式(當然,也可以直接宣告為static的方式)。

(4)優缺點

單例模式的優點

由於單例模式

在記憶體中只有一個例項,減少了記憶體開支

,特別是一個物件需要頻繁地建立銷燬時,而且建立或銷燬時效能又無法最佳化,單例模式的優勢就非常明顯;

由於單例模式只生成一個例項,所以減少了系統的效能開銷,當一個物件的產生需要比較多的資源的時候,如讀取配置,產生其他的依賴物件時,可以透過在應用啟動的時候直接產生一個單例物件,然後用永久駐留記憶體的方式來解決;

單例模式可以避免對資源的多重佔用,例如對一個寫檔案動作,由於只有一個例項存在記憶體中,避免對同一個資原始檔的同時寫操作;

單例模式可以在系統設定全域性的訪問點,最佳化和共享資源訪問,例如可以設計一個單例類,負責所有資料表的對映處理

單例模式的缺點

單例模式一般沒有介面,擴充套件困難,若要擴充套件,除了修改程式碼基本上沒有第二種途徑可以實現;

單例模式與單一職責原則有衝突,一個類應該只實現一個邏輯,而不關心他是否是單例的

,是不是要單例取決於環境,單例模式把“要單例”和業務邏輯融合在一個類中。

2。最基本的實現方式

程式碼實現為:

public

class

Singleton

{

private

static

Singleton singleton;

// 限制產生多個物件

private

Singleton

{

}

// 獲得物件例項的方法

public

static

Singleton

getSingleton

{

if

(singleton ==

null

) {

singleton =

new

Singleton();

}

return

singleton;

}

}

相信大多數同學在入門Java的階段都見過這段程式碼。該方式在低併發的情況下尚不會出現問題,若系統壓力增大,併發量增加時則可能在記憶體中出現多個例項,破壞設計的初衷。本文的後續就是圍繞這種實現分析改進,探討實現執行緒安全的單例模式的最佳實踐。

為什麼這種實現是執行緒不安全的呢?如一個執行緒A執行到

singleton = new Singleton();

這裡,

但還沒有獲得物件(物件初始化是需要時間的)

,第二個執行緒B也在執行,執行到

if(singleton == null)

判斷,那麼執行緒B獲得判斷條件也是為真,於是繼續執行下去,執行緒A獲得了一個物件,執行緒B也獲得了一個物件,在記憶體中就出現兩個物件,造成單例模式的失效!!

所以根本原因在於可能存在多個執行緒併發的訪問getSingleton()方法造成單例物件的多次建立,解決因多執行緒併發訪問導致單例模式實效的最佳方法就是——不要使用多執行緒併發訪問。(⊙o⊙)…

單例模式各版本的原理與實踐

3。餓漢式

(1)實現原理

言歸正傳,上面說的問題其實就是對

if(singleton == null)

的判斷失效造成

singleton = new Singleton();

可能會被多個執行緒併發的執行。餓漢式單例模式的實現的本質其實就是依賴類載入機制保證構造方法只會被執行一次。JVM在類的初始化階段(即在Class被載入後,且被執行緒使用之前),會執行類的初始化。

在執行類的初始化期間,JVM會去獲取一個鎖。這個鎖可以同步多個執行緒對同一個類的初始化

餓漢式單例的實現程式碼為:

public

class

Singleton

{

private

static

Singleton singleton =

new

Singleton();

private

Singleton

()

{

}

// 獲得物件例項的方法

public

static

Singleton

getSingleton

()

{

return

singleton;

}

}

(2)優缺點及適用場景

可以看到餓漢式的實現非常簡單,

適合那些在初始化時就要用到單例的情況

,如果單例物件初始化非常快,而且佔用記憶體非常小的時候這種方式是比較合適的,可以直接在應用啟動時載入並初始化。

不適用的場景:

單例初始化的操作耗時比較長而應用對於啟動速度又有要求;

單例的佔用記憶體比較大;

單例只是在某個特定場景的情況下才會被使用,而一般情況下是不會使用的;

在上述的幾種情況下使用餓漢式的單例模式是不合適的,這時候就需要

用到懶漢式的方式去按需延遲載入單例

4。利用同步鎖機制實現的懶漢式

實現程式碼為:

public

class

Singleton

{

private

static

Singleton singleton =

null

private

Singleton

()

{

}

// 獲得物件例項的方法

public

static

Singleton

getSingleton

()

{

synchronized

(Singleton。class) {

if

(singleton ==

null

) {

singleton =

new

Singleton();

}

}

return

singleton;

}

}

這種是最常見的懶漢式單例實現,

使用同步鎖synchronized(Singleton.class)防止多執行緒同時進入造成instance被多次例項化

。但是他的缺陷也是非常明顯的,就是每次在呼叫getSingleton()獲取單例的例項的時候,都需要進行同步。

事實上我們只想保證一次初始化成功,其餘的快速返回而已,如果在getInstance頻繁使用的地方就要考慮重新優化了。

5。對同步鎖機制實現懶漢式的改進——DCL

(1)原理與實現

由於synchronized(甚至是無競爭的synchronized)存在著巨大的效能開銷。因此,人們想出了一個

“聰明”的技巧:雙重檢查鎖定(double-checked locking)。

透過這種方式來降低同步的開銷。下面是使用雙重檢查鎖定來實現延遲初始化的程式碼實現:

public

class

Singleton

{

private

static

Singleton instance =

null

//1

// 獲得物件例項的方法

public

static

Singleton

getSingleton

()

{

//2

if

(instance ==

null

) {

//3:第一次檢查

synchronized

(Singleton。class) {

//4:加鎖

if

(instance ==

null

//5:第二次檢查

instance =

new

Singleton();

//6:問題的根源產生

}

}

return

instance;

}

private

Singleton

()

{

}

}

如上面的程式碼所示,它的“優點”如下:

在多個執行緒試圖在同一時間建立物件時,會透過加鎖來保證只有一個執行緒能建立物件;

在物件建立好了以後,執行getSingleton()方法將不需要獲取鎖,直接返回已經建立好的物件

雙重檢查模式看上去好像很完美,但這是一個錯誤的最佳化,線上程執行到上面所示的程式碼3處讀取到singleton物件不為null時,singleton引用的物件可能還沒有完成初始化

(2)問題分析

可能產生錯誤的場景

1。執行緒A進入getSingleton()方法;

2。因為此時instance為null,所以執行緒A進入synchronized塊;

3。

執行緒A執行 instance = new Singleton(); 把例項變數instance設定成了非空。(注意,是在呼叫構造方法之前)

4。執行緒A退出,執行緒B進入。

5。執行緒B檢查instance是否為空,此時不為空(第三步的時候被執行緒A設定成了非空)。執行緒B返回instance的引用。(

問題出現了,這時instance的引用並不是Singleton的例項,因為沒有呼叫構造方法

6。執行緒B退出,執行緒A進入;

7。執行緒A繼續呼叫構造方法,完成instance的初始化,再返回。

(3)問題根源

多執行緒問題,很大程度是由於非原子性造成的

,如果我們每一個可能產生競爭的地方都是原子性的,那多執行緒需要考慮的東西就要少很多了。在上述程式中也是一樣,我們看第六行程式碼:

instance =

new

Singleton();

//6:問題的根源產生

在JMM中,這行程式碼可以分解為3個過程:

memory = allocate(); //

#1為物件分配記憶體空間

init(memory); //

#2初始化

instance = memory; //

#3設定instance,將其指向剛分配的記憶體空間。

上面的3行程式碼,

如果是順序執行,不會帶來問題。但是,在某些JIT編譯器上,#2和#3可能發生重排序

。也就是說,重排序後,上面三個過程變成了:

memory = allocate(); //

#1為物件分配記憶體空間

instance = memory; //

#2

init(memory); //

#3初始化

根據《The Java Language Specification, Java SE 7 Edition》一書中的內容:所有執行緒在執行java程式時必須要遵守intra-thread semantics。

intra-thread semantics保證重排序不會改變單執行緒內的程式執行結果。換句話來說,intra-thread semantics允許那些在單執行緒內,不會改變單執行緒程式執行結果的重排序

。上面三行虛擬碼的2和3之間雖然被重排序了,但這個重排序並不會違反intra-thread semantics。這個重排序在沒有改變單執行緒程式的執行結果的前提下,可以提高程式的執行效能。

下面,再讓我們看看多執行緒併發執行的時候的情況。請看下面的示意圖:

單例模式各版本的原理與實踐

這裡2和3雖然重排序了,但java記憶體模型的intra-thread semantics將確保2一定會排在4前面執行。因此執行緒A的intra-thread semantics沒有改變。但2和3的重排序,將導致執行緒B在B1處判斷出instance不為空,執行緒B接下來將訪問instance引用的物件。此時,執行緒B將會訪問到一個還未初始化的物件。

分析清楚問題發生的根源之後,可以想出兩個辦法來實現執行緒安全的延遲初始化:

不允許2和3重排序;

允許2和3重排序,但不允許其他執行緒“看到”這個重排序。

後文介紹的解決方案就分別對應於上面這兩點。

6。Java1。5以後安全的DCL版本

(1)實現程式碼

public

class

Singleton

{

private

volatile

static

Singleton instance =

null

// 獲得物件例項的方法

public

static

Singleton

getSingleton

()

{

if

(instance ==

null

) {

synchronized

(Singleton。class) {

if

(instance ==

null

instance =

new

Singleton();

}

}

return

instance;

}

private

Singleton

()

{

}

}

(2)原理分析

可以發現程式碼只做一點小的修改(把instance宣告為volatile型),

為什麼volatile可以解決呢?回顧一下他的兩層語義:

(1)可見性:指的是在一個執行緒中對該變數的修改會馬上由工作記憶體(Work Memory)寫回主記憶體(Main Memory)

(2)禁止指令重排序最佳化

當宣告物件的引用為volatile後,“問題的根源”的三行虛擬碼中的2和3之間的重排序,在多執行緒環境中將會被禁止,從而在根本上解決了問題。但是很不幸,

禁止指令重排最佳化這條語義直到jdk1.5以後才能正確工作。此前的JDK中即使將變數宣告為volatile也無法完全避免重排序所導致的問題

。所以,在jdk1。5版本前,雙重檢查鎖形式的單例模式是無法保證執行緒安全的。

寫到這裡,可能有的同學會有疑問了:

單例模式各版本的原理與實踐

7。Java1。4以前安全的DCL版本

額(⊙o⊙)…雖然現在的日常開發已經普遍在使用Java1。7甚至1。8了。不過嘗試著探討下在Jav1。4以前實現安全的DCL還是一個還有意思的話題。我自己也沒有找到太確定的答案,這方面的資料也非常的少。下面給出的實現程式碼不一定能保證正確,貼出來僅供參考,歡迎有興趣的同學在評論區留言分享一下經驗。

單例模式各版本的原理與實踐

(2)實現程式碼及思路

public

class

Singleton

{

private

static

Singleton instance =

null

// 獲得物件例項的方法

public

static

Singleton

getSingleton

()

{

if

(instance ==

null

) {

//1。第一次檢查

synchronized

(Singleton。class) {

//2。第一個synchronized塊

Singleton temp = instance;

//3。給臨時變數temp賦值

if

(temp ==

null

) {

//4。第二次檢查

synchronized

(Singleton。class) {

//5。第二個synchronized塊

temp =

new

Singleton();

//6。解決問題的關鍵地方

}

instance = temp;

//7。把temp的引用賦值給instance

}

}

}

return

instance;

}

private

Singleton

()

{

}

}

上面給出的程式碼中,很關鍵的地方在於在synchronized塊中引入了一個臨時變數Singleton temp,透過對temp的判空及相應的初始化,保證在程式碼7處,執行intance = temp;時,

instance不為null且完成了初始化

8。內部類方式

(1)實現

在第五部分的結尾我們提到了兩個辦法來實現執行緒安全的延遲初始化,內部類方式正是基於第二種方法——

執行緒之間重排序透明性

實現程式碼為:

public

class

Singleton

{

// 獲得物件例項的方法

public

static

Singleton

getSingleton

()

{

return

SingletonHolder。instance;

}

/**

* 靜態內部類與外部類的例項沒有繫結關係,而且只有被呼叫時才會

* 載入,從而實現了延遲載入

*/

private

static

class

SingletonHolder

{

/**

* 靜態初始化器,由JVM來保證執行緒安全

*/

private

static

Singleton instance =

new

Singleton();

}

private

Singleton

()

{

}

}

(2)原理分析

在這種方式中,使用了一個專門的內部類來初始化Singleton,JVM將推遲SingletonHolder的初始化操作,直到開始使用這個類時才初始化。並且在初始化的過程中JVM會去獲取一個用於同步多個執行緒對同一個類進行初始化的鎖,這樣就不需要額外的同步。

這種方式不僅能夠保證執行緒安全,也能保證單例物件的唯一性,同時也延遲例項化,是一種非常推薦的方式

單例模式各版本的原理與實踐

9。列舉方式

(1)實現方法

從Java1。5起,可以透過使用列舉機制來實現單例模式:

public

enum

Singleton {

// 定義列舉元素,他就是Singleton的一個例項

INSTANCE;

public

void

doSomething

()

{

// do something

}

}

呼叫方式

Singleton singleton = Singleton。INSTANCE;

singleton。doSomething();

可以看到實現的程式碼非常的簡潔,按照Joshua Bloch大神的原話來說:

While this approach has yet to be widely adopted,a single-element enum type is the best way to implement a singleton.

(2)序列化與反序列化的問題

在上述的幾種單例模式實現中,在一個情況下它們會出現重新建立物件的情況,那就是

反序列化

透過序列化可以將一個單例的例項物件寫到磁碟,然後再讀回來,從而有效地獲得一個例項。

即使建構函式是私有的,反序列化時依然可以透過特殊的途徑去建立類的一個新的例項

,相當於呼叫該類的建構函式。反序列化操作提供一個很特別的鉤子函式,類中具有一個私有的、被例項化的方法readResolve(),這個方法可以讓開發人員控制物件的反序列化。例如,上述幾個例項中如果要杜絕單例物件在被反序列化時重新生成物件,那麼必須加入如下方法:

private

Object

readResolve

()

throws

ObjectStreamException

{

return

INSTANCE;

}

也就是在readResolve方法中將例項物件返回,而不是預設的重新生成一個新的物件。

(3)Java反射攻擊

下面我們基於內部類實現的單例模式的方式,來演示一下透過JAVA的反射機制來“攻擊”單例模式:

public

class

TestMain

{

public

static

void

main

String[] args

) throws NoSuchMethodException, SecurityException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException

{

Class<?> classType = Singleton。class;

Constructor<?> c = classType。getDeclaredConstructor(

null

);

c。setAccessible(

true

);

Singleton singleton1 = (Singleton) c。newInstance();

Singleton singleton2 = Singleton。getSingleton();

System。

out

。println(singleton1 == singleton2);

}

}

執行結果:false,可以看到,透過反射獲取建構函式,然後呼叫setAccessible(true)就可以呼叫私有的建構函式,所有singleton1和singleton2是兩個不同的物件。如果要抵禦這種攻擊,可以修改構造器,讓它在被要求建立第二個例項的時候丟擲異常。

修改原有程式碼為:

public

class

Singleton

{

public

static

Singleton

getSingleton

()

{

return

SingletonHolder。instance;

}

private

static

class

SingletonHolder

{

private

static

Singleton instance =

new

Singleton();

}

private

static

boolean

flag =

false

private

Singleton

()

{

synchronized

(Singleton。class) {

if

(flag ==

false

) {

flag = !flag;

}

else

{

throw

new

RuntimeException(

“單例模式被破壞!”

);

}

}

}

}

再次執行上面的測試程式碼:得到的結果為:

Exception

in

thread

main

java

。lang

。RuntimeException

: 單例模式被破壞!

at

com

。danli

。Singleton

。<

init

>(

Singleton

。java

:29)

at

com

。danli

。Singleton

。getSingleton

Singleton

。java

:12)

at

com

。danli

。TestMain

。main

TestMain

。java

:23)

可以看到,成功的阻止了單例模式被破壞。

但是我們如果直接基於列舉方式實現的單例模式進行同樣的程式碼測試,會直接得到結果:

Exception

in

thread

main

java

。lang

。NoSuchMethodException

com

。danli

。Singleton

。<

init

>()

at

java

。lang

。Class

。getConstructor0

Class

。java

:2730)

at

java

。lang

。Class

。getDeclaredConstructor

Class

。java

:2004)

at

com

。danli

。TestMain

。main

TestMain

。java

:20)

可以看到,列舉方式實現的單例自己是可以避免反射攻擊的

(4)列舉方式的優點

餓漢式、懶漢式、雙重校驗鎖(DCL)還是靜態內部類都存在的缺點:

都需要額外的工作(Serializable、transient、readResolve())來實現序列化,否則每次反序列化一個序列化的物件例項時都會建立一個新的例項。

可能會有人使用反射強行呼叫我們的私有構造器(如果要避免這種情況,可以修改構造器,讓它在建立第二個例項的時候拋異常)。

列舉類很好的解決了這兩個問題,使用列舉除了執行緒安全和防止反射強行呼叫構造器之外,還提供了自動序列化機制,防止反序列化的時候建立新的物件。因此,

《EffectiveJava》Item3中推薦儘可能地使用列舉來實現單例。

但是在Android中卻不推薦這種用法,在Android官網Manage Your App‘s Memory中有這樣一段話:

Enums often require more than twice as much memory as static constants。 You should strictly avoid using enums on Android。

意思就是列舉類這種寫法雖然簡單方便,但是記憶體佔用上是靜態變數的兩倍以上,所以儘可能的避免這種寫法。

不過網上有的建議是

如果程式不是大量採用列舉,那麼這種效能的體現是很小的,基本不會受到影響,不用特別在意

。如果程式出現了效能問題,理論上這個地方就是一個性能最佳化點。

10。單例模式的擴充套件

(1)定義

上文的幾種實現方式裡,一個類都只產生一個物件。萬一有天產品提的需求中,需要一個類只產能產生兩三個物件呢?該怎麼實現?

單例模式各版本的原理與實踐

這種需要產生固定數量物件的模式就叫做

多例模式

,實際上就是單例模式的自然推廣,作為物件的建立模式,多例模式有以下的特點:

多例類可有多個例項;

多例類必須自己建立,管理自己的例項,並向外界提供自己的例項。

(2)應用例項

喜歡打麻將的同學(捂臉)都知道,每一桌麻將牌局都需要兩個骰子,因此骰子就應該是多例類,這裡就以這個場景為例來說明多例模式的應用。

實現程式碼為:

public

class

Die

{

private

static

Die die1 =

new

Die();

private

static

Die die2 =

new

Die();

private

Die

{

}

public

static

Die

getInstance

int

whichOne

{

if

(whichOne ==

1

) {

return

die1;

}

else

{

return

die2;

}

}

public

synchronized

int

dice

{

Random rand =

new

Random(System。currentTimeMillis());

int

value

= rand。nextInt(

6

);

value

+=

1

return

value

}

}

在多例類Die中,使用了餓漢式方式建立了兩個Die的例項,根據靜態工廠方法的引數,工廠方法返還兩個例項中的一個,Die物件呼叫die()方法代表擲骰子,這個方法會返還一個1——6之間的隨機數,相當於骰子的點數。

(3)實踐原則

一個多例類可以使用靜態變數儲存所有的例項,特別是例項數目不多的時候,可以使用一個個的靜態變數儲存一個個的例項。當數目較多的時候,就需要使用Map等集合儲存這些例項

使用這種模式可以讓我們在設計時決定在記憶體中有多少個例項,方便系統進行擴充套件,修正單例可能存在的效能問題,提高系統的相應速度。例如讀取檔案,我們可以在系統啟動時完成初始化工作,在記憶體中啟動固定數量的reader例項,然後在需要讀取檔案時就可以快速響應。

11。小結

最後總結一下,不管哪種方案,時刻牢記單例模式的三大要點:

執行緒安全

延遲載入

序列化與反序列化安全

本文詳細的分析了懶漢式,餓漢式,雙重檢查鎖定,靜態內部類,列舉五種方式的具體實現原理和優缺點,並簡要介紹了單例模式的擴充套件——多例模式。希望大家看完之後能對單例模式有進一步的瞭解,並在日常工作中結合具體需求選擇適合的單例模式實現。