「補課」進行時:設計模式(1)——人人都能應該懂的單例模式

1. 引言

最近在看秦小波老師的《設計模式之禪》這本書,裏面有句話對我觸動挺大的。

設計模式已經誕近 20 年了,其間出版了很多關於它的經典著作,相信大家都能如數家珍。儘管有這麼多書,工作 5 年了還不知道什麼是策略模式、狀態模式、責任鏈模式的程序員大有人在。

很不幸,我就是這部分人當中的一個。回想起這幾年的工作生涯,設計模式不能說沒有接觸過,但是絕對不多,能想到的隨手寫出來的幾個設計模式也僅限於「單例模式」、「工廠模式」、「建造者模式」、「代理模式」、「裝飾模式」。

好吧,我認知比較深的也就這幾個模式,說出來都自己感覺臉紅,還有很大一部分僅限於聽過,說了以後大致知道是什麼玩意,沒有細細的研究過,正好趁着這個機會,寫點文章,給自己補補課,所以這個系列的名字叫「補課」進行時。

至於為什麼要選設計模式,因為設計模式這個東西,它是軟件行業的經驗總結,因此它具有更廣泛的適應性,不管你使用什麼編程語言,不管你遇到什麼業務類型,都需要用到它。

因為它是一個指導思想,學習了它以後,我們可以站在一個更高的層次去賞析程序代碼、軟件設計、架構,完成一個 Coder 的蛻變。

2. 單例模式

在古代行軍打仗的時候,每支軍隊都要有一個將軍,戰場上如何作戰,完全需要聽將軍的指揮,將軍怎麼說,這個仗就怎麼打,每個士兵都知道將軍是誰,而不需要在將軍前面加上張將軍或者是李將軍。

既然將軍只能有一個,我們需要用程序去實現這個將軍的話,也就是一個類只能產生一個將軍的對象,不能產生多個,這就是單例模式的要義。

產生一個對象有多重方式,最常見的是直接 new 一個出來,當然,還可以有反射、複製等操作,我們如何來控制一個類只能產生一個對象呢?

最簡單的做法是直接在構造函數上動手腳,使用 new 來新建對象的時候,會根據輸入的參數調用相應的構造函數,我們如果直接把構造函數設置成 private ,這樣就可以做到不允許外部類來訪問創建對象,從而保證對象的唯一性。

public class General {
    // 初始化一個將軍
    private static final General general = new General();

    // 構造函數私有化
    private General() {

    }

    public static General getInstance() {
        return general;
    }

    public void command() {
        System.out.println("將軍下令,兄弟們跟我上啊!!!");
    }
}

現在我們有了一個將軍類,接下來我們實現一個士兵類:

public class Soldier {
    public static void main(String[] args) {
        for (int soldiers = 0; soldiers < 5; soldiers++) {
            General general = General.getInstance();
            general.command();
        }
    }
}

有 5 個士兵收到了將軍的命令,跟着將軍一起衝鋒陷陣,成就一世英名。

單例模式(Singleton Pattern)的定義異常簡單:Ensure a class has only one instance, and provide a global point of accessto it.(確保某一個類只有一個實例,而且自行實例化並向整個系統提供這個實例。)

優點:

由於單例模式在內存中只有一個實例,減少了內存開支,特別是一個對象需要頻繁地創建、銷毀時,而且創建或銷毀時性能又無法優化,單例模式的優勢就非常明顯。

缺點:

單例模式一般沒有接口,擴展很困難,若要擴展,除了修改代碼基本上沒有第二種途徑可以實現。

注意事項:

在某些有一定並發的場景中,需要注意線程同步的問題,防止創建多個對象,造成未知錯誤異常。

因為單例模式有多種變形的寫法,一定要注意這個問題,舉一個會產生線程同步問題的例子:

public class Singleton {
    private static Singleton singleton = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

這種方案在沒有並發的情況下不會出現任何問題,但若是出現了並發,就會在內存中產生多個實例。

原因是線程 A 在執行到 singleton = new Singleton() 這句話的時候,但是還沒有完成實例的初始化操作,線程 B 恰巧執行到了 singleton == null 的判斷,這時,線程 B 判斷條件為真,也去執行 singleton 初始化的這句代碼,就會造成線程 A 獲得了一個對象,線程 B 也獲得了一個對象。

解決線程不安全的方式有很多種,比如加一個 synchronized 關鍵字。

public class Singleton1 {
    private static Singleton1 singleton1 = null;

    private Singleton1() {
    }

    public static synchronized Singleton1 getInstance() {
        if (singleton1 == null) {
            singleton1 = new Singleton1();
        }
        return singleton1;
    }
}

這種在代碼塊中使用 synchronized 關鍵字的方式名字叫做懶漢式單例,前面我們寫的那個將軍叫做餓漢式單例。

餓漢式和懶漢式的命名很有意思:

