你真的會寫單例模式嗎?

文章轉載自「開發者圓桌」一個關於開發者入門、進階、踩坑的微信公衆號設計模式

單例模式多是你們常常接觸和使用的一個設計模式,你可能會這麼寫:緩存

public class Test {安全

    private static Test instance;微信

    private Test() {多線程

    }併發

    public static Test getInstance(){性能

        if(instance==null){//1:A線程執行優化

            instance=new Test();//2:B線程執行ui

        }this

        return instance;

    }

}

 

上面代碼你們應該都知道,所謂的線程不安全的懶漢單例寫法。在Test類中,假設A線程執行代碼1的同時,B線程執行代碼2,此時,線程A可能看到instance引用的對象尚未初始化,致使被new屢次。

 

你可能會說,線程不安全,我能夠對getInstance()方法作同步處理保證安全啊,好比下面這樣的寫法:

 public class Test {

private static Test instance;

private Test() {

}

public synchronized static Test getInstance(){

    if(instance==null){

instance=new Test();

    }

    return instance;

}

}

 

這樣的寫法是保證了線程安全,可是因爲getInstance()方法作了同步處理,synchronized將致使性能開銷。如getInstance()方法被多個線程頻繁調用,將會致使程序執行性能的降低。反之,若是getInstance()方法不會被多個線程頻繁的調用,那麼這個方案將可以提供使人滿意的性能。

 

那麼,有沒有更優雅的方案呢?前人的智慧是偉大的,在早期的JVM中,synchronized存在巨大的性能開銷,所以,人們想出了一個「聰明」的技巧--雙重檢查鎖定。人們經過雙重檢查鎖定來下降同步的開銷,代碼以下:

public class Test { //1

    private static Test instance; //2

    private Test() {

    }

    public static Test getInstance() { //3

        if (instance == null) { //4:第一次檢查

            synchronized (Test.class) { //5:加鎖

                if (instance == null) //6:第二次檢查

                    instance = new Test(); //7

            } //8

        } //9

        return instance; //10

    } //11

}

 

如上面代碼所示,若是第一次檢查instance不爲null,那麼就不須要執行下面的加鎖和初始化操做。所以,能夠大幅下降synchronized帶來的性能開銷。

 

坑1:指令重排問題

 

雙重檢查鎖定看起來彷佛很完美,這種寫法是否是絕對安全呢?從語義角度來看,並無什麼問題,可是其實仍是有坑。爲何呢?第7行代碼可分解爲以下的3行僞代碼:

memory=allocate(); //1:分配對象的內存空間

ctorInstance(memory); //2:初始化對象

instance=memory; //3:設置instance指向剛分配的內存地址

 

僞代碼中的2和3之間,可能會被重排序「在一些JIT編譯器上,這種重排序是真實發生的」,2和3之間重排序以後的執行時序以下:

memory=allocate(); //1:分配對象的內存空間

instance=memory; //3:設置instance指向剛分配的內存地址,注意此時對象尚未被初始化

ctorInstance(memory); //2:初始化對象

 

回到示例代碼第7行,若是發生重排序,另外一個併發執行的線程B就有可能在第4行判斷instance不爲null。線程B接下來將訪問instance所引用的對象,但此時這個對象可能尚未被A線程初始化完成,進而致使異常的出現。

 

在知曉問題發生的根源以後,咱們能夠想出兩個辦法解決:一是不容許2和3重排序;二是容許2和3重排序,但不容許其餘線程「看到」這個重排序。

 

基於volatile的解決方案,不容許2和3重排序

 

解決這個坑以前咱們要先來看看volatile這個關鍵字。其實這個關鍵字有兩層語義。第一層語義相信你們都比較熟悉,就是可見性。可見性指的是在一個線程中對該變量的修改會立刻由工做內存(Work Memory)寫回主內存(Main Memory),因此會立刻反應在其它線程的讀取操做中。順便一提,工做內存和主內存能夠近似理解爲實際電腦中的高速緩存和主存,工做內存是線程獨享的,主存是線程共享的。

 

volatile的第二層語義是禁止指令重排序優化。你們知道咱們寫的代碼(尤爲是多線程代碼),因爲編譯器優化,在實際執行的時候可能與咱們編寫的順序不一樣。編譯器只保證程序執行結果與源代碼相同,卻不保證明際指令的順序與源代碼相同。這在單線程看起來沒什麼問題,然而一旦引入多線程,這種亂序就可能致使嚴重問題。volatile關鍵字就能夠從語義上解決這個問題。

 

