上篇文章 走進 JDK 之 Enum 提到過,列舉很適合用來實現單例模式。實際上,在 Effective Java 中也提到過(果然英雄所見略同):
單元素的列舉型別經常成為實現 Singleton 的最佳方法 。
首先什麼是單例?就一條基本原則,單例物件的類只會被初始化一次。在 Java 中,我們可以說在 JVM 中只存在該類的唯一一個物件例項。在 Android 中,我們可以說在程式執行期間,該類有且僅有一個物件例項。說到單例模式的實現,你們肯定信手拈來,什麼懶漢,餓漢,DCL,靜態內部類,門清。在說單例之前,考慮下面幾個問題:
- 你的單例執行緒安全嗎?
- 你的單例反射安全嗎?
- 你的單例序列化安全嗎?
今天,我就來鑽鑽牛角尖,看看你們的單例是否真的 “單例”。
單例的一般實現
餓漢式
public class HungrySingleton {
private static final HungrySingleton mInstance = new HungrySingleton();
private HungrySingleton() {
}
public static HungrySingleton getInstance() {
return mInstance;
}
}
複製程式碼
私有構造器是單例的一般套路,保證不能在外部新建物件。餓漢式在類載入時期就已經初始化例項,由於類載入過程是執行緒安全的,所以餓漢式預設也是執行緒安全的。它的缺點也很明顯,我真正需要單例物件的時機是我呼叫 getInstance()
的時候,而不是類載入時期。如果單例物件是很耗資源的,如資料庫,socket 等等,無疑是不合適的。於是就有了懶漢式。
懶漢式
public class LazySingleton {
private static LazySingleton mInstance;
private LazySingleton() {
}
public static synchronized LazySingleton getInstance() {
if (mInstance == null)
mInstance = new LazySingleton();
return mInstance;
}
}
複製程式碼
例項化的時機挪到了 getInstance()
方法中,做到了 lazy init ,但也失去了類載入時期初始化的執行緒安全保障。因此使用了 synchronized
關鍵字來保障執行緒安全。但這顯然是一個無差別攻擊,管你要不要同步,管你是不是多執行緒,一律給我加鎖。這也帶來了額外的效能消耗。這點問題肯定難不倒程式設計師們,於是,雙重檢查鎖定(DCL, Double Check Lock) 應運而生。
DCL
public class DCLSingleton {
private static DCLSingleton mInstance;
private DCLSingleton() {
}
public static DCLSingleton getInstance() {
if (mInstance == null) { // 1
synchronized (DCLSingleton.class) { // 2
if (mInstance == null) // 3
mInstance = new DCLSingleton(); // 4
}
}
return mInstance;
}
}
複製程式碼
1
處做第一次判斷,如果已經例項化了,直接返回物件,避免無用的同步消耗。2
處僅對例項化過程做同步操作,保證單例。3
處做第二次判斷,只有 mInstance
為空時再初始化。看起來時多麼的完美,保證執行緒安全的同時又兼顧效能。但是 DCL 存在一個致命缺陷,就是重排序導致的多執行緒訪問可能獲得一個未初始化的物件。
首先記住上面標記的 4 行程式碼。其中第 4 行程式碼 mInstance = new DCLSingleton();
在 JVM 看來有這麼幾步:
- 為物件分配記憶體空間
- 初始化物件
- 將 mInstance 引用指向第 1 步中分配的記憶體地址
在單執行緒內,在不影響執行結果的前提下,可能存在指令重排序。例如下列程式碼:
int a = 1;
int b = 2;
複製程式碼
在 JVM 中你是無法確保這兩行程式碼誰先執行的,因為誰先執行都不影響程式執行結果。同理,建立例項物件的三部中,第 2 步 初始化物件 和 第 3 步 將 mInstance 引用指向物件的記憶體地址 之間也是可能存在重排序的。
- 為物件分配記憶體空間
- 將 mInstance 引用指向第 1 步中分配的記憶體地址
- 初始化物件
這樣的話,就存在這樣一種可能。執行緒 A 按上面重排序之後的指令執行,當執行到第 2 行 將 mInstance 引用指向物件的記憶體地址 時,執行緒 B 開始執行了,此時執行緒 A 已為 mInstance
賦值,執行緒 B 進行 DCL 的第一次判斷 if (mInstance == null)
,結果為 false
,直接返回 mInstance
指向的物件,但是由於重排序的緣故,物件其實尚未初始化,這樣就出問題了。還挺繞口的,借用 《Java 併發程式設計藝術》 中的一張表格,會對執行流程更加清晰。
時間 | 執行緒 A | 執行緒 B |
---|---|---|
t1 | A1: 分配物件的記憶體空間 | |
t2 | A3: 設定 mInstance 指向記憶體空間 | |
t3 | B1: 判斷 mInstance 是否為空 | |
t4 | B2: 由於 mInstance 不為空,執行緒 B 將訪問 mInstance 指向的物件 | |
t5 | A2: 初始化物件 | |
t6 | A3: 訪問 mInstance 引用的物件 |
A3
和 A2
發生重排序導致執行緒 B 獲取了一個尚未初始化的物件。
說了半天,該怎麼改?其實很簡單,禁止多執行緒下的重排序就可以了,只需要用 volatile
關鍵字修飾 mInstance
。在 JDK 1.5 中,增強了 volatile 的記憶體語義,對一個volatile 域的寫,happens-before 於任意後續對這個 volatile 域的讀。volatile 會禁止一些處理器重排序,此時 DCL 就做到了真正的執行緒安全。
靜態內部類模式
public class StaticInnerSingleton {
private StaticInnerSingleton(){}
private static class SingletonHolder{
private static final StaticInnerSingleton mInstance=new StaticInnerSingleton();
}
public static StaticInnerSingleton getInstance(){
return SingletonHolder.mInstance;
}
}
複製程式碼
鑑於 DCL 繁瑣的程式碼,程式設計師又發明了靜態內部類模式,它和餓漢式一樣基於類載入時器的執行緒安全,但是又做到了延遲載入。SingletonHolder
是一個靜態內部類,當外部類被載入的時候並不會初始化。當呼叫 getInstance()
方法時,才會被載入。
列舉單例暫且不提,放在最後再說。先對上面的單例模式做個檢測。
真的是單例?
還記得開頭的提問嗎?
- 你的單例執行緒安全嗎?
- 你的單例反射安全嗎?
- 你的單例序列化安全嗎?
上面大篇幅的論述都在說明執行緒安全。下面看看反射安全和序列化安全。
反射安全
直接上程式碼,我用 DCL 來做測試:
public static void main(String[] args) {
DCLSingleton singleton1 = DCLSingleton.getInstance();
DCLSingleton singleton2 = null;
try {
Class<DCLSingleton> clazz = DCLSingleton.class;
Constructor<DCLSingleton> constructor = clazz.getDeclaredConstructor();
constructor.setAccessible(true);
singleton2 = constructor.newInstance();
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(singleton1.hashCode());
System.out.println(singleton2.hashCode());
}
複製程式碼
執行結果:
1627674070
1360875712
複製程式碼
很無情,通過反射破壞了單例。如何保證反射安全呢?只能以暴制暴,當已經存在例項的時候再去呼叫建構函式直接丟擲異常,對建構函式做如下修改:
private DCLSingleton() {
if (mInstance!=null)
throw new RuntimeException("想反射我,沒門!");
}
複製程式碼
上面的測試程式碼會直接丟擲異常。
序列化安全
將你的單例類實現 Serializable
持久化儲存起來,日後再恢復出來,他還是單例嗎?
public static void main(String[] args) {
DCLSingleton singleton1 = DCLSingleton.getInstance();
DCLSingleton singleton2 = null;
try {
ObjectOutput output=new ObjectOutputStream(new FileOutputStream("singleton.ser"));
output.writeObject(singleton1);
output.close();
ObjectInput input=new ObjectInputStream(new FileInputStream("singleton.ser"));
singleton2= (DCLSingleton) input.readObject();
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(singleton1.hashCode());
System.out.println(singleton2.hashCode());
}
複製程式碼
執行結果:
644117698
793589513
複製程式碼
不堪一擊。反序列化時生成了新的例項物件。要修復也很簡單,只需要修改反序列化的邏輯就可以了,即重寫 readResolve()
方法,使其返回統一例項。
protected Object readResolve() {
return getInstance();
}
複製程式碼
脆弱不堪的單例模式經過重重考驗,進化成了完全體,延遲載入,執行緒安全,反射安全,序列化安全。全部程式碼如下:
public class DCLSingleton implements Serializable {
private static DCLSingleton mInstance;
private DCLSingleton() {
if (mInstance!=null)
throw new RuntimeException("想反射我,沒門!");
}
public static DCLSingleton getInstance() {
if (mInstance == null) {
synchronized (DCLSingleton.class) {
if (mInstance == null)
mInstance = new DCLSingleton();
}
}
return mInstance;
}
protected Object readResolve() {
return getInstance();
}
}
複製程式碼
列舉單例
列舉看到 DCL 就開始嘲笑他了,“你瞅瞅你那是啥,寫個單例費那大勁呢?” 於是擼起袖子自己寫了一個列舉單例:
public enum EnumSingleton {
INSTANCE;
}
複製程式碼
DCL 反問,“你這啥玩意,你這就是單例了?我來扒了你的皮看看 !” 於是 DCL 掏出 jad ,扒了 Enum 的衣服,拉出來示眾:
public final class EnumSingleton extends Enum {
public static EnumSingleton[] values() {
return (EnumSingleton[])$VALUES.clone();
}
public static EnumSingleton valueOf(String s) {
return (EnumSingleton)Enum.valueOf(test/singleton/EnumSingleton, s);
}
private EnumSingleton(String s, int i) {
super(s, i);
}
public static final EnumSingleton INSTANCE;
private static final EnumSingleton $VALUES[];
static {
INSTANCE = new EnumSingleton("INSTANCE", 0);
$VALUES = (new EnumSingleton[] {
INSTANCE
});
}
}
複製程式碼
我們依次來檢查列舉單例的執行緒安全,反射安全,序列化安全。
首先列舉單例無疑是執行緒安全的,類似餓漢式,INSTANCE
的初始化放在了 static 靜態程式碼段中,在類載入階段執行。由此可見,列舉單例並不是延時載入的。
對於反射安全,又要掏出上面的檢測程式碼了,根據 EnumSingleton
的構造器,需要稍微做些改動:
public static void main(String[] args) {
EnumSingleton singleton1 = EnumSingleton.INSTANCE;
EnumSingleton singleton2 = null;
try {
Class<EnumSingleton> clazz = EnumSingleton.class;
Constructor<EnumSingleton> constructor = clazz.getDeclaredConstructor(String.class,int.class);
constructor.setAccessible(true);
singleton2 = constructor.newInstance("test",1);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(singleton1.hashCode());
System.out.println(singleton2.hashCode());
}
複製程式碼
結果直接報錯,錯誤日誌如下:
java.lang.IllegalArgumentException: Cannot reflectively create enum objects
at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
at singleton.SingleTest.main(SingleTest.java:16)
複製程式碼
錯誤發生在 Constructor.newInstance()
方法,又要從原始碼中找答案了,在 newInstance()
原始碼中,有這麼一句:
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
throw new IllegalArgumentException("Cannot reflectively create enum objects");
複製程式碼
如果是列舉修飾的,直接丟擲異常。和之前的對抗反射的手段一致,壓根就不給你反射。所以,列舉單例也是天生反射安全的。
最後列舉單例也是序列化安全的,上篇文章中已經說明過,你可以執行測試程式碼試試。
看起來列舉單例的確是個不錯的選擇,程式碼簡單,又能保證絕大多數情況下的單例例項唯一。但是真正在開發中大家好像用的並不多,更多的可能應該是列舉在 Java 1.5 中才新增,大家預設已經習慣了其他的單例實現方式。
程式碼最少的單例?
說到列舉單例程式碼簡單,Kotlin 第一個站出來不服了。我敢說第一,誰敢說第二,給你們獻醜了:
object KotlinSingleton { }
複製程式碼
jad 反編譯一下:
public final class KotlinSingleton {
private KotlinSingleton(){
}
public static final KotlinSingleton INSTANCE;
static {
KotlinSingleton kotlinsingleton = new KotlinSingleton();
INSTANCE = kotlinsingleton;
}
}
複製程式碼
可以看到,Kotlin 的單例其實也是餓漢式的一種,不鑽牛角尖的話,基本可以滿足大部分需求。
吹毛求疵的談了談單例模式,可以看見要完全的保證單例還是有很多坑點的。在開發中並沒有必要鑽牛角尖,例如 Kotlin 預設提供的單例實現就是餓漢式而已,其實已經可以滿足絕大多數的情況了。
由列舉引申出了這麼一篇文章,大家姑且可以當做娛樂看一看,交個朋友。
秉心說,專注 Java/Android 原創知識分享,LeetCode 題解,每週一篇閱讀分享,歡迎掃碼關注! 後臺回覆 “群聊” ,加入秉心說讀者群,一個有趣的、有問必答的交流群,最重要的,還有不定期紅包哦!