一個單例還能寫出花來嗎?
單例可以說是最簡單的一個設計模式了,單例模式要求只能創建一個對象實例。通常的寫法是聲明私有的構造函數,提供靜態方法獲取單例的對象實例。
常見的單例寫法就是餓漢式、懶漢式、雙重加鎖驗證、靜態內部類和枚舉的方式,寫法可能大家都知道,不過針對不同的寫法還是有可以繼續深挖一下的地方,讓我們從最簡單的幾種寫法開始回顧單例,不想看前面的話直接往後翻好了。
回顧幾種實現方式
餓漢式
餓漢式的寫法通常靜態成員變量已經是初始化好的,優點是可以不加鎖就獲取到對象實例,線程安全,主要的缺點在於不是延加載,稍微存在內存的浪費,因爲如果初始化的邏輯較爲複雜,比如存在網絡請求或者一些複雜的邏輯在內,就會產生內存的浪費。
懶漢式
懶漢式的寫法解決了餓漢式浪費內存的問題,在真正需要獲取實例對象的纔去執行初始化。
通常一般來說可能會有兩種方式,第一種就是不加鎖的寫法,很顯然這樣是肯定不行的,正常的方式一般都是通過同步鎖的方式加鎖獲取實例對象。
但是這種實現方式在之前的 JDK 版本synchronized
沒有鎖優化的情況每次獲取單例對象性能存在很大的問題,於是乎有了 DCL 的寫法。
雙重加鎖驗證 DCL
於是爲了解決懶漢式性能的問題,雙重加鎖驗證的寫法誕生了,先判斷一次空,真的爲空再執行加鎖,然後再判斷一次。
這樣的話,只有在實例對象是空的情況纔會去加鎖創建對象,性能問題得到了一定程度上的解決,也不會和餓漢一樣有內存浪費的問題。
但是,這個寫法也存在問題,就是會拿到未初始化完全的對象,我之前的一篇文章中也提到這個方式的問題,具體請看一次羣聊引發的血案。
讓我這裏複用一下我寫過的東西。
從 CPU 的角度來看,instance = new Instance() 可以分爲分爲幾個步驟:
-
分配對象內存空間
-
執行構造方法,對象初始化
-
instance 指向分配的內存地址
實際上,由於指令重排的問題,2、3 的步驟可能會發生重排序,那麼問題就發生了。
instance 先被指向內存地址,然後再執行初始化,如果此時另外一個線程來訪問 getInstance 方法,就會拿到 instance 不是 null,最後拿到的將是一個沒有被完全初始化的對象!
現在也有很多人說這個問題在高版本的 JDK 中已經解決了,但是我是沒發現有什麼直接證據,如果你知道,請你告訴我。
靜態內部類
這個通過 JVM 來保證創建單例對象的線程安全和唯一性,是比較好的辦法。
Singleton
類加載的時候,SingletonHolder
不會加載,只有在調用getInstance
方法的時候纔會執行初始化,這樣既起到了懶加載的作用,同時又使用到了 JVM 類加載機制,保證了單例對象初始化的線程安全。
這種方式也是目前比較推薦的一種方式。
枚舉
通過枚舉來實現單例是 Effective Java 作者 Josh Bloch 提倡的方式,也是單例模式的最佳實現方式。
爲了看清楚枚舉怎麼實現單例模式的,我們來編譯一下枚舉生成的最終字節碼。
執行javac Singleton.java
生成class
文件,接着執行javap -p Singleton.class
,得到如下內容:
爲了看到更詳細的內容,我們執行 javap -c Singleton
。
通過最終生成的字節碼,我們其實發現本質上枚舉的初始化通過static
代碼塊來進行初始化。
考慮下類加載的幾個步驟,加載 -> 驗證 -> 準備 -> 解析 -> 初始化,最終初始化就是執行static
代碼塊,而static
代碼塊是絕對線程安全的,只能由 JVM 來調度,這樣保證了線程安全。
枚舉的實現方式好處還不止於此,除了一目瞭然的實現簡單之外,還能防止其他幾種實現方式避免不了的幾個問題。
再說幾種方式的問題
反射破壞單例
除了枚舉之外,其他的幾種方式都可以通過反射的方式達到破壞單例的目的,就隨便以一個實現方式來舉例,這裏最終的輸出結果是false
。
如果拿去嘗試反射創建枚舉對象的話,則是會報錯,可以自己動手嘗試一下。
爲什麼會報錯,可以直接看一下newInstance
的源碼,有一段特殊的關於枚舉類型的判斷,下圖中我紅色標記的部分。
序列化
除了衆所周知的使用反射來破壞單例之外,還有另外一種能破壞單例的方式就是序列化。
對上面的餓漢方法實現序列化,然後得到的結果是false
,序列化前後對象發生了改變。
其實關鍵的部分在於ois.readObject
方法,一路跟蹤最後找到一段代碼如下:
所以很明顯我們發現了最終實際上這裏通過反射創建了一個新的對象,isInstantiable
實際代表的應該是類或者屬性是序列化的,那麼久就返回 true,我們這裏肯定是 true,所以最終產生了一個新的對象。
枚舉爲啥可以防止這個問題?枚舉的實現方式不太一樣而已,同樣跟蹤到枚舉部分的實現邏輯。
下圖中紅框標註的部分就是枚舉類型去實現反序列化的邏輯,最終只是通過valueOf
方法查找枚舉,不存在新建一個對象的邏輯。
那麼,怎麼防止其他方式序列化對單例的破壞?再往下看看源碼,紅框標註的意思只要有readResolve
方法就可以解決問題了。
實際上,最終解決方案也很簡單,單例類加上方法即可。
好了,打完收工。現在是北京時間 4 月 15 日凌晨 1 點整,困了,睡覺。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/0n4TKGbK2UrKarutOxFd7g