注意,禁止指令重排優化這條語義直到jdk1.5之後才能正確工做。此前的JDK中即便將變量聲明爲volatile也沒法徹底避免重排序所致使的問題。因此,在jdk1.5版本前,雙重檢查鎖形式的單例模式是沒法保證線程安全的。

 

jdk1.5之後的版本「固然目前主流JDK版本已然是jdk1.5後續版本了,注意一下便可」,對於前面的基於雙重檢查鎖定的方案,只須要作一點小的修改,就能夠實現線程安全的延遲初始化,示例代碼以下:

public class Test {

    private volatile static Test instance;

    private Test() {

    }

    public static Test getInstance() {

        if (instance == null) {

            synchronized (Test.class) {

                if (instance == null)

                    instance = new Test();//instance爲volatile,如今沒問題了

            }

        }

        return instance;

    }

}

 

當聲明對象的引用爲volatile後,前面僞代碼談到的2和3之間的重排序,在多線程環境中將會被禁止。

 

基於類初始化的解決方案,容許2和3重排序,但不容許其餘線程「看到」這個重排序

 

 

JVM在類的初始化階段「即在Class被加載後,且被線程使用以前」,會執行類的初始化。在執行類的初始化期間,JVM會去獲取多個線程對同一個類的初始化。基於這個特性,實現的示例代碼以下:

public class Test {

    private Test() {

    }

    private static class InstanceHolder {

        public static Test instance = new Test();

    }

    public static Test getInstance() {

        return InstanceHolder.instance; //這裏將致使InstanceHolder類被初始化

    }

}

 

這個方案的本質是容許前面僞代碼談到的2和3重排序,但不容許其餘線程「看到」這個重排序。在Test示例代碼中,首次執行getInstance()方法的線程將致使InstanceHolder類被初始化。因爲Java語言是多線程的,多個線程可能在同一時間嘗試去初始化同一個類或接口(好比這裏多個線程可能會在同一時刻調用getInstance()方法來初始化IInstanceHolder類)。Java語言規定,對於每個類和接口C,都有一個惟一的初始化鎖LC與之對應。從C到LC的映射,由JVM的具體實現去自由實現。JVM在類初始化期間會獲取這個初始化鎖,而且每一個線程至少獲取一次鎖來確保這個類已經被初始化過了。


坑2:序列化與反射問題

 

可是,上面提到的全部實現方式都有兩個共同的缺點:

1.都須要額外的工做(Serializable、transient、readResolve())來實現序列化,不然每次反序列化一個序列化的對象實例時都會建立一個新的實例。

 

2.可能會有人使用反射強行調用咱們的私有構造器(若是要避免這種狀況,能夠修改構造器,讓它在建立第二個實例的時候拋異常)。

 

固然,還有一種更加優雅的方法來實現單例模式,那就是枚舉寫法:

public enum Singleton {

    INSTANCE;

    private String name;

    public String getName(){

        return name;

    }

    public void setName(String name){

        this.name = name;

    }

}

調用時的僞代碼:

Singleton.INSTANCE.getName();

 

使用枚舉除了線程安全和防止反射強行調用構造器以外,還提供了自動序列化機制,防止反序列化的時候建立新的對象。所以,Effective Java推薦儘量地使用枚舉來實現單例。

 

總結

 

代碼沒有一勞永逸的寫法,只有在特定條件下最合適的寫法。在不一樣的平臺、不一樣的開發環境(尤爲是jdk版本)下,天然有不一樣的最優解或者說較優解。

 

好比枚舉,雖然Effective Java中推薦使用,可是在Android平臺上倒是不被推薦的。在這篇Android Training中明確指出:Enums often require more than twice as much memory as static constants. You should strictly avoid using enums on Android.

 

再好比雙重檢查鎖法,不能在jdk1.5以前使用,而在Android平臺上使用就比較放心了(通常Android都是jdk1.6以上了,不只修正了volatile的語義問題,還加入了很多鎖優化,使得多線程同步的開銷下降很多)。

 

最後,無論採起何種方案,請時刻牢記單例的三大要點:

    1. 線程安全

    2. 延遲加載

    3. 序列化與反序列化安全

相關文章
相關標籤/搜索