以我的經驗為例(如有不對歡迎指正),在生產過程中,經常會遇到下面兩種情況:
1.封裝的某個類不包含具有具體業務含義的類成員變量,是對業務動作的封裝,如MVC中的各層(HTTPRequest對象以Threadlocal方式傳遞進來的)。
2.某個類具有全局意義,一旦實例化為對象則對象可被全局使用。如某個類封裝了全球的地理位置信息及獲取某位置信息的方法(不考慮地球爆炸,板塊移動),信息不會變動且可被全局使用。
3.許多時候整個系統只需要擁有一個的全局對象,這樣有利於我們協調系統整體的行為。如用來封裝全局配置信息的類。
上述三種情況下如果每次使用都創建一個新的對象,且使用頻率較高或類對象體積較大時,對象頻繁的創建和GC會造成極大的資源浪費,同時不利於對系統整體行為的協調。此時便需要考慮使用單例模式來達到對象復用的目的。
在看單例模式的實現前我們先來看一下使用單例模式需要注意的四大原則:
1.構造私有。(阻止類被通過常規方法實例化)
2.以靜態方法或者枚舉返回實例。(保證實例的唯一性)
3.確保實例只有一個,尤其是多線程環境。(確保在創建實例時的線程安全)
4.確保反序列化時不會重新構建對象。(在有序列化反序列化的場景下防止單例被莫名破壞,造成未考慮到的后果)
目前單例模式的實現方式有很多種,我們僅討論接受度最為廣泛的DCL方式與靜態內部類方式(本篇討論靜態內部類方式)。
靜態內部類方式
要理解靜態內部類方式,首先要理解類加載機制。
虛擬機把Class文件加載到內存,然后進行校驗,解析和初始化,最終形成java類型,這就是虛擬機的類加載機制。加載,驗證,准備,解析、初始化這5個階段的順序是確定的,類的加載過程,必須按照這種順序開始。這些階段通常是相互交叉和混合進行的。解析階段在某些情況下,可以在初始化階段之后再開始---為了支持java語言的運行時綁定(動態綁定,多態的原理)。
在Java虛擬機規范中,沒有強制約束什么時候要開始加載,但是,卻嚴格規定了幾種情況必須進行初始化(加載,驗證,准備則需要在初始化之前開始):
1. 遇到 new、getstatic、putstatic、或者invokestatic 這4條字節碼指令,如果沒有類沒有進行過初始化,則觸發初始化
2. 使用java.lang.reflect包的方法,進行反射調用的時候,如果沒有初始化,則先觸發初始化
3. 初始化一個類時候,如果發現父類沒有初始化,則先觸發父類的初始化
我們僅說與本期主題相關的初始化階段:
類初始化階段是類加載過程的最后階段。在這個階段,java虛擬機才真正開始執行類定義中的java程序代碼。在編譯的時候,編譯器會自動收集類中的所有靜態變量(類變量)和靜態語句塊(static{}塊)中的語句合並產生的,編譯器收集的順序是根據語句在java代碼中的順序決定的。收集完成之后,會編譯成java類的 static{} 方法,java虛擬機則會保證一個類的static{} 方法在多線程或者單線程環境中正確的執行,並且只執行一次。在執行的過程中,便完成了類變量的初始化。如果我們的java類中,沒有顯式聲明static{}塊,如果類中有靜態變量,編譯器會默認給我們生成一個static{}方法。
對於靜態變量來說,虛擬機會保證在子類的static{}方法執行之前,父類的static{}方法已經執行完畢(即如果父類沒有加載則先加載父類)。由於父類的static{}方法先執行,也就意味着父類的靜態變量要優先於子類的靜態變量賦值操作。
對於實例變量來說,在實例化對象時,JVM會在堆中為對象分配足夠的空間,然后將空間清零(即所有類型賦默認值,引用類型為null)。JVM會收集類中的復制語句放於構造函數中執行,如果沒有顯式聲明構造函數則會默認生成一個構造函數。子類默認生成的構造函數第一行默認為super();即如果父類有無參的構造方法,子類會先調用父類的構造方法再調用本身的構造方法。因為它繼承父類成員的使用,必須先初始化這些成員。如果父類沒有無參的構造方法則子類繼承會報錯,需要子類通過super顯式調用父類的有參構造方法。如果類中顯式定義一個或多個構造方法,則不再生成默認構造方法。
對於靜態變量,上面的描述還不太准確。類初始化階段,JVM保證同一個類的static{}方法只被執行一次,這是靜態內部類單例模式的核心。JVM靠類的全限定類名以及加載它的類加載器來唯一確定一個類。(這個很重要,經常會有這方面的坑!比如反序列化時,被序列化的對象使用java默認的類加載器加載,而使用了反序列化的一方使用的框架(如springBoot就有自己的類加載器)強制使用自己的類加載器加載某個類,則會因為JVM判定不是一個類而報ClassNotFoundException!)
所以修正一下的說法便是,靜態內部類單例模式的核心原理為對於一個類,JVM在僅用一個類加載器加載它時,靜態變量的賦值在全局只會執行一次!
使用靜態內部類的優點是:因為外部類對內部類的引用屬於被動引用,不屬於前面提到的三種必須進行初始化的情況,所以加載類本身並不需要同時加載內部類。在需要實例化該類是才觸發內部類的加載以及本類的實例化,做到了延時加載(懶加載),節約內存。同時因為JVM會保證一個類的<cinit>()方法(初始化方法)執行時的線程安全,從而保證了實例在全局的唯一性。
下面我們來實現一下靜態內部類的單例模式:
/** * @Author Nyr * @Date 2019/11/19 20:48 * @Description 單例模式-靜態內部類方式 */ public class Car2 { private Car2(){} private static class innerCar2{ private static Car2 car2=new Car2(); } public Car2 getCar2(){ return innerCar2.car2; } }
為什么使用內部類而不是直接使用靜態變量,我覺着有兩個原因(求指正,第二條並不是很確定,后續會寫代碼測試):
1. 使用內部類可以延時加載。如果直接使用靜態變量,因為加載子類等其它原因對實例進行了初始化,而此時並不需要該類的實例,造成了資源的浪費。
2. 原類因為帶有業務含義,在使用上會有各種可能,比如使用了特定的類加載器進行加載,這樣就對單例造成了破壞。
說完了優點我們再來說說缺點,那就是內部類的傳參不是很靈活,需要將參數定義為final。當然我們也可以將其寫入final的Object數組或者在內部類定義一個接受參數的init()方法來接收參數,但總的來說傳參確實不方便。
而對上一篇所說的DCL來說,同步塊的使用明顯的降低了效率。兩種方法可以說各有優缺,我們應視實際情況酌情選擇。