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

極客挖掘機發表於2020-10-20

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 例項,然後在需要讀取檔案時就可以快速響應。

相關文章