設計模式之單例設計模式

螞蟻style發表於2020-04-18

  前面我們已經講解了設計模式的七大設計原則,今天我們就來聊一聊設計模式中的單例設計模式,看看如何從小小單例模式衍生出來一個大世界。

  單例設計模式最簡單的兩個形態分為“懶漢式”和“餓漢式”,顧名思義,懶漢式就是基於事件驅動去載入,俗稱懶載入,餓漢式就是提前載入。

餓漢式

public class Singletion {

    private Singletion() {
    }

    private static Singletion singletion = new Singletion();

    public static Singletion getInstance() {
        return singletion;
    }
}

上面我們是通過靜態變數的形式例項化一次,我們可不可以換一種下面的形式呢,我們把建立單例物件放在靜態程式碼塊中,可以實現相同的效果

public class Singletion {

    private Singletion() {
    }
    static{
        singletion = new Singletion();
    }

    private static Singletion singletion;

    public static Singletion getInstance() {
        return singletion;
    }
}

 看完餓漢式我們再看下懶漢式

public class Singletion {

    private Singletion() {
    }
    private static Singletion singletion;

    public static Singletion getInstance() {
        if(singletion==null){
            return new Singletion();
        }
        return singletion;
    }
}

但是這種單例有一個很明顯的問題,執行緒不安全,在多執行緒環境下,很有可能建立多個物件,我們需要改進一下,加鎖,沒錯就是加鎖

 

public class Singletion {

    private Singletion() {
    }
    private static Singletion singletion;

    public static synchronized Singletion getInstance() {
        if(singletion==null){
            return new Singletion();
        }
        return singletion;
    }
}

那麼我把鎖加在方法上怎麼樣呢,如果被一個外國人看到,我想他一定會說:噢,天哪,這真是個糟糕的決定.為什麼說是個糟糕的決定呢?

在我們實際開發中,一個方法中一定有許多程式碼要執行,我們僅僅只是想同步建立單例物件這一部分程式碼,而我們卻把鎖加在了方法上.

這就好比一個廁所有多個坑位,我們只是想在每個坑位的門上加鎖,而你卻把鎖加在了廁所門上,當進去一個人就把廁所鎖上,這樣顯然不太合適,因此我就再次改造了一下

public class Singletion {

    private Singletion() {
    }

    private static Singletion singletion;

    public static Singletion getInstance() {

        if (singletion == null) {
            synchronized (Singletion.class) {
                if (singletion == null) {
                    return new Singletion();
                }
            }
        }
        return singletion;
    }
}

這樣我們使用過加鎖和雙重檢查機制解決了多執行緒不安全的問題,事情真的就萬事大吉了?如果讓一個外國人看到,我想他會說:噢,天哪,這真是個糟糕的決定.

這裡我們就要聊一聊JVM.編譯器和處理器的指令重排序和物件建立了

  物件建立在我們new的時候到底做了些什麼呢?

  1:為物件分配記憶體空間

  2:初始化物件

  3:將物件指向分配的記憶體空間

而指令重排序做了一系列優化,物件建立的過程順序很有可能1->3->2,那麼在多執行緒環境下很有可能執行緒1執行了 1,3還沒有執行2初始化物件,另一個執行緒呼叫getInstance方法發現單例物件不為null,直接返回單例物件,但是此時單例物件還沒有執行2,也就是物件初始化.

  為了解決指令重排序可能產生的影響,我們需要在本單例物件的靜態變數上加上volitaile關鍵字修飾才能保證後續不會出問題.

public class Singletion {

    private Singletion() {
    }

    private static volatile Singletion singletion;

    public static Singletion getInstance() {

        if (singletion == null) {
            synchronized (Singletion.class) {
                if (singletion == null) {
                    return new Singletion();
                }
            }
        }
        return singletion;
    }
}

那麼這樣就結束了?有沒有辦法讓JVM幫助我們保證執行緒安全呢?畢竟我不想寫太多程式碼,接下來我們聊一聊靜態內部類

public class Singletion {

    private Singletion() {
    }

    private static class SingletonInside{
        private static final Singletion SGT=new Singletion();
    }

    public static Singletion getInstance() {
        return SingletonInside.SGT;
    }
}
 

才能保證後續不會出問題.這種方式採用類載入的機制來保證執行緒安全,並且實現了懶載入,只有訪問靜態內部類才回去載入靜態內部類.

這樣就萬無一失了?這時如果一個外國人看到,我想他會說:噢,天啊,這真是個糟糕的決定.

如果此刻我要用反射去建立物件,還能說萬無一失嗎,在反射的基礎上,上面的單例全部都可以推翻?

  Constructor<Singletion> constructor = Singletion.class.getDeclaredConstructor();
   constructor.setAccessible(true);

        Singletion instance1 = constructor.newInstance();
        Singletion instance2 = constructor.newInstance();
        System.out.println(instance1);
        System.out.println(instance2);    

你會驚訝的發現,兩次物件建立的地址不一樣,建立了兩個不一樣地址的物件

那麼如何才能防止反射建立呢?

public class Singletion {

    private Singletion() {
        if (SingletonInside.SGT != null) {
            throw new RuntimeException("不允許反射建立");
        }
    }

    private static class SingletonInside{
        private static final Singletion SGT=new Singletion();
    }

    public static Singletion getInstance() {
        return SingletonInside.SGT;
    }
}

這樣就可以了嗎?如果實現了序列化介面,我們通過序列化和反序列化還能拿到同一個物件嗎?

         Singletion instance = Singletion.getInstance();    
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("src/main/resources/temp.txt")); oos.writeObject(instance1); oos.close(); //反序列化 ObjectInputStream ois = new ObjectInputStream( new FileInputStream("src/main/resources/temp.txt")); Singletion instance2 = (Singletion)ois.readObject();

我們通過比較地址會發現,序列化後的物件會建立另一個記憶體地址,我們如何防止序列化呢? readResolve方法序列化,我們就直接返回物件

public class Singletion implements Serializable {

    private Singletion() {
        if (SingletonInside.SGT != null) {
            throw new RuntimeException("不允許反射建立");
        }
    }

    private static class SingletonInside{
        private static final Singletion SGT=new Singletion();
    }

    public static Singletion getInstance() {
        return SingletonInside.SGT;
    }

    private Object readResolve(){
        return SingletonInside.SGT;
    }
    
}

目前單例模式還有最後一種終級方案,可以解決上述問題

public enum Singleton implements Serializable{
    SGT;
    
    public void say(){
        System.out.println("我是列舉單例");
    }
}
這種方式是 Effective Java  作者 Josh Bloch  (喬什布洛赫)提倡的方式,但是無法實現延時載入,那麼我們究竟該如何在實際開發中選用呢,根據業務場景選用合適的單例模式,
沒有最好的單例,只有最合適的單例.下一章我們將會聊一聊簡單工廠模式和抽象工廠設計模式

相關文章