使用列舉來寫出更優雅的單例設計模式
Java 中的單例設計模式,很多時候我們只會注意到執行緒引起的表象性問題,但是沒考慮過對反射機制的限制,此文旨在簡單介紹利用列舉來防止反射的漏洞。
一、最常見的單例
我們先展示一段最常見的懶漢式的單例:
public class Singleton { private Singleton(){} // 私有構造 private static Singleton instance = null; // 私有單例物件 // 靜態工廠 public static Singleton getInstance(){ if (instance == null) { // 雙重檢測機制 synchronized (Singleton.class) { // 同步鎖 if (instance == null) { // 雙重檢測機制 instance = new Singleton(); } } } return instance; } } 複製程式碼
上述單例的寫法採用的 雙重檢查機制 增加了一定的安全性,但是沒有考慮到 JVM 編譯器的指令重排 。
二、杜絕 JVM 的指令重排對單例造成的影響
1、什麼是指令重排
比如 java 中簡單的一句 instance = new Singleton,會被編譯器編譯成如下 JVM 指令:
memory =allocate();//1:分配物件的記憶體空間 ctorInstance(memory);//2:初始化物件 instance =memory;//3:設定instance指向剛分配的記憶體地址 複製程式碼
但是這些指令順序並非一成不變,有可能會經過 JVM 和 CPU 的優化,指令重排成下面的順序:
memory =allocate();//1:分配物件的記憶體空間 instance =memory;//3:設定instance指向剛分配的記憶體地址 ctorInstance(memory);//2:初始化物件 複製程式碼
2、影響
對應到上文的單例模式,會產生如下圖的問題:
-
當執行緒 A 執行完1,3,時,準備走2,即 instance 物件還未完成初始化,但已經不再指向 null 。
-
此時如果執行緒 B 搶佔到CPU資源,執行 if(instance == null)的結果會是 false,
-
從而返回一個沒有初始化完成的instance物件。

3、解決
如何去防止呢,很簡單,可以利用關鍵字 volatile 來修飾 instance 物件,如下圖進行優化:

why?
很簡單,volatile 修飾符在此處的作用就是阻止變數訪問前後的指令重排,從而保證了指令的執行順序。
意思就是,指令的執行順序是嚴格按照上文的 1、2、3 來執行的,從而物件不會出現中間態。
其實,volatile 關鍵字在多執行緒的開發中應用很廣,暫不贅述。
雖然很贊,但是此處仍然 沒有考慮過反射機制帶來的影響 。
三、進階篇,實現完美單例
1、小插曲
實現單例有很多種模式,在此介紹一種使用靜態內部類實現單例模式的方式:
public class Singleton { private static class LazyHolder { private static final Singleton INSTANCE = new Singleton(); } private Singleton (){} public static Singleton getInstance() { return LazyHolder.INSTANCE; } } 複製程式碼
這是一種很巧妙的方式,原由是:
-
從外部無法訪問靜態內部類 LazyHolder,只有當呼叫 Singleton.getInstance() 方法的時候,才能得到單例物件 INSTANCE。
-
INSTANCE 物件初始化的時機並不是在單例類 Singleton 被載入的時候,而是在呼叫 getInstance 方法,使得靜態內部類 LazyHolder 被載入的時候。
-
因此這種實現方式是利用classloader的載入機制來實現懶載入,並保證構建單例的執行緒安全。
2、漏洞展示
很多種單例的寫法都有一個通病,就是無法防止反射機制的漏洞,從而無法保證物件的唯一性,如下舉例:
利用如下的反正程式碼對上文構造的單例進行物件的建立。
public static void main(String[] args) { try { //獲得構造器 Constructor con = Singleton.class.getDeclaredConstructor(); //設定為可訪問 con.setAccessible(true); //構造兩個不同的物件 Singleton singleton1 = (Singleton)con.newInstance(); Singleton singleton2 = (Singleton)con.newInstance(); //驗證是否是不同物件 System.out.println(singleton1); System.out.println(singleton2); System.out.println(singleton1.equals(singleton2)); } catch (Exception e) { e.printStackTrace(); } } 複製程式碼
我們直接看結果:

結果很明顯,這顯然是兩個物件。
3、解決
使用 列舉 來實現單例模式。
實現很簡單,就三行程式碼:
public enum Singleton { INSTANCE; } 複製程式碼
上面所展示的就是一個單例,
why?
其實這就是 enum 的一塊語法糖, JVM 會阻止反射獲取列舉類的私有構造方法 。
仍然使用上文的反射程式碼來進行測試,發現,報錯。嘿嘿,完美解決反射的問題。