「補課」進行時:設計模式(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()
這句話實際上會涉及到以下三件事兒:
-
申請一塊記憶體空間。
-
在這塊空間裡實例化物件。
-
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 例項,然後在需要讀取檔案時就可以快速響應。