鑽鑽 “單例” 的牛角尖

秉心說發表於2019-04-19

上篇文章 走進 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 看來有這麼幾步:

  1. 為物件分配記憶體空間
  2. 初始化物件
  3. 將 mInstance 引用指向第 1 步中分配的記憶體地址

在單執行緒內,在不影響執行結果的前提下,可能存在指令重排序。例如下列程式碼:

int a = 1;
int b = 2;
複製程式碼

在 JVM 中你是無法確保這兩行程式碼誰先執行的,因為誰先執行都不影響程式執行結果。同理,建立例項物件的三部中,第 2 步 初始化物件 和 第 3 步 將 mInstance 引用指向物件的記憶體地址 之間也是可能存在重排序的。

  1. 為物件分配記憶體空間
  2. 將 mInstance 引用指向第 1 步中分配的記憶體地址
  3. 初始化物件

這樣的話,就存在這樣一種可能。執行緒 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 引用的物件

A3A2 發生重排序導致執行緒 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 題解,每週一篇閱讀分享,歡迎掃碼關注! 後臺回覆 “群聊” ,加入秉心說讀者群,一個有趣的、有問必答的交流群,最重要的,還有不定期紅包哦!

相關文章