【那座山,正當頂上,有一塊仙石。其石有三丈六尺五寸高,有二丈四尺圍圓。三丈六尺五寸高,按周天三百六十五度;二丈四尺圍圓,按政歷二十四氣。上有九竅八孔,按九宮八卦。四面更無樹木遮陰,左右倒有芝蘭相襯。蓋自開闢以來,每受天真地秀,日精月華,感之既久,遂有靈通之意。內育仙胞,一日迸裂,產一石卵,似圓球樣大 ...
【那座山,正當頂上,有一塊仙石。其石有三丈六尺五寸高,有二丈四尺圍圓。三丈六尺五寸高,按周天三百六十五度;二丈四尺圍圓,按政歷二十四氣。上有九竅八孔,按九宮八卦。四面更無樹木遮陰,左右倒有芝蘭相襯。蓋自開闢以來,每受天真地秀,日精月華,感之既久,遂有靈通之意。內育仙胞,一日迸裂,產一石卵,似圓球樣大。因見風,化作一個石猴,五官俱備,四肢皆全。便就學爬學走,拜了四方。目運兩道金光,射沖鬥府。】
上面這段文字,描述了悟空出生時的場景。孫悟空只有一個,任何程式要使用孫悟空這個對象,都只能使用同一個實例。
所以,單例模式非常好理解,單例模式確保一個類只有一個實例,且這個類自己創建自己的唯一實例並向整個系統提供這個實例,這個類叫做單例類。
其實,這個設計模式與抽象思維或者業務架構設計沒有太多關係,更多要求的是對Java記憶體模型以及併發編程的理解,所以在介紹單例模式之前,需要先介紹一下JMM(Java Memory Model)相關的基礎知識,然後再理解單例模式就會簡單得多。
1.重排序
在執行程式時,為了提高性能,編譯器和處理器常常會對指令做重排序。重排序又包括編譯器優化的重排序、指令級並行的重排序以及記憶體系統的重排序。
比如下麵一段代碼:
int a = 1;//A int b = 2;//B
A並不一定是比B先執行的,它們的執行順序可能是A-B,也可能是B-A,甚至有可能是一同執行的。
2.happens-before與as-if-serial
as-if-serial保證單線程內程式的執行結果不被改變,它給程式員一個幻覺:單線程程式是按程式的順序來執行的;
happens-before保證正確同步的多線程程式的執行結果不被改變,它給程式員一個幻覺:正確同步的多線程程式是按happens-before指定的順序來執行的。
程式員其實並不關心兩個指令是否真的被重排序了,我們只關心程式執行的語義不能被改變,也就是程式的執行結果不能改變。
比如上面那段代碼的A與B順序顛倒過來,對程式的結果並沒有影響,我們還是可以獲得兩個賦值正確的int變數。但如果是下麵這段代碼,就有問題了:
int x = 1;//A int x = 2;//B
如果這兩行代碼的執行順序發生了改變,那麼我們最終得到的x的值可能不是2,而是1,那樣程式的執行結果就發生了改變了。好在JMM對於這種有數據依賴性(兩個指令都是對同一個變數進行的)的重排序已經禁止了,所以我們並不需要擔心。
3.類初始化鎖
Java語言規範規定,對於每一個類或者介面A,都有一個唯一的初始化鎖LA與之對應。從A到LA的映射,由JVM的具體實現去自由實現。JVM在類初始化期間會獲取這個初始化鎖,並且每個線程至少獲取一次鎖來確保這個類已經被初始化過了。這個鎖可以同步多個線程對同一個類的初始化。
4.volatile的記憶體語義
當寫一個volatile變數時,JMM會把該線程對應的本地記憶體中的共用變數值刷新到主記憶體;當讀一個volatile變數時,JMM會把該線程對應的本地記憶體置為無效,線程接下來將從主記憶體中讀取共用變數。
5.JSR-133記憶體模型
從JDK5開始,java升級了記憶體模型,開始使用JSR-133記憶體模型。JSR-133對舊記憶體模型的修補主要有兩個:增強volatile的記憶體語義,嚴格限制volatile變數與普通變數的重排序(舊記憶體模型允許volatile變數與普通變數重排序);增強final的記憶體語義,使final具有初始化安全性,在舊的記憶體模型中,多次讀取同一個final變數的值可能會不同。
下麵我們再來開始看單例模式的各種實現方式,也許你還對上面這些概念不是很熟悉,但結合具體的代碼,相信會加深你的理解。
餓漢模式
package com.tirion.design.singleton; public class WuKong { private static WuKong wuKong = new WuKong(); private WuKong() { } public static WuKong getWuKong() { return wuKong; } }
static變數會在類裝載的時候完成初始化,這裡註意構造方法也被聲明為private,我們只能通過WuKong.getWuKong()來獲取WuKong的唯一實例wuKong靜態變數。
因為單例的實現是在類裝載的時候完成的,並且無論後面對象實例是否被真正用到(WuKong.getWuKong()會不會得到執行),對象實例都已經被創建了,所以把這種以空間換時間的方式成為餓漢模式。
餓漢模式的優缺點也非常明顯,它不必等到用到的時候再創建實例,節省了程式的運行時間,但在某些情況下也可能創建了不必要的對象,導致空間被浪費。
懶漢模式
package com.tirion.design.singleton; public class WuKong { private static WuKong wuKong = null; private WuKong() { } public static synchronized WuKong getWuKong() { if (wuKong == null) { wuKong = new WuKong(); } return wuKong; } }
懶漢模式與餓漢模式的不同之處在於把實例對象的創建放到了靜態工廠方法內部,當調用WuKong.getWuKong()時,會判斷實例是否已經被創建,如果沒有創建則進行實例對象的初始化工作,已經創建則直接返回。
懶漢模式為了實現多線程環境下的線程安全,在創建實例的方法上增加了synchronized同步控制,順便說一下synchronized是編譯器通過插入monitorenter和monitorexit指令來進行同步控制的,所有調用synchronized方法的線程都要在monitorenter處等待獲取monitor對象鎖,所以導致懶漢模式線上程競爭環境下效率非常低,這也是稱之為懶漢模式的原因。
基於volatile的DCL雙重檢查鎖機制的單例
1 package com.tirion.design.singleton; 2 3 public class WuKong { 4 private static volatile WuKong wuKong = null; 5 6 private WuKong() { 7 } 8 9 public static WuKong getWuKong() { 10 if (wuKong == null) { 11 synchronized (WuKong.class) { 12 if (wuKong == null) { 13 wuKong = new WuKong(); 14 } 15 } 16 } 17 return wuKong; 18 } 19 }
我們發現,雙重鎖檢查機制相比於懶漢模式,又有幾個細節被改動:
a.靜態工廠方法的synchronized被去掉了,改為使用同步代碼塊來進行控制
b.從原先的一次判斷對象實例是否為null改為了兩次判斷
c.對象實例增加了volatile關鍵詞修飾
下麵我們來對這幾個細節一一進行分析,看看這些改動有哪些意義:
針對第一個改動,我們從懶漢模式的分析中已經可以看出,synchronized方法的效率會比較差,實際情況下,除了對象實例剛剛要被創建及正在被創建的那段時間里,後面的時間針對synchronized同步鎖的競爭都是浪費的(因為對象實例已經被建立了),所以這裡通過第一個判斷 if (wuKong == null){synchronized...},規避了對象實例被創建後的所有對synchronized的同步鎖競爭,大大節省了代碼的執行時間,提高了效率;
針對第二個改動,是結合上一個改動而產生的,想象現在有兩個線程A和B同時進入了Line9(代碼行號)方法,由於它倆是前兩個進入方法的,所以它們都通過了Line10的對象實例為空的判斷,進入了Line11的同步代碼塊,由於同一時間只有一個線程能夠進入同步代碼塊,所以線程A獲得了監視器鎖,進入了同步代碼塊內部並執行了對象實例的初始化工作,當線程A退出同步代碼塊時會釋放監視器鎖,這時處於Blocked狀態下的線程B就會獲取到監視器鎖併進入到同步代碼塊中,如果沒有第二個實例對象是否為空的判斷的話,線程B就也會執行一遍對象實例的初始化,這樣就違反單例模式對象實例只初始化一次的原則了;
針對第三個改動,我們先要看一下JVM是如何執行Line13的wuKong = new WuKong()這段代碼的,其實,這一行代碼可以分解為如下的三行偽代碼:
memory = allocate(); // 1-分配對象的記憶體空間 ctorInstance(memory); // 2-初始化對象 wuKong = memory; // 3-設置wuKong指向剛分配的記憶體地址
在一些編譯器上,上面三行代碼中的2和3可能會發生重排序,因為重排序並不影響as-if-serial原則,重排序後,就是先把wuKong這個實例指向空的記憶體空間地址,隨後再在空的記憶體空間上進行對象的初始化工作。
在單線程的情況下,上述重排序確實不會影響程式的執行結果,但在多線程環境下,可能會出現如下情況:
線程B剛剛進入Line10的is null判斷時,線程A恰好出現了對象記憶體地址分配與對象初始化的重排序,這時候線程B看到的對象實例不是null(空的記憶體地址,但不是null),所以線程B直接繞過了同步代碼塊,直接返回了一個還未進行初始化的對象。
那麼我們如何解決這個問題呢?一種思路是禁止對象記憶體地址指向和對象初始化的重排序。
在JDK5或更高版本後,Java開始使用了新的JSR-133記憶體模型,在這個模型中對舊記憶體模型做了一個重要的修補,增強了volatile關鍵字的記憶體語義,通過添加記憶體屏障的方式,禁止了volatile對象初始化與記憶體地址指向的重排序,也因此避免了上述情況可能導致的問題。
需要註意的是,這個解決方案只在JDK5及之後才能正常運作。
基於類初始化的單例
package com.tirion.design.singleton; public class WuKong { private WuKong() { }; private static class WuKongHolder { public static WuKong wuKong = new WuKong(); } public static WuKong getWuKong() { return WuKongHolder.wuKong; } }
在調用WuKong.getWuKong()時,WuKongHolder將被立即初始化,在上面我們已經介紹了類初始化時,所有線程都會去競爭一個類初始化鎖,所以這個初始化動作是線程安全的。
同時,在第一個線程完成類的初始化寫入工作,釋放類初始化鎖的之後,第二個線程會嘗試獲取這個類初始化鎖,happens-before規則保證了一個鎖的釋放一定發生在同一個鎖的獲取之前,所以第一個線程在釋放鎖之前執行類的初始化的寫入操作對後面獲取同一個鎖的線程可見。
在happens-before規則的保證下,無論WuKong wuKong = new WuKong();代碼內部發生了怎樣的重排序,對於後面的線程來說都不可見。
通過對比基於volatile的雙重檢查鎖定的單例和基於類初始化的單例,我們發現基於類初始化的方案的實現代碼更加簡潔方便,也不需要太多的JMM知識。
但是基於volatile的DCL的單例模式有一個額外的優勢,就是除了可以對靜態欄位實現延遲初始化之外,還可以對實例欄位實現延遲初始化。所以當需要對實例欄位實現延遲初始化的時候,可以選擇基於volatile的雙重檢查機制的單例模式。
基於枚舉的單例
package com.tirion.design.singleton; public enum WuKongEnum { WUKONG; private WuKong wuKong; private WuKongEnum() { wuKong = new WuKong(); } public WuKong getWuKong() { return wuKong; } }
在理解基於枚舉的單例之前,我們先要知道編譯器會在創建枚舉時替我們創建一個繼承java.lang.Enum的類,這個創建過程我們是無法干涉的,這個類看起來像下麵這樣
public class WuKongEnum extends Enum{ public static final WuKongEnum WUKONG; ... }
在調用WuKongEnum.getWuKong()時,編譯器自動生成的private構造方法將得到執行,對象實例將得到初始化,另外由於對象實例是static final的,所以JVM將會保證它只會初始化一次。另外Enum實現了Serializable介面,所以它也無償提供了序列化機制。
所以說,用枚舉實現單例模式是簡潔、高效且安全的。
關於單例模式的介紹就到這裡,你可以將它記憶為悟空單例模式。
如果你認為文章中哪裡有錯誤或者不足的地方,歡迎在評論區指出,也希望這篇文章對你學習java設計模式能夠有所幫助。轉載請註明,謝謝。
更多設計模式的介紹請到悟空模式-java設計模式中查看。