  • 餓漢:類一旦加載,就把單例初始化完成,保證 getInstance 的時候,單例是已經存在的了。
  • 懶漢:懶漢比較懶,只有當調用getInstance的時候,才回去初始化這個單例。

餓漢式天生就是線程安全的,可以直接用於多線程而不會出現問題,懶漢式本身是非線程安全的,為了實現線程安全有幾種寫法,上面那種加方法鎖的方式有點笨重,我們還可以使用同步代碼塊,減少鎖的顆粒大小。

public class Singleton2 {
    private static volatile Singleton2 singleton2;

    private Singleton2() {
    }

    public static synchronized Singleton2 getInstance() {
        // 第一層檢查,檢查是否有引用指向對象,高並發情況下會有多個線程同時進入
        if(singleton2 == null) {
            // 第一層鎖,保證只有一個線程進入
            synchronized (Singleton2.class) {
                // 第二層檢查
                if (singleton2 == null) {
                    // volatile 關鍵字作用為禁止指令重排,保證返回 Singleton 對象一定在創建對象後
                    singleton2 = new Singleton2();
                }
            }
        }
        return singleton2;
    }
}

關於 volatile 關鍵字多說兩句,如果對象沒有 volatile 關鍵字,這裡會涉及到一個指令重排序問題, singleton2 = new Singleton2() 這句話實際上會涉及到以下三件事兒:

  1. 申請一塊內存空間。

  2. 在這塊空間里實例化對象。

  3. singleton2 的引用指向這塊空間地址。

對於以上步驟,指令重排序很有可能不是按上面 123 步驟依次執行的。比如,先執行 1 申請一塊內存空間,然後執行 3 步驟, singleton2 的引用去指向剛剛申請的內存空間地址,那麼,當它再去執行 2 步驟,判斷 singleton2 時,由於 singleton2 已經指向了某一地址,它就不會再為 null 了,因此,也就不會實例化對象了。

而我們添加的關鍵字 volatile 就是為了解決這個問題,因為 volatile 可以禁止指令重排序。

不過還是建議大家使用餓漢式的單例模式,畢竟比較簡單,出錯的概率比較低。

2.1 單例模式擴展——上限的多例模式

還是剛才那個例子,如果一隻軍隊中,偶然情況下出現了 3 個將軍,士兵需要聽從這 3 個將軍的命令,我們用代碼實現一下,這段代碼稍微有點長:

public class General1 {
    // 定義最多能產生的將軍數量
    private static int maxNumOfGeneral1 = 3;

    // 定義一個列表,存放所有將軍的名字
    private static ArrayList<String> nameList = new ArrayList<> ();

    // 定義一個列表,容納所有的將軍實例
    private static ArrayList<General1> general1ArrayList = new ArrayList<> ();

    // 定義當前將軍序號
    private static int countNumOfGeneral1 = 0;

    // 在靜態代碼塊中產生所有的將軍
    static {
        for (int i = 0; i < maxNumOfGeneral1; i++) {
            general1ArrayList.add(new General1(String.valueOf(i)));
        }
    }

    private General1() {
        // 目的是不產生將軍
    }

    private General1(String name) {
        // 給將軍加個名字,建立一個將軍對象
        nameList.add(name);
    }

    public static General1 getInstance() {
        // 隨機產生一個將軍,只要能發號施令就成
        Random random = new Random();
        countNumOfGeneral1 = random.nextInt(maxNumOfGeneral1);
        return general1ArrayList.get(countNumOfGeneral1);
    }

    public void command() {
        System.out.println("將軍說:我是 " + nameList.get(countNumOfGeneral1) + " 號將軍");
    }
}

上面這段代碼使用了兩個 ArrayList 分別存儲實例和實例變量。

如果考慮到線程安全的問題,可以使用 Vector 來代替,或者加鎖等方式。

我們再創建一個士兵類,等將軍發號施令:

public class Soldier1 {
    public static void main(String[] args) {
        for (int soldiers1 = 0; soldiers1 < 5; soldiers1++) {
            General1 general = General1.getInstance();
            general.command();
        }
    }
}

結果是這樣的:

將軍說:我是 0 號將軍
將軍說:我是 0 號將軍
將軍說:我是 1 號將軍
將軍說:我是 0 號將軍
將軍說:我是 2 號將軍

這種需要產生固定數量對象的模式就叫做有上限的多例模式,它是單例模式的一種擴展,採用有上限的多例模式,我們可以在設計時決定在內存中有多少個實例,方便系統進行擴展,修正單例可能存在的性能問題,提供系統的響應速度。例如讀取文件,我們可以在系統啟動時完成初始化工作,在內存中啟動固定數量的 reader 實例,然後在需要讀取文件時就可以快速響應。