單例模式詳解
概念
單例模式(
Singleton Pattern
)是
Java
中最簡單的設計模式之一。這種類型的設計模式屬于建立型模式。在
GOF
書中給出的定義為:保證一個類僅有一個執行個體,并提供一個通路它的全局通路點。
單例模式一般展現在類聲明中,單例的類負責建立自己的對象,同時確定隻有單個對象被建立。這個類提供了一種通路其唯一的對象的方式,可以直接通路,不需要執行個體化該類的對象。
注意:
1、單例類隻能有一個執行個體。
2、單例類必須自己建立自己的唯一執行個體。
3、單例類必須給所有其他對象提供這一執行個體。
優缺點
優點:
1、在記憶體裡隻有一個執行個體,減少了記憶體的開銷,尤其是頻繁的建立和銷毀執行個體(比如管理學院首頁頁面緩存)。
2、避免對資源的多重占用(比如寫檔案操作)。
缺點:
沒有接口,不能繼承,與單一職責原則沖突,一個類應該隻關心内部邏輯,而不關心外面怎麼樣來執行個體化。
單例模式的六種寫法
單例模式的代碼要素:
1、将構造函數私有化
2、在類的内部建立執行個體
3、提供擷取唯一執行個體的方法
【1】餓漢式
//code 1
public class Singleton {
//在類内部執行個體化一個執行個體
private static Singleton instance = new Singleton();
//私有的構造函數,外部無法通路
private Singleton() {
}
//對外提供擷取執行個體的靜态方法
public static Singleton getInstance() {
return instance;
}
}
所謂餓漢。這是個比較形象的比喻。對于一個餓漢來說,他希望他想要用到這個執行個體的時候就能夠立即拿到,而不需要任何等待時間。是以,通過
static
的靜态初始化方式,在該類第一次被加載的時候,就有一個
SimpleSingleton
的執行個體被建立出來了。這樣就保證在第一次想要使用該對象時,他已經被初始化好了。
同時,由于該執行個體在類被加載的時候就建立出來了,是以也避免了線程安全問題。(原因見:在深度分析Java的ClassLoader機制(源碼級别)、Java類的加載、連結和初始化)
還有一種餓漢模式的變種
//code 3
public class Singleton2 {
//在類内部定義
private static Singleton2 instance;
static {
//執行個體化該執行個體
instance = new Singleton2();
}
//私有的構造函數,外部無法通路
private Singleton2() {
}
//對外提供擷取執行個體的靜态方法
public static Singleton2 getInstance() {
return instance;
}
}
code 3
和
code 1
其實是一樣的,都是在類被加載的時候執行個體化一個對象。
餓漢式單例,在類被加載的時候對象就會執行個體化。這也許會造成不必要的消耗,因為有可能這個執行個體根本就不會被用到。而且,如果這個類被多次加載的話也會造成多次執行個體化。其實解決這個問題的方式有很多,下面提供兩種解決方式,第一種是使用靜态内部類的形式。第二種是使用懶漢式。
【2】懶漢式,線程不安全
//code 5
public class Singleton {
//定義執行個體
private static Singleton instance;
//私有構造方法
private Singleton(){}
//對外提供擷取執行個體的靜态方法
public static Singleton getInstance() {
//在對象被使用的時候才執行個體化
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
這段代碼簡單明了,而且使用了懶加載模式,但是卻存在緻命的問題。當有多個線程并行調用
getInstance()
的時候,就會建立多個執行個體。也就是說在多線程下不能正常工作。
【3】線程安全的懶漢式
//code 6
public class SynchronizedSingleton {
//定義執行個體
private static SynchronizedSingleton instance;
//私有構造方法
private SynchronizedSingleton(){}
//對外提供擷取執行個體的靜态方法,對該方法加鎖
public static synchronized SynchronizedSingleton getInstance() {
//在對象被使用的時候才執行個體化
if (instance == null) {
instance = new SynchronizedSingleton();
}
return instance;
}
}
針對線程不安全的懶漢式的單例,其實解決方式很簡單,就是給建立對象的步驟加鎖。
這種寫法能夠在多線程中很好的工作,而且看起來它也具備很好的延遲加載,但是,遺憾的是,他效率很低,因為
99%
情況下不需要同步。(因為上面的
synchronized
的加鎖範圍是整個方法,該方法的所有操作都是同步進行的,但是對于非第一次建立對象的情況,也就是沒有進入
if
語句中的情況,根本不需要同步操作,可以直接傳回
instance
。)這就引出了雙重檢驗鎖。
【4】雙重檢測機制(DCL)懶漢式
//code 7
public class Singleton {
private static Singleton singleton;
private Singleton() {
}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
針對上面
code 6
存在的問題,相信對并發程式設計了解的同學都知道如何解決。其實上面的代碼存在的問題主要是鎖的範圍太大了。隻要縮小鎖的範圍就可以了。那麼如何縮小鎖的範圍呢?相比于同步方法,同步代碼塊的加鎖範圍更小。
code 6
可以改造成上面的樣子。
雙重檢驗鎖模式(
Double Checked Locking Pattern
),是一種使用同步塊加鎖的方法。稱其為雙重檢查鎖,因為會有兩次檢查
instance == null
,一次是在同步塊外,一次是在同步塊内。為什麼在同步塊内還要再檢驗一次?因為可能會有多個線程一起進入同步塊外的
if
,如果在同步塊内不進行二次檢驗的話就會生成多個執行個體了。
但是,事情這的有這麼容易嗎?上面的代碼看上去好像是沒有任何問題。實作了惰性初始化,解決了同步問題,還減小了鎖的範圍,提高了效率。但是,該代碼還存在隐患。隐患的原因主要和Java記憶體模型(JMM)有關。
主要在于
instance = new Singleton()
這句,這并非是一個原子操作,事實上在
JVM
中這句話大概做了下面 3 件事情。
給 instance 配置設定記憶體
調用 Singleton 的構造函數來初始化成員變量
将instance對象指向配置設定的記憶體空間(執行完這步 instance 就為非 null 了)
但是在 JVM 的即時編譯器中存在指令重排序的優化。也就是說上面的第二步和第三步的順序是不能保證的,最終的執行順序可能是
1-2-3
也可能是
1-3-2
。如果是後者,則在
3
執行完畢、
2
未執行之前,被線程二搶占了,這時
instance
已經是非
null
了(但卻沒有初始化),是以線程二會直接傳回
instance
,然後使用,然後順理成章地報錯。
在
J2SE 1.4
或更早的版本中使用雙重檢查鎖有潛在的危險,有時會正常工作(區分正确實作和有小問題的實作是很困難的。取決于編譯器,線程的排程和其他并發系統活動,不正确的實作雙重檢查鎖導緻的異常結果可能會間歇性出現。重制異常是十分困難的。) 在
J2SE 5.0
中,這一問題被修正了。
volatile
關鍵字保證多個線程可以正确處理單件執行個體。
是以,針對code 7 ,可以有code 8 和code 9兩種替代方案:
//code 8
public class VolatileSingleton {
private static volatile VolatileSingleton singleton;
private VolatileSingleton() {
}
public static VolatileSingleton getSingleton() {
if (singleton == null) {
synchronized (VolatileSingleton.class) {
if (singleton == null) {
singleton = new VolatileSingleton();
}
}
}
return singleton;
}
}
有些人認為使用
volatile
的原因是可見性,也就是可以保證線程在本地不會存有
instance
的副本,每次都是去主記憶體中讀取。但其實是不對的。使用
volatile
的主要原因是其另一個特性:禁止指令重排序優化。也就是說,在
volatile
變量的指派操作後面會有一個記憶體屏障(生成的彙編代碼上),讀操作不會被重排序到記憶體屏障之前。比如上面的例子,取操作必須在執行完
1-2-3
之後或者
1-3-2
之後,不存在執行到
1-3
然後取到值的情況。從「先行發生原則」的角度了解的話,就是對于一個
volatile
變量的寫操作都先行發生于後面對這個變量的讀操作(這裡的“後面”是時間上的先後順序)。
但是特别注意在
Java 5
以前的版本使用了
volatile
的雙檢鎖還是有問題的。其原因是
Java 5
以前的
JMM
(
Java
記憶體模型)是存在缺陷的,即使将變量聲明成
volatile
也不能完全避免重排序,主要是
volatile
變量前後的代碼仍然存在重排序問題。這個
volatile
屏蔽重排序的問題在
Java 5
中才得以修複,是以在這之後才可以放心使用
volatile
。
上面這種雙重校驗鎖的方式用的比較廣泛,他解決了前面提到的所有問題。但是,即使是這種看上去完美無缺的方式也可能存在問題,那就是遇到序列化的時候。詳細内容後文介紹。
使用
final
//code 9
class FinalWrapper<T> {
public final T value;
public FinalWrapper(T value) {
this.value = value;
}
}
public class FinalSingleton {
private FinalWrapper<FinalSingleton> helperWrapper = null;
public FinalSingleton getHelper() {
FinalWrapper<FinalSingleton> wrapper = helperWrapper;
if (wrapper == null) {
synchronized (this) {
if (helperWrapper == null) {
helperWrapper = new FinalWrapper<FinalSingleton>(new FinalSingleton());
}
wrapper = helperWrapper;
}
}
return wrapper.value;
}
}
【5】靜态内部類
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton (){}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
這種方式能達到雙檢鎖方式一樣的功效,但實作更簡單。對靜态域使用延遲初始化,應使用這種方式而不是雙檢鎖方式。這種方式隻适用于靜态域的情況,雙檢鎖方式可在執行個體域需要延遲初始化時使用。
這種方式同樣利用了
classloder
機制來保證初始化
instance
時隻有一個線程,它跟第 1 種方式不同的是:第 1 種方式隻要
Singleton
類被裝載了,那麼
instance
就會被執行個體化(沒有達到
lazy loading
效果),而這種方式是
Singleton
類被裝載了,
instance
不一定被初始化。因為
SingletonHolder
類沒有被主動使用,隻有通過顯式調用
getInstance
方法時,才會顯式裝載
SingletonHolder
類,進而執行個體化
instance
。想象一下,如果執行個體化
instance
很消耗資源,是以想讓它延遲加載,另外一方面,又不希望在
Singleton
類加載時就執行個體化,因為不能確定 Singleton 類還可能在其他的地方被主動使用進而被加載,那麼這個時候執行個體化
instance
顯然是不合适的。這個時候,這種方式相比第 1 種方式就顯得很合理。
這種寫法仍然使用JVM本身機制保證了線程安全問題;由于
SingletonHolder
是私有的,除了
getInstance()
之外沒有辦法通路它,是以它是懶加載的;同時讀取執行個體的時候不會進行同步,沒有性能缺陷;也不依賴
JDK
版本。
【6】枚舉式
// code 10
public enum Singleton {
INSTANCE;
Singleton() {
}
}
Java 1.5
之前,實作單例一般隻有以上幾種辦法,在
Java 1.5
之後,還有另外一種實作單例的方式,那就是使用枚舉。可以通過
Singleton.INSTANCE
來通路執行個體。
這種方式是
Effective Java
作者
Josh Bloch
提倡的方式(
Effective Java
第3條),它不僅能避免多線程同步問題,而且還能防止反序列化重新建立新的對象(下面會介紹),可謂是很堅強的壁壘啊,在深度分析Java的枚舉類型—-枚舉的線程安全性及序列化問題中有詳細介紹枚舉的線程安全問題和序列化問題。
那這種有啥好處?枚舉的方式實作:
簡潔
無嘗提供了序列化機制
絕對防止多次執行個體化,即使是在面對複雜的序列化或者反射攻擊的時候(安全)!
這種也較為推薦使用!
具體推薦原因如下:
深度分析Java的枚舉類型—-枚舉的線程安全性及序列化問題
為什麼我牆裂建議大家使用枚舉來實作單例
注意點
有兩個問題需要注意:
1、如果單例由不同的類裝載器裝入,那便有可能存在多個單例類的執行個體。假定不是遠端存取,例如一些servlet容器對每個servlet使用完全不同的類裝載器,這樣的話如果有兩個servlet通路一個單例類,它們就都會有各自的執行個體。
該問題可以通過如下方式修複:
private static Class getClass(String classname)
throws ClassNotFoundException {
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
if(classLoader == null)
classLoader = Singleton.class.getClassLoader();
return (classLoader.loadClass(classname));
}
}
2、如果Singleton實作了java.io.Serializable接口,那麼這個類的執行個體就可能被序列化和複原。不管怎樣,如果你序列化一個單例類的對象,接下來複原多個那個對象,那你就會有多個單例類的執行個體。序列化問題參考下面的分析。
單例與序列化
在單例與序列化的那些事兒一文中,分析過單例和序列化之前的關系——序列化可以破壞單例。要想防止序列化對單例的破壞,隻要在Singleton類中定義readResolve就可以解決該問題。
//code 11
package com.hollis;
import java.io.Serializable;
/**
* Created by hollis on 16/2/5.
* 使用雙重校驗鎖方式實作單例
*/
public class Singleton implements Serializable{
private volatile static Singleton singleton;
private Singleton (){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
private Object readResolve() {
return singleton;
}
}
總結
一般來說,單例模式有五種寫法:懶漢、餓漢、雙重檢驗鎖、靜态内部類、枚舉。上述所說都是線程安全的實作,文章給出的第2種方法不算正确的寫法。
一般情況下直接使用餓漢式就好了,如果明确要求要懶加載(lazy initialization)會傾向于使用靜态内部類,如果涉及到反序列化建立對象時,可以使用枚舉的方式來實作單例。如果有其他特殊的需求,可以考慮使用雙檢鎖方式。
參考資料:
如何正确地寫出單例模式
單例模式的七種寫法
設計模式(二)——單例模式
單例模式
單例模式你會幾種寫法?
本部落格用于技術學習,所有資源都來源于網絡,部分是轉發,部分是個人總結。歡迎共同學習和轉載,轉載請在醒目位置标明原文。如有侵權,請留言告知,及時撤除。