為什麼我牆裂建議大家使用列舉來實現單例。

HollisChuang發表於2019-03-01

關於單例模式,我的部落格中有很多文章介紹過。作為23種設計模式中最為常用的設計模式,單例模式並沒有想象的那麼簡單。因為在設計單例的時候要考慮很多問題,比如執行緒安全問題、序列化對單例的破壞等。

單例相關文章一覽:

設計模式(二)——單例模式

設計模式(三)——JDK中的那些單例

單例模式的七種寫法

單例與序列化的那些事兒

不使用synchronized和lock,如何實現一個執行緒安全的單例?

不使用synchronized和lock,如何實現一個執行緒安全的單例?(二)

如果你對單例不是很瞭解,或者對於單例的執行緒安全問題以及序列化會破壞單例等問題不是很清楚,可以先閱讀以上文章。上面六篇文章看完之後,相信你一定會對單例模式有更多,更深入的理解。

我們知道,單例模式,一般有七種寫法,那麼這七種寫法中,最好的是哪一種呢?為什麼呢?本文就來抽絲剝繭一下。

哪種寫單例的方式最好

在StakcOverflow中,有一個關於What is an efficient way to implement a singleton pattern in Java?的討論:

單例

如上圖,得票率最高的回答是:使用列舉。

回答者引用了Joshua Bloch大神在《Effective Java》中明確表達過的觀點:

使用列舉實現單例的方法雖然還沒有廣泛採用,但是單元素的列舉型別已經成為實現Singleton的最佳方法。

如果你真的深入理解了單例的用法以及一些可能存在的坑的話,那麼你也許也能得到相同的結論,那就是:使用列舉實現單例是一種很好的方法。

列舉單例寫法簡單

如果你看過《單例模式的七種寫法》中的實現單例的所有方式的程式碼,那就會發現,各種方式實現單例的程式碼都比較複雜。主要原因是在考慮執行緒安全問題。

我們簡單對比下“雙重校驗鎖”方式和列舉方式實現單例的程式碼。

“雙重校驗鎖”實現單例:

public class Singleton {  
    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;  
    }  
}  
複製程式碼

列舉實現單例:

public enum Singleton {  
    INSTANCE;  
    public void whateverMethod() {  
    }  
}  
複製程式碼

相比之下,你就會發現,列舉實現單例的程式碼會精簡很多。

上面的雙重鎖校驗的程式碼之所以很臃腫,是因為大部分程式碼都是在保證執行緒安全。為了在保證執行緒安全和鎖粒度之間做權衡,程式碼難免會寫的複雜些。但是,這段程式碼還是有問題的,因為他無法解決反序列化會破壞單例的問題。

列舉可解決執行緒安全問題

上面提到過。使用非列舉的方式實現單例,都要自己來保證執行緒安全,所以,這就導致其他方法必然是比較臃腫的。那麼,為什麼使用列舉就不需要解決執行緒安全問題呢?

其實,並不是使用列舉就不需要保證執行緒安全,只不過執行緒安全的保證不需要我們關心而已。也就是說,其實在“底層”還是做了執行緒安全方面的保證的。

那麼,“底層”到底指的是什麼?

這就要說到關於列舉的實現了。這部分內容可以參考我的另外一篇博文深度分析Java的列舉型別—-列舉的執行緒安全性及序列化問題,這裡我簡單說明一下:

定義列舉時使用enum和class一樣,是Java中的一個關鍵字。就像class對應用一個Class類一樣,enum也對應有一個Enum類。

通過將定義好的列舉反編譯,我們就能發現,其實列舉在經過javac的編譯之後,會被轉換成形如public final class T extends Enum的定義。

而且,列舉中的各個列舉項同事通過static來定義的。如:

public enum T {
    SPRING,SUMMER,AUTUMN,WINTER;
}
複製程式碼

反編譯後程式碼為:

public final class T extends Enum
{
    //省略部分內容
    public static final T SPRING;
    public static final T SUMMER;
    public static final T AUTUMN;
    public static final T WINTER;
    private static final T ENUM$VALUES[];
    static
    {
        SPRING = new T("SPRING", 0);
        SUMMER = new T("SUMMER", 1);
        AUTUMN = new T("AUTUMN", 2);
        WINTER = new T("WINTER", 3);
        ENUM$VALUES = (new T[] {
            SPRING, SUMMER, AUTUMN, WINTER
        });
    }
}
複製程式碼

瞭解JVM的類載入機制的朋友應該對這部分比較清楚。static型別的屬性會在類被載入之後被初始化,我們在深度分析Java的ClassLoader機制(原始碼級別)Java類的載入、連結和初始化兩個文章中分別介紹過,當一個Java類第一次被真正使用到的時候靜態資源被初始化、Java類的載入和初始化過程都是執行緒安全的(因為虛擬機器在載入列舉的類的時候,會使用ClassLoader的loadClass方法,而這個方法使用同步程式碼塊保證了執行緒安全)。所以,建立一個enum型別是執行緒安全的。

也就是說,我們定義的一個列舉,在第一次被真正用到的時候,會被虛擬機器載入並初始化,而這個初始化過程是執行緒安全的。而我們知道,解決單例的併發問題,主要解決的就是初始化過程中的執行緒安全問題。

所以,由於列舉的以上特性,列舉實現的單例是天生執行緒安全的。

列舉可解決反序列化會破壞單例的問題

前面我們提到過,就是使用雙重校驗鎖實現的單例其實是存在一定問題的,就是這種單例有可能被序列化鎖破壞,關於這種破壞及解決辦法,參看單例與序列化的那些事兒,這裡不做更加詳細的說明了。

那麼,對於序列化這件事情,為什麼列舉又有先天的優勢了呢?答案可以在Java Object Serialization Specification 中找到答案。其中專門對列舉的序列化做了如下規定:

serialization

大概意思就是:在序列化的時候Java僅僅是將列舉物件的name屬性輸出到結果中,反序列化的時候則是通過java.lang.EnumvalueOf方法來根據名字查詢列舉物件。同時,編譯器是不允許任何對這種序列化機制的定製的,因此禁用了writeObjectreadObjectreadObjectNoDatawriteReplacereadResolve等方法。

普通的Java類的反序列化過程中,會通過反射呼叫類的預設建構函式來初始化物件。所以,即使單例中建構函式是私有的,也會被反射給破壞掉。由於反序列化後的物件是重新new出來的,所以這就破壞了單例。

但是,列舉的反序列化並不是通過反射實現的。所以,也就不會發生由於反序列化導致的單例破壞問題。這部分內容在深度分析Java的列舉型別—-列舉的執行緒安全性及序列化問題中也有更加詳細的介紹,還展示了部分程式碼,感興趣的朋友可以前往閱讀。

總結

在所有的單例實現方式中,列舉是一種在程式碼寫法上最簡單的方式,之所以程式碼十分簡潔,是因為Java給我們提供了enum關鍵字,我們便可以很方便的宣告一個列舉型別,而不需要關心其初始化過程中的執行緒安全問題,因為列舉類在被虛擬機器載入的時候會保證執行緒安全的被初始化。

除此之外,在序列化方面,Java中有明確規定,列舉的序列化和反序列化是有特殊定製的。這就可以避免反序列化過程中由於反射而導致的單例被破壞問題。

為什麼我牆裂建議大家使用列舉來實現單例。

相關文章