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

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

![](https://cdn.geekdigging.com/DesignPatterns/java_design_pattern.jpg) ## 1. 引言 最近在看秦小波老師的《設計模式之禪》這本書,裡面有句話對我觸動挺大的。 > 設計模式已經誕近 20 年了,其間出版了很多關於它的經典著作,相信大家都能如數家珍。儘管有這麼多書,工作 5 年了還不知道什麼是策略模式、狀態模式、責任鏈模式的程式設計師大有人在。 很不幸,我就是這部分人當中的一個。回想起這幾年的工作生涯,設計模式不能說沒有接觸過,但是絕對不多,能想到的隨手寫出來的幾個設計模式也僅限於「單例模式」、「工廠模式」、「建造者模式」、「代理模式」、「裝飾模式」。 好吧,我認知比較深的也就這幾個模式,說出來都自己感覺臉紅,還有很大一部分僅限於聽過,說了以後大致知道是什麼玩意,沒有細細的研究過,正好趁著這個機會,寫點文章,給自己補補課,所以這個系列的名字叫「補課」進行時。 至於為什麼要選設計模式,因為設計模式這個東西,它是軟體行業的經驗總結,因此它具有更廣泛的適應性,不管你使用什麼程式語言,不管你遇到什麼業務型別,都需要用到它。 因為它是一個指導思想,學習了它以後,我們可以站在一個更高的層次去賞析程式程式碼、軟體設計、架構,完成一個 Coder 的蛻變。 ## 2. 單例模式 在古代行軍打仗的時候,每支軍隊都要有一個將軍,戰場上如何作戰,完全需要聽將軍的指揮,將軍怎麼說,這個仗就怎麼打,每個士兵都知道將軍是誰,而不需要在將軍前面加上張將軍或者是李將軍。 ![](https://cdn.geekdigging.com/DesignPatterns/01/napolun.jpg) 既然將軍只能有一個,我們需要用程式去實現這個將軍的話,也就是一個類只能產生一個將軍的物件,不能產生多個,這就是單例模式的要義。 產生一個物件有多重方式,最常見的是直接 new 一個出來,當然,還可以有反射、複製等操作,我們如何來控制一個類只能產生一個物件呢? 最簡單的做法是直接在建構函式上動手腳,使用 new 來新建物件的時候,會根據輸入的引數呼叫相應的建構函式,我們如果直接把建構函式設定成 private ,這樣就可以做到不允許外部類來訪問建立物件,從而保證物件的唯一性。 ```java public class General { // 初始化一個將軍 private static final General general = new General(); // 建構函式私有化 private General() { } public static General getInstance() { return general; } public void command() { System.out.println("將軍下令,兄弟們跟我上啊!!!"); } } ``` 現在我們有了一個將軍類,接下來我們實現一個士兵類: ```java public class Soldier { public static void main(String[] args) { for (int soldiers = 0; soldiers < 5; soldiers++) { General general = General.getInstance(); general.command(); } } } ``` 有 5 個士兵收到了將軍的命令,跟著將軍一起衝鋒陷陣,成就一世英名。 ![](https://cdn.geekdigging.com/DesignPatterns/01/napolunjiamian.jpg) 單例模式(Singleton Pattern)的定義異常簡單:Ensure a class has only one instance, and provide a global point of accessto it.(確保某一個類只有一個例項,而且自行例項化並向整個系統提供這個例項。) **優點:** 由於單例模式在記憶體中只有一個例項,減少了記憶體開支,特別是一個物件需要頻繁地建立、銷燬時,而且建立或銷燬時效能又無法優化,單例模式的優勢就非常明顯。 **缺點:** 單例模式一般沒有介面,擴充套件很困難,若要擴充套件,除了修改程式碼基本上沒有第二種途徑可以實現。 **注意事項:** 在某些有一定併發的場景中,需要注意執行緒同步的問題,防止建立多個物件,造成未知錯誤異常。 因為單例模式有多種變形的寫法,一定要注意這個問題,舉一個會產生執行緒同步問題的例子: ```java 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 關鍵字。 ```java 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的時候,才回去初始化這個單例。 餓漢式天生就是執行緒安全的,可以直接用於多執行緒而不會出現問題,懶漢式本身是非執行緒安全的,為了實現執行緒安全有幾種寫法,上面那種加方法鎖的方式有點笨重,我們還可以使用同步程式碼塊,減少鎖的顆粒大小。 ```java 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 個將軍的命令,我們用程式碼實現一下,這段程式碼稍微有點長: ```java public class General1 { // 定義最多能產生的將軍數量 private static int maxNumOfGeneral1 = 3; // 定義一個列表,存放所有將軍的名字 private static ArrayList nameList = new ArrayList<> (); // 定義一個列表,容納所有的將軍例項 private static ArrayList 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 來代替,或者加鎖等方式。 我們再建立一個士兵類,等將軍發號施令: ```java public class Soldier1 { public static void main(String[] args) { for (int soldiers1 = 0; soldiers1 < 5; soldiers1++) { General1 general = General1.getInstance(); general.command(); } } } ``` 結果是這樣的: ```shell 將軍說:我是 0 號將軍 將軍說:我是 0 號將軍 將軍說:我是 1 號將軍 將軍說:我是 0 號將軍 將軍說:我是 2 號將軍 ``` 這種需要產生固定數量物件的模式就叫做有上限的多例模式,它是單例模式的一種擴充套件,採用有上限的多例模式,我們可以在設計時決定在記憶體中有多少個例項,方便系統進行擴充套件,修正單例可能存在的效能問題,提供系統的響應速度。例如讀取檔案,我們可以在系統啟動時完成初始化工作,在記憶體中啟動固定數量的 reader 例項,然後在需要讀取檔案時就可以快速