一個單例模式的晉級過程(餓漢-懶漢-DCL-IoDH-枚舉)
文章目錄
什麼是單例?
單例模式(Singleton Pattern)是 Java 中最簡單的設計模式之一。這種類型的設計模式屬於創建型模式,它提供了一種創建對象的最佳方式。
這種模式涉及到一個單一的類,該類負責創建自己的對象,同時確保只有單個對象被創建。這個類提供了一種訪問其唯一的對象的方式,可以直接訪問,不需要實例化該類的對象。
規則:
- 單例類只能有一個實例。
- 單例類必須自己創建自己的唯一實例。
- 單例類必須給所有其他對象提供這一實例。
簡單來說就是一個特殊的類只能創建一次。
單例有哪些運用場景?
Windows的任務管理器,回收站,網站的計數器,線程池,數據庫連接池;
一下這些環境都可以考慮使用單例
1.需要生成唯一序列的環境
2.需要頻繁實例化然後銷燬的對象
3.創建對象時耗時過多或者耗資源過多,但又經常用到的對象
4.方便資源相互通信的環境
實現
1.餓漢式
**是否 Lazy 初始化:**否
**是否多線程安全:**是
**實現難度:**易
**描述:**這是最簡單的實現方式。
public class Singleton {
//內部調用構造函數創建的一個對象
private static Singleton instance = new Singleton();
//讓構造函數爲 private,這樣該類就不會被實例化
private Singleton (){}
//提供統一的外部訪問方法,獲取唯一可用的對象
public static Singleton getInstance() {
return instance;
}
}
測試
public class Main {
public static void main(String[] args) {
//由於使用使用private構造函數私有化 所以使用new關鍵字創建對象會報錯。
//Singleton instance = new Singleton();
//多次獲取的是同一個對象
Singleton instance1 = Singleton.getInstance();
Singleton instance2 = Singleton.getInstance();
}
}
優化-final
可以看到instance對象創建後是不會再被更改的,所以可以使用final關鍵字修飾一下。
private static final Singleton instance = new Singleton();
2.懶漢式
**是否 Lazy 初始化:**是
**是否多線程安全:**否
**實現難度:**易
**描述:**這種方式是最基本的實現方式,這種實現最大的問題就是不支持多線程。因爲沒有加鎖 synchronized,所以嚴格意義上它並不算單例模式。
這種方式 lazy loading 很明顯,不要求線程安全,在多線程不能正常工作。
public class Singleton {
//先定義一個空印用,等待後續賦值
private static Singleton instance;
//讓構造函數爲 private,這樣該類就不會被實例化
private Singleton (){}
//獲取實例時先判斷是否爲空,是否需要創建一個對象。
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
優化-加鎖同步
由於getInstance方法在多線程情況瞎可能會存在線程安全問題,所以可以把getInstance方法加鎖,來保證線程安全
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
**是否多線程安全:**是
由於對getInstance()方法做了同步處理,synchronized將導致性能開銷。如果getInstance()方
法被多個線程頻繁的調用,將會導致程序執行性能的下降。反之,如果getInstance()方法不會被
多個線程頻繁的調用,那麼這個延遲初始化方案將能提供令人滿意的性能。
在早期的JVM中,synchronized(甚至是無競爭的synchronized)存在巨大的性能開銷。因此,
人們想出了一個“聰明”的技巧:雙重檢查鎖定(Double-Checked Locking)。人們想通過雙重檢查
鎖定來降低同步的開銷。下面來介紹雙重檢查鎖定來實現延遲初始化。
3.DCL雙檢鎖/雙重校驗鎖
即double-checked locking
**JDK 版本:**JDK1.5 起
**是否 Lazy 初始化:**是
**是否多線程安全:**是
**實現難度:**較複雜
**描述:**這種方式採用雙鎖機制,安全且在多線程情況下能保持高性能。
getInstance() 的性能對應用程序很關鍵。
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;
}
}
重排序問題
如果第一次檢查instance不爲null,那麼就不需要執行下面的加鎖和初始化操作。因此,可以大幅降低synchronized帶來的性能開銷。多個線程試圖在同一時間創建對象時,會通過加鎖來保證只有一個線程能創建對象。在對象創建好之後,執行getInstance()方法將不需要獲取鎖,直接返回已創建好的對象。
雙重檢查鎖定看起來似乎很完美,但這是一個錯誤的優化!在線程執行到第一次檢測時,代碼讀取到instance不爲null時,instance引用的對象有可能還沒有完成初始化.
注意上述代碼種表注了一個問題。new Singleton();在JVM中可以分解爲一下三個步驟
memory = allocate(); // 1:分配對象的內存空間
ctorInstance(memory); // 2:初始化對象
instance = memory; // 3:設置instance指向剛分配的內存地址
上面3行僞代碼中的2和3之間,可能會被重排序(在一些JIT編譯器上,這種重排序是真實發生的)。2和3之間重排序之後的執行時序如下。
memory = allocate(); // 1:分配對象的內存空間
instance = memory; // 3:設置instance指向剛分配的內存地址
// 注意,此時對象還沒有被初始化!
ctorInstance(memory); // 2:初始化對象
也就是說,在多線程環境下,可能會有一下的執行順序
多線程執行時序表
線程A | 線程B |
---|---|
A1:分配對象的內存空間 | |
A3:設置instance指向內存空間 | |
B1:第一次檢測非空狀態 | |
B2:由於instance不爲空,線程B開始印用instance的對象 | |
A2:初始化對象 | |
A4:訪問instance對象 |
上面錯誤雙重檢查鎖定的示例代碼中,如果線程A 獲取到鎖進入創建對象實例,這個時候發生了指令重排序。當線程A執行到 A3 時刻,線程 B 剛好進入,由於此時對象已經不爲 Null,所以線程 B 可以自由訪問該對象。然後該對象還未初始化,所以線程 B 訪問時將會發生異常。
此問題的主要原因就是發生了指令重排序;
volatile 作用
正確的雙重檢查鎖定模式需要使用 volatile
。volatile
主要包含兩個功能。
- 保證可見性。使用
volatile
定義的變量,將會保證對所有線程的可見性。 - 禁止指令重排序優化。
由於 volatile
禁止對象創建時指令之間重排序,所以其他線程不會訪問到一個未初始化的對象,從而保證安全性。
注意,
volatile
禁止指令重排序在 JDK 5 之後才被修復
也就是將Singleton聲明爲volatile 類型即可解決問題
優化-基於volatile 的雙重檢查鎖
public class Singleton {
private static volatile Singleton singleton; //加上volatile
private Singleton (){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
4.IODH按需初始化持有者
即Initialization On Demand Holder idiom
**是否 Lazy 初始化:**是
**是否多線程安全:**是
**實現難度:**一般
**描述:**這種方式能達到雙檢鎖方式一樣的功效,但實現更簡單。對靜態域使用延遲初始化,應使用這種方式而不是雙檢鎖方式。這種方式只適用於靜態域的情況,雙檢鎖方式可在實例域需要延遲初始化時使用。
這種方式同樣利用了 classloader 機制來保證初始化 instance 時只有一個線程,它跟餓漢式不同的是:餓漢式只要 Singleton 類被裝載了,那麼 instance 就會被實例化(沒有達到 lazy loading 效果),而這種方式是 Singleton 類被裝載了,instance 不一定被初始化。因爲 SingletonHolder 類沒有被主動使用,只有通過顯式調用 getInstance 方法時,纔會顯式裝載 SingletonHolder 類,從而實例化 instance。想象一下,如果實例化 instance 很消耗資源,所以想讓它延遲加載,另外一方面,又不希望在 Singleton 類加載時就實例化,因爲不能確保 Singleton 類還可能在其他的地方被主動使用從而被加載,那麼這個時候實例化 instance 顯然是不合適的。這個時候,這種方式相比餓漢式就顯得很合理。
public class Singleton {
//定義一個私有內部類
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
//私有構造方法
private Singleton (){}
//這裏將導致InstanceHolder類被初始化
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
兩個線程併發執行getInstance()方法,下面是執行的示意圖,圖來自於《Java併發編程的藝術》
這個方案允許new Singleton(); 創建時的重排序,但不允許非構造線程(這裏指線程B)“看到”這個重排序。
反射問題
好了現在已經有了很多種的實現方式了。也解決了線程安全和按需加載的問題。下面說一個讓人絕望的問題。
如果客戶端使用反射機制,藉助AccessibleObject.setAccessible(true)方法,那麼就可以用反射的方式調用private修飾的私有方法。😢 辛辛苦苦大半年,一個反射回到解放前。
public static void main(String[] args) throws Exception {
Class<?> classType = Singleton.class;
Constructor<?> c = classType.getDeclaredConstructor(null);
c.setAccessible(true);
Singleton s1 = (Singleton)c.newInstance();
Singleton s2 = Singleton.getInstance();
System.out.println(s1==s2);
}
繼續優化 😭
參考《effective java3》第3條 用私有構造器或者枚舉類型強化 Singleton屬性
私有構造函數異常處理
public class Singleton {
pprivate static volatile boolean flag = false;
//私有構造方法
private Singleton (){
synchronized(Singleton.class){
if(flag == false) {
flag = !flag;
}else {
throw new RuntimeException("狗東西你想幹嗎?");
}
}
}
//定義一個私有內部類
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
//這裏將導致InstanceHolder類被初始化
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
5.枚舉實現單例
**JDK 版本:**JDK1.5 起
**是否 Lazy 初始化:**否
**是否多線程安全:**是
**實現難度:**易
**描述:**這種實現方式還沒有被廣泛採用,但這是實現單例模式的最佳方法。它更簡潔,自動支持序列化機制,絕對防止多次實例化。
這種方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不僅能避免多線程同步問題,而且還自動支持序列化機制,防止反序列化重新創建新的對象,絕對防止多次實例化。不過,由於 JDK1.5 之後才加入 enum 特性,用這種方式寫不免讓人感覺生疏,在實際工作中,也很少用。
不能通過 reflection attack 來調用私有構造方法。
public enum Singleton{
INSTANCE;
}
使用推薦
一般情況下,不建議使用懶漢式,建議使用線程安全的餓漢式。只有在要明確實現 lazy loading 效果時,纔會使用IODH的方式。如果涉及到反序列化創建對象時,可以嘗試使用枚舉方式。如果有其他特殊的需求,可以考慮使用雙檢鎖方式。
參考
《java併發編程的藝術》
《effective java3》