單例模式我相信大家應該不會陌生,隨手抓一個程式設計師,讓他說說最常用的 3 種設計模式,其中一定包含單例模式。
單例最重要的是,關注唯一性以及執行緒安全問題。而在 Java 中,單例存在多種實現正規化,例如:餓漢式、懶漢式、靜態內部類、雙重檢測等等,甚至還可以利用列舉的特性實現單例,可謂是把單例玩出了花樣。
這其中,餓漢式單例實現程式碼是最簡單的,關鍵程式碼只需一行 static final 申明物件即可,程式碼簡單且滿足需求。
但是餓漢式經常會被我們"嫌棄",日常 Review Code 時,甚至看到餓漢式單例也會「友善的建議」對方使用雙重檢測。
餓漢式依賴 JVM 載入類的時機,來完成靜態物件的初始化,這個過程本身就是執行緒安全的。而它最被人詬病的,其實是無法延遲載入,完全依賴 JVM 載入類的時機,這就導致單例類載入時機不可控。也就有可能,有些資源,業務還未使用,單例類就已經準備好了,導致過多的佔用了系統資源。
我們再回過頭來看看 Kotlin。在 Kotlin 中,實現單例非常簡單,只需要將關鍵字 class 替換為 object 即可。
object SomeSingleton{ fun sayHi(){}}
但 Kotlin 的 object 其實就是餓漢式單例。它難道不怕存在資源佔用的問題嗎?
二、Kotliin 的 object2.1 Kotlin 的 object 原理在開始 Kotlin 的 object 選擇餓漢式單例前,我們先來看看 Kotlin object 原理。
Kotlin 和 Java 可以互相呼叫,Kotlin 程式碼執行前也會被編譯器編譯成 Java 位元組碼。那我們就可以通過工具將其還原為 Java 程式碼進行分析。
這個轉換工具, AS 原生支援。藉助 AS 的 Tools → Kotlin → Show Kotlin Bytecode,就可以檢視 Kotlin 編譯後的 Java 位元組碼,再點選 Decompile 按鈕,就可以將位元組碼轉成 Java 程式碼。
可以看到,INSTANCE 使用 static final 宣告,並且在 static 程式碼塊內對其進行初始化,標準的餓漢式單例。
2.2 餓漢式如何保證唯一和執行緒安全?前面提到,單例最重要的就是關注其唯一性和執行緒問題。
需要在任何情況下,都確保一個類只存在一個例項,不會因為多執行緒的訪問,導致建立多個例項。同時也不會因為多執行緒而引入新的效率問題。
餓漢式單例的原理,其實是基於 JVM 的類載入機制來保證其符合單例的規範的。
簡單來說,JVM 在載入類的時候,會經過初始化階段(即 Class 被載入後,且被執行緒使用前)。在初始化期間,JVM 會獲取一把鎖,這個鎖可以同步多個執行緒,對一個類的初始化,確保只有一個執行緒完成類的載入過程。這個步驟是執行緒安全的。
上圖很清晰的描述了類的初始化鎖工作流程,這裡就不展開細說。
三、所謂的餓漢式問題前文提到,餓漢式單例最被人詬病的問題,在於無法實現懶載入,完全依賴虛擬機器載入類的策略載入。
3.1 懶載入懶載入的目的,說白了就是為了避免,無必要的資源浪費,在不需要的時候不載入,等什麼時候業務真的需要使用到它的時候,再載入資源。
雖然餓漢式依賴虛擬機器載入類的策略,但虛擬機器本身也會有優化項,那就是「按需載入」的策略。
虛擬機器在執行程式時,並不時在啟動時,就將所有的類都載入並初始化完成,而是採用「按需載入」的策略,在真正使用時,才會進行初始化。
例如 顯式的 new Class()、呼叫類的靜態方法、反射、Class.forName() 等,這些事件首次發生時,都會觸發虛擬機器載入類。
例如前文中,SomeSingleton 這個單例類,我們放到一個 App 中執行一下,App 先啟動,點選按鈕執行 SomeSingleton.sayHi() 方法。
15:39:34.539 I/cxmyDev: App running15:39:44.606 I/cxmyDev: SomeSingleton init15:39:44.606 I/cxmyDev: SomeSingleton sayHi
注意 Log 的時間,只有點選按鈕執行 SomeSingleton.sayHi() 時,該單例類才被虛擬機器載入。
也就是說,通常只有在你真實使用這個類時,它才會真的被虛擬機器初始化,我們並不需要擔心會被提前載入而導致資源浪費。
當然,不同虛擬機器的實現方式不同,這並不是強制的,但是大多數為了效能都會準守此規則。
3.2 軟體設計的角度既然餓漢式的單例,也是在首次使用時初始化,這自然就是一種類懶載入的效果。
那我們再換個角度思考,如果餓漢式單例就是在程式啟動時,就初始化好了,有問題嗎?
在 Java 中,其實構造一個普通物件的成本很低。那為什麼到了單例模式下,就覺得是個問題呢?
主要是單例的生命週期較長,承載了業務和狀態,我們不提前構造無非是 2 個問題。
單例物件本身,初始化比較複雜或耗時,提前初始化會影響其他業務;單例初始化後,持有的資源太多,導致記憶體資源的浪費;問題一:初始化邏輯複雜
如果單例在初始化階段,存在大量的邏輯,那麼也不應該等到需要使用時才初始化它,否者必然會影響到接下來的業務效能。而是應該在此之前,系統較為空閒時初始化。
例如 Android 下就可以藉助 IdleHandler 在空閒時提前做一些初始化工作。
問題二:持有資源太多
系統的各項資源,從來就沒有夠的時候。
任何時候快取和效能都是要平衡的,單例作為一個生命週期較長的類,更不應該長時間持有大量的資源。否者就算載入時不報錯,也必然會埋下 OOM 隱患,是之後記憶體優化時,重點關注的物件。
在編寫程式碼時,就思考對記憶體資源的合理利用,而不是等到記憶體問題嚴重時,再集中進行記憶體優化。合理使用弱引用優化持有資源,也是一種不錯的優化手段。
另外如果初始化時,就是必須會佔用一些資源,那麼基於 Fail-fast 原則,有問題也應該儘早的暴露出來。
畢竟 App 崩潰在開發手裡,這叫問題,而崩潰在使用者手裡,這就叫事故。
四、小結時刻今天我們聊了 Java 的單例,以及 Kotlin object 單例的實現原理,最後我們再小結一下。
Kotlin object 使用「餓漢式」單例,依賴 JVM 的類載入機制確保唯一和執行緒安全;JVM 載入類採用「按需載入」策略,確保懶載入;Kotlin 的 object 選擇餓漢式單例,在效能和實現上都不存在問題,使用它無需顧慮。