最近在看jvm,發現隨着自己對jvm底層的了解,現在對java代碼可以說是有了全新的認識。今天就從jvm的角度來看一看以前自以為很了解的單例模式。
了解單例模式的人都知道,單例模式有兩種:“餓漢模式”和“懶漢模式”。
引用一段網上對這兩種模式的介紹:
“餓漢模式的特點是加載類時比較慢,但運行時獲取對象的速度比較快,線程安全。餓漢式是線程安全的,在類創建的同時就已經創建好一個靜態的對象供系統使用,以后不在改變。懶漢模式的特點是加載類時比較快,但是在運行時獲取對象的速度比較慢,線程不安全, 懶漢式如果在創建實例對象時不加上synchronized則會導致對象的訪問不是線程安全的。所以在此推薦大家使用餓漢模式。”
筆者先給出結論“上面這段描述可以說是完全不正確,最后給出的結論還算勉強正確,為什么說勉強正確,因為我不會推薦大家使用餓漢模式,我會直接說就用餓漢模式,懶漢模式在任何情況下都不需要”。
網上這段文字的錯誤主要有兩點
- 懶漢模式線程不安全,如果想線程安全必須加synchronized
- 餓漢模式在加載類時會慢
先來看一下懶漢模式,不用synchronized也能實現線程安全
先來回顧一下懶漢模式的“發展史”
懶漢模式V1.0:
package common; public class Singleton { private static Singleton singleton; public static Singleton getInstance(){ if (singleton==null) { singleton=new Singleton(); } return singleton; } }
懶漢模式V1.0看起來就很不安全,當同時有兩個線程調用 getInstance()方法時,很容易讓兩個線程都進入if塊導致new 了兩次對象。
於是在某一次大會上,有磚家發布了下面這種叫做DCL(double check lock)的錯誤寫法,因為是磚家發布的,因此這種錯誤寫法在網上廣為流傳,我在公司也看到有人這么寫,這種我們可以稱為懶漢模式V2.0
package common; public class Singleton { private static Singleton singleton; public static Singleton getInstance(){ if (singleton==null) { synchronized (Singleton.class) { if (singleton==null) { singleton=new Singleton(); } } } return singleton; } }
懶漢模式V2.0解決了1.0中可能會new兩次對象的問題,但是依然有問題。
這里我們先引入一個概念——指令重排序:編譯器或處理器為了優化程序性能而采取的對指令進行重新排序執行的一種手段。
比如:
int a=1;
int b=a+1;
int c=2;
在執行這三句代碼的時候,在編譯器和處理器對程序進行優化之后,可以先執行int c=2,再執行另外兩句,這就是指令重排序。
但是很顯然,指令重排序並不是可以隨便亂排的,比如int b=a+1這句依賴了a的值,因此必須要在int a=1之后執行才能保證最終b的值是正確的。因此,指令重排序后,要保證在單個線程里,執行結果和重排序前是等效的。
這里為什么強調是單個線程呢?比如剛剛的例子,假如abc都是全局變量,我們把c=2這一句重排序到第一句,從執行這三句代碼的線程的角度,執行完三句代碼后abc的值和重排序之前是一致的。
但是假設現在有另外一個線程在不停的打印abc的值,那么因為重排序的關系,在打印結果里就會出現c=2而ab還沒有被賦值的結果。因此,在指令重排序后,從重排序的這個線程自身來看,重排序后的代碼可以看作是有序的(因為保證運行結果不變),而從其他線程的角度來看,重排序后的代碼是亂序執行的。
回到我們的懶漢模式V2.0,我們現在知道了,當多線程並發的時候,假如第一個線程成功獲取鎖並進入if塊執行singleton=new Singleton(),
這句代碼我們可以看成三步操作:
- 在堆內存中划分一個Singleton對象實體的空間
- 初始化堆內存中對象實例的數據(字段等)
- 將singleton變量通過指針指向生成的對象實體
這個時候因為指令重排序,可能在步驟2還沒有執行完的時候,步驟3已經執行完了,
這時候singleton變量已經不為null,此時如果有並發的線程執行getInstance()方法,將獲取到一個沒有初始化完成的Singleton對象從而引發錯誤。
為了解決這個問題,我們給singleton變量添加關鍵字volatile得到懶漢模式V3.0:
package common; public class Singleton { private static volatile Singleton singleton; public static Singleton getInstance(){ if (singleton==null) { synchronized (Singleton.class) { if (singleton==null) { singleton=new Singleton(); } } } return singleton; } }
這里用volatile修飾singleton並不是用了volatile的可見性,而是用了java內存模型的“先行發生”(happens-before)原則的其中一條:
Volatile變量規則:對一個volatile變量的寫操作先行發生於后面對這個變量的讀操作,這里的“后面”指時間上的先后順序。
這樣一來就能禁止指令重排序,確保singleton對象是在初始化完成后才能被讀到。
懶漢模式V3.0可以說是懶漢模式的終極形式,經過2次修改終於線程安全了,然而並沒有什么卵用,因為餓漢模式先天就沒有線程安全問題,而且也並不像網上說的那樣,上來就要創建實例。
餓漢模式解析:
網上一般的說法是,餓漢模式會導致程序啟動慢,因為一上來就要創建實例。相信這么說的人一定是不了解java的類加載機制。先上個餓漢模式的代碼:
package common; public class Singleton { private static final Singleton singleton=new Singleton(); public static Singleton getInstance(){ return singleton; } }
可以看到new實例是直接寫在了靜態變量后面,還有一種寫法:
package common; public class Singleton { private static final Singleton singleton; static{ singleton=new Singleton(); } public static Singleton getInstance(){ return singleton; } }
這兩種寫法在編譯后是完全等效的,
類的加載分為5個步驟:加載、驗證、准備、解析、初始化
初始化就是執行編譯后的<cinit>()方法,而<cinit>()方法就是在編譯時將靜態變量賦值和靜態塊合並到一起生成的。
所以說,“餓漢模式”的創建對象是在類加載的初始化階段進行的,那么類加載的初始化階段在什么時候進行呢?jvm規范規定有且只有以下7種情況下會進行類加載的初始化階段:
- 使用new關鍵字實例化對象的時候
- 設置或讀取一個類的靜態字段(被final修飾,已在編譯器把結果放入常量池的靜態字段除外)的時候
- 調用一個類的靜態方法的時候
- 使用java.lang.reflect包的方法對類進行反射調用的時候
- 初始化一個類的子類(會首先初始化父類)
- 當虛擬機啟動的時候,初始化包含main方法的主類
- 當使用jdk1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。
基本來說就是只有當你以某種方式調用了這個類的時候,它才會進行初始化,而不是說jvm啟動的時候就初始化,所以說假如你的單例類里只有一個getInstance()方法,那基本上就是當你從其他類調用getInstance()方法的時候才會進行初始化,這事實上和“懶漢模式”是一樣的效果。而jvm本身會確保類的初始化只執行一次。
當然,也有一種可能就是單例類里除了getInstance()方法還有一些其他靜態方法,這樣當調用其他靜態方法的時候,也會初始化實例,但是這個很容易解決,只要加個內部類就行了(這種模式叫holder pattern):
package common; public class Singleton { private static class SingletonHolder{ private static Singleton instance=new Singleton(); } public static Singleton getInstance(){ return SingletonHolder.instance; } }
這樣只有當調用getInstance()方法的時候,才會初始化內部類SingletonHolder。
總結
經過以上分析,“懶漢模式”實現復雜而且沒有任何獨占優點,“餓漢模式”完勝。