Java 單例模式

原文鏈接:https://zhuanlan.zhihu.com/p/25733866

最常用最安全的兩種方法:

它們共同的特點是:懶加載、線程安全、效率較高。

1.雙重檢查鎖定”(Double Check Lock(DCL))方式:

2.“靜態內部類”方式:

 

參考文章:
 

題記

度娘上對設計模式(Design pattern)的定義是:“一套被反覆使用、多數人知曉的、經過分類編目的、代碼設計經驗的總結。”它由著名的“四人幫”,又稱 GOF (即 Gang of Four),在《設計模式》(《Design Patterns: Elements of Reusable Object-Oriented Software》)一書中提升到理論高度,並將之規範化。在我看來,設計模式是前人對一些有共性的問題的優秀解決方案的經驗總結,一個設計模式針對一類不斷重複發生的問題給出了可複用的、經過了時間考驗的較完善的解決方案。使用設計模式可以提高代碼的可重用性、可靠性,從而大大提高開發效率,值得我們細細研究。
在這裏,我想結合我們的 Android 項目,談談大家在其中使用到的一些設計模式。一則,就個人的學習經驗看來,研究例子是最容易學會設計模式的方式;二則,其實設計模式的應用同所使用的編程語言和環境都是有關係的,譬如說,我們最先要討論的單例模式,在 Java 中實現的時候就要特別注意不同 JDK 版本對該模式造成的影響。所以會特意針對我們所關注的 Android 項目進行一些分析。希望通過理論與實踐相結合的方式,深入學習設計模式,並自然而然地合理運用到將來,從而完美解決更多問題。

0 引言

單例模式(Singleton Pattern)一般被認爲是最簡單、最易理解的設計模式,也因爲它的簡潔易懂,是項目中最常用、最易被識別出來的模式。既然即使是一個初級的程序員,也會使用單例模式了,爲什麼我們還要在這裏特意地討論它,並且作爲第一個模式來分析呢?事實上在我看來,單例模式是很有“深度”的一個模式,要用好、用對它並不是一件簡單的事。

  1. 首先,單例模式可以有多種實現方法,需要根據情況作出正確的選擇。
    看名字就知道單例模式的目標就是要確保某個類只產生一個實例,要達到這個目的,代碼可以有多種寫法,它們各自有不同的優缺點,我們要綜合考慮多線程、初始化時機、性能優化、java 版本、類加載器個數等各方面因素,才能做到在合適的情況下選出合用的方法。簡單舉例看一下 Android 或 Java 中,幾個應用了單例模式的場景各自所選擇的實現方式:

    isoChronology,LoggingProxy:餓漢模式;
    CalendarAccessControlContext:內部靜態類;
    EventBus:雙重檢查加鎖 DCL;
    LayoutInflater:容器方式管理的單例服務之一,通過靜態語句塊被註冊到 Android 應用的服務中。

  2. 其次,單例模式極易被濫用。基本上知道模式的程序員都聽說過單例模式,但是在不熟悉的情況下,單例模式往往被用在使用它並不能帶來好處的場景下。有很多用了單例的代碼並不真的只需要一個實例,這時使用單例模式就會引入不必要的限制和全局狀態維護困難等缺陷。通常說來,適合使用單例模式的機會也並不會太多,如果你的某個工程中出現了太多單例,你就應該重新審視一下你的設計,詳細確認一下這些場景是否真的都必須要控制實例的個數。

  3. 再者,目前對單例模式也出現了不少爭議,使用時更要上心:
    a. 不少人認爲,單例既負責實例化類並提供全局訪問,又實現了特定的業務邏輯,一定程度上違背了“單一職責原則”,是反模式的。
    b. 單例模式將全局狀態(global state)引入了應用,這是單元測試的大敵。
    譬如說 Java 用戶都耳熟能詳的幾個方法:

    System.currentTimeMillis();
    
    new Date();
    
    Math.random();
    

    它們是 JVM 中非常常用的暗藏全局狀態(global state)的方法,全局狀態會引入狀態不確定性(state indeterminism),導致微妙的副作用,很容易就會破壞了單元測試的有效性。也就是說多次調用上述的這些方法,輸出結果會不相同;同時它們的輸出還同代碼執行的順序有關,對於單元測試來說,這簡直就是噩夢!要防止狀態從一個測試被帶到另一個測試,就不能使用靜態變量,而單例類通常都會持有至少一個靜態變量(唯一的實例),現實中更是靜態變量頻繁出現的類,從而是測試人員最不想看到的一個模式。
    c. 單例導致了類之間的強耦合,擴展性差,違反了面向對象編程的理念。
    單例封裝了自己實例的創建,不適用於繼承和多態,同時創建時一般也不傳入參數等,難以用一個模擬對象來進行測試。這都不是健康的代碼表現形式。

鑑於上述的這些爭議,有部分程序員逐步將單例模式移除出他們的工程,然而這在我看來實在是有點因噎廢食,畢竟比起測試的簡便性,代碼是否健壯易用纔是我們的關注點。很多對單例的批評也是基於因爲不瞭解它誤用所引發的問題,如果能得到正確的使用,單例也可以發揮出很強的作用。每個模式都有它的優缺點和適用範圍,相信大家看過的每一本介紹模式的書籍,都會詳細寫明某個模式適用於哪些場景。我的觀點是,我們要做的是更清楚地瞭解每一個模式,從而決定在當前的應用場景是否需要使用,以及如何更好地使用這個模式。就像《深入淺出設計模式》裏說的:

使用模式最好的方式是:“把模式裝進腦子裏,然後在你的設計和已有的應用中,尋找何處可以使用它們。”

單例模式是經得起時間考驗的模式,只是在錯誤使用的情況下可能爲項目帶來額外的風險,因此在使用單例模式之前,我們一定要明確知道自己在做什麼,也必須搞清楚爲什麼要這麼做。此文就帶大家好好了解一下單例模式,以求在今後的使用中能正確地將它用在利遠大於弊的地方,優化我們的代碼。

1 單例模式簡介

Singleton 模式可以是很簡單的,一般的實現只需要一個類就可以完成,甚至都不需要UML圖就能解釋清楚。在這個唯一的類中,單例模式確保此類僅有一個實例,自行實例化並提供一個訪問它的全局公有靜態方法。

  • 一般在兩種場景下會考慮使用單例(Singleton)模式:
  1. 產生某對象會消耗過多的資源,爲避免頻繁地創建與銷燬對象對資源的浪費。如:

    對數據庫的操作、訪問 IO、線程池(threadpool)、網絡請求等。

  2. 某種類型的對象應該有且只有一個。如果製造出多個這樣的實例,可能導致:程序行爲異常、資源使用過量、結果不一致等問題。如果多人能同時操作一個文件,又不進行版本管理,必然會有的修改被覆蓋,所以:

    一個系統只能有:一個窗口管理器或文件系統,計時工具或 ID(序號)生成器,緩存(cache),處理偏好設置和註冊表(registry)的對象,日誌對象。

  • 單例模式的優點:可以減少系統內存開支,減少系統性能開銷,避免對資源的多重佔用、同時操作。
  • 單例模式的缺點:擴展很困難,容易引發內存泄露,測試困難,一定程度上違背了單一職責原則,進程被殺時可能有狀態不一致問題。

2 單例的各種實現

我們經常看到的單例模式,按加載時機可以分爲:餓漢方式和懶漢方式;按實現的方式,有:雙重檢查加鎖,內部類方式和枚舉方式等等。另外還有一種通過Map容器來管理單例的方式。它們有的效率很高,有的節省內存,有的實現得簡單漂亮,還有的則存在嚴重缺陷,它們大部分使用的時候都有限制條件。下面我們來分析下各種寫法的區別,辨別出哪些是不可行的,哪些是推薦的,最後爲大家篩選出幾個最值得我們適時應用到項目中的實現方式。

因爲下面要討論的單例寫法比較多,篩選過程略長,結論先行:
無論以哪種形式實現單例模式,本質都是使單例類的構造函數對其他類不可見,僅提供獲取唯一一個實例的靜態方法,必須保證這個獲取實例的方法是線程安全的,並防止反序列化、反射、克隆(、多個類加載器、分佈式系統)等多種情況下重新生成新的實例對象。至於選擇哪種實現方式則取決於項目自身情況,如:是否是複雜的高併發環境、JDK 是哪個版本的、對單例對象資源消耗的要求等。

  • 上表中僅列舉那些線程安全的實現方式,永遠不要使用線程不安全的單例!
  • 另有使用容器管理單例的方式,屬於特殊的應用情況,下文單獨討論。

直觀一點,再上一張圖:

  • 此四種單例實現方式都是線程安全的,是實現單例時不錯的選擇
  • 下文會詳細給出的三種餓漢模式差別不大,一般使用第二種 static factory 方式

下面就來具體談一下各種單例實現方式及適用範圍。

2.1 線程安全

作爲一個單例,我們首先要確保的就是實例的“唯一性”,有很多因素會導致“唯一性”失效,它們包括:多線程、序列化、反射、克隆等,更特殊一點的情況還有:分佈式系統、多個類加載器等等。其中,多線程問題最爲突出。爲了提高應用的工作效率,現如今我們的工程中基本上都會用到多線程;目前使用單線程能輕鬆完成的任務,日復一日,隨着業務邏輯的複雜化、用戶數量的遞增,也有可能要被升級爲多線程處理。所以任何在多線程下不能保證單個實例的單例模式,我都認爲應該立即被棄用。

在只考慮一個類加載器的情況下,“餓漢方式”實現的單例(在系統運行起來裝載類的時候就進行初始化實例的操作,由 JVM 虛擬機來保證一個類的初始化方法在多線程環境中被正確加鎖和同步,所以)是線程安全的,而“懶漢”方式則需要注意了,先來看一種最簡單的“懶漢方式”的單例:

這種寫法只能在單線程下使用。如果是多線程,可能發生一個線程通過並進入了 if (singleton == null) 判斷語句塊,但還未來得及創建新的實例時,另一個線程也通過了這個判斷語句,兩個線程最終都進行了創建,導致多個實例的產生。所以在多線程環境下必須摒棄此方式。

除了多併發的情況,實現單例模式時另一個重要的考量因素是效率。前述的“懶漢方式”的多線程問題可以通過加上 synchronized 修飾符解決,但考慮到性能,一定不要簡單粗暴地將其添加在如下位置:

上述方式通過爲 getInstence() 方法增加 synchronized 關鍵字,迫使每個線程在進入這個方法前,要先等候別的線程離開該方法,即不會有兩個線程可以同時進入此方法執行 new Singleton(),從而保證了單例的有效。但它的致命缺陷是效率太低了,每個線程每次執行 getInstance() 方法獲取類的實例時,都會進行同步。而事實上實例創建完成後,同步就變爲不必要的開銷了,這樣做在高併發下必然會拖垮性能。所以此方法雖然可行但也不推薦。那我們將同步方法改爲同步代碼塊是不是就能減少同步對性能的影響了呢:

但是這種同步卻並不能做到線程安全,同最初的懶漢模式一個道理,它可能產生多個實例,所以亦不可行。我們必須再增加一個單例不爲空的判斷來確保線程安全,也就是所謂的“雙重檢查鎖定”(Double Check Lock(DCL))方式:

此方法的“Double-Check”體現在進行了兩次 if (singleton == null) 的檢查,這樣既同步代碼塊保證了線程安全,同時實例化的代碼也只會執行一次,實例化後同步操作不會再被執行,從而效率提升很多(詳細比較見附錄 1)。

雙重檢查鎖定(DCL)方式也是延遲加載的,它唯一的問題是,由於 Java 編譯器允許處理器亂序執行,在 JDK 版本小於 1.5 時會有 DCL 失效的問題(原因解釋詳見附錄 2)。當然,現在大家使用的 JDK 普遍都已超過 1.4,只要在定義單例時加上 1.5 及以上版本具體化了的 volatile 關鍵字,即可保證執行的順序,從而使單例起效。所以 DCL 方式是推薦的一種方式。

  • Android 中鼎鼎大名的 Universal Image LoaderEventBus 都是採用了這種方式的單例,下面節選的源碼片段就是從它們的 GitHub 工程內拷貝過來的:

  • EventBus 是一個事件發佈和訂閱的框架,各個組件向全局唯一的一個 EventBus 對象註冊自己,就能發佈和接收到 event 事件。

  • 我們項目中用到的 DCL 方式實例分析:

    • VersionManager:
      版本控制類,主要用於應用啓動時判斷當前屬於:新安裝、更新、沒有改變三種情況中的哪一種,從而決定是否要檢查更新、顯示引導頁、拉取素材等等。這個類在應用啓動時就使用,貌似使用急切加載更合適,但是由於它是根據 Preference 中記錄的版本號來實現判斷的,在項目的 PrefsUtils 類初始化完 preference 成員變量以後纔會被使用,所以使用 DCL 方式完全合適。
    • PoiManager:拉取地理位置信息(用於拼圖及 Webview);WtLoginManager:QQ 登錄使用;WeiboManager:新浪微博登錄分享使用;CollageTemplateManager,CollageDataManager,CollageDataObserver:拼圖的模板、數據、天氣地理位置信息等的管理類:這些類都只有在進入了相應模塊或使用某一功能時纔會被用到,所以使用 DCL 方式。它們中幾個持有較多資源的類,甚至還寫了 destroy() 方法,可以在退出功能或使用完成時釋放資源,銷燬單例。以 CollageTemplateManager 類爲例,它載入了模板描述文件、縮略圖等較多的資源,而退出拼圖功能模塊後在其他模塊中都不會再被使用。代碼如下:

我們最後再看一種延遲加載的“靜態內部類”方式:

這種方式利用了 classloder 的機制來保證初始化 instance 時只會有一個。需要注意的是:雖然它的名字中有“靜態”兩字,但它是屬於“懶漢模式”的!!這種方式的 Singleton 類被裝載時,只要 SingletonHolder 類還沒有被主動使用,instance 就不會被初始化。只有在顯式調用 getInstance() 方法時,纔會裝載 SingletonHolder 類,從而實例化對象。

“靜態內部類”方式基本上彌補了 DCL 方式在 JDK 版本低於 1.5 時高併發環境失效的缺陷。《Java併發編程實踐》中也指出 DCL 方式的“優化”是醜陋的,對靜態內部類方式推崇備至。但是可能因爲同大家創建單例時的思考習慣不太一致(根據單例模式的特點,一般首先想到的是通過 instance 判空來確保單例),此方式並不特別常見,然而它是所有懶加載的單例實現中適用範圍最廣、限制最小、最爲推薦的一種。(下述的枚舉方式限制也很少,但是可能更不易理解。)

  • 我們的 Android 項目中也用到了“靜態內部類”方式來實現單例:

SoundController:用於控制拍照時的快門聲音。由於用戶很少會修改拍照快門聲,所以此功能採用了延遲加載,靜態內部類方式簡潔又方便。話說回來,因爲使用頻率低,此處即使是使用同步方法的懶漢模式也沒有什麼問題。

至此,所有的常用懶漢模式都已討論完畢,僅推薦“雙重檢查鎖定”(DCL)方式(符合思考邏輯)和“靜態內部類”方式(任意 JDK 版本可用),它們共同的特點是:懶加載、線程安全、效率較高。

2.2 加載時機

除了高併發下的線程安全,對於單例模式另一個必須要考慮的問題是加載的時機,也就是要在延遲加載和急切加載間做出選擇。之前已經看了懶漢加載的單例實現方法,這裏再給出兩種餓漢加載方式:

這三種方式差別不大,都依賴 JVM 在類裝載時就完成唯一對象的實例化,基於類加載的機制,它們天生就是線程安全的,所以都是可行的,第二種更易於理解也比較常見。

那麼我們到底什麼時候選擇懶加載,什麼時候選擇餓加載呢?

首先,餓漢式的創建方式對使用的場景有限制。如果實例創建時依賴於某個非靜態方法的結果,或者依賴於配置文件等,就不考慮使用餓漢模式了(靜態變量也是同樣的情況)。但是這些情況並不常見,我們主要考慮的還是兩種方法對空間和時間利用率上的差別。

餓漢式因爲在類創建的同時就實例化了靜態對象,其資源已經初始化完成,所以第一次調用時更快,優勢在於速度和反應時間,但是不管此單例會不會被使用,在程序運行期間會一直佔據着一定的內存;而懶漢式是延遲加載的,優點在於資源利用率高,但第一次調用時的初始化工作會導致性能延遲,以後每次獲取實例時也都要先判斷實例是否被初始化,造成些許效率損失。

所以這是一個空間和時間之間的選擇題,如果一個類初始化需要耗費很多時間,或應用程序總是會使用到該單例,那建議使用餓漢模式;如果資源要佔用較多內存,或一個類不一定會被用到,或資源敏感,則可以考慮懶漢模式。

  • 有人戲稱單例爲“內存泄露”,即使一直沒有人使用,它也佔據着內存。所以再重申一遍,在使用單例模式前先考慮清楚是否必須,對於那些不是頻繁創建和銷燬,且創建和銷燬也不會消耗太多資源的情況,不要因爲首先想到的是單例模式就使用了它。
  • 下面我們先看一下項目中用到的餓漢單例的例子:

    • 根據業務邏輯需要在程序一啓動的時候就進行操作的類有:
      SimpleRequest:啓動時拉取相機配置和熱補丁
      HotFixEngine:熱補丁應用類
      CameraAttrs:相機屬性,包括黑名單等
      DeviceInstance:(拍照)設備信息類
      VideoDeviceInstance:視頻設備信息類
      OpDataManager:運營信息管理,包括:廣告頁、首頁 icon、首頁 banner、應用推薦、紅點角標等等
      其中典型的 HotFixEngine 類用於加載 hack dex 包,需要儘早執行,不然會出現一堆 java.lang.ClassNotFoundException 錯誤。最好的執行時機是在 Application 的 attachBaseContext 中(如果工程中引入了 multidex 的,則放在 multidex 之後執行),所以採用了餓漢模式。

    • 也有在整個程序運行過程中從頭至尾都需要用到,最好不要頻繁創建回收的類:
      MemoryManager:所有縮略圖的 cache,大圖、拼圖模板等的管理
      PerformanceLog:性能打點
      DataReport:數據上報

    • 最後是其實不太適合使用餓漢模式,可以修改爲懶漢模式的類:
      LoginManager:登錄管理和 WxLoginManager:微信登錄管理,其實這兩個類是之前同空間的話題圈合作時,工程集成了社區化功能,首頁就需要拉取用戶消息所引入的類。當時採用急切加載是非常合理且符合需求的,但是由於近期將社區化功能弱化以後,只有在用戶反饋時才需要登錄,這兩個類在後續改爲延遲加載會更好。
      SownloadFailDialogue:拉取 banner 後臺協議出錯時彈出對話框。最大問題是,這是出錯時纔會用到的類,很少需要使用,餓漢模式顯然過於“急切”了。
      FaceValueDetector:人臉數值檢測(夫妻相等)和 VideoPreviewFaceOutLineDetector:人臉檢測 & 人臉追蹤,並不一定會使用到,可以考慮修改爲懶漢式。

之前已經舉過 DCL 和靜態內部類實現的單例模式,都沒有問題,不過項目中也發現了一些同步方法的懶漢單例模式,這些類有空的話,最好還是可以修改成前兩種方式:

CameraManager:相機管理類
MaterialDownloadBroadcast:素材下載廣播類

2.3 其他需要注意的對單例模式的破壞

2.3.1 序列化

除了多線程,序列化也可能破壞單例模式一個實例的要求。

序列化一是可以將一個單例的實例對象寫到磁盤,實現數據的持久化;二是實現對象數據的遠程傳輸。當單例對象有必要實現 Serializable 接口時,即使將其構造函數設爲私有,在它反序列化時依然會通過特殊的途徑再創建類的一個新的實例,相當於調用了該類的構造函數有效地獲得了一個新實例!下述代碼就展示了一般情況下行之有效的餓漢式單例,在反序列化情況下不再是單例。

輸出如下:

Is singleton pattern normally valid: true
Is singleton pattern valid for deserialize: false

要避免單例對象在反序列化時重新生成對象,則在 implements Serializable 的同時應該實現 readResolve() 方法,並在其中保證反序列化的時候獲得原來的對象。

:readResolve() 是反序列化操作提供的一個很特別的鉤子函數,它在從流中讀取對象的 readObject(ObjectInputStream) 方法之後被調用,可以讓開發人員控制對象的反序列化。我們在 readResolve() 方法中用原來的 instance 替換掉從流中讀取到的新創建的 instance,就可以避免使用序列化方式破壞了單例。)

在單例中加入上述代碼後,輸出即變爲:

Is singleton pattern normally valid: true
Is singleton pattern valid for deserialize with readResolve(): true

單例有效。

如果想要比較“優雅”地避免上述問題,最好的方式其實是使用枚舉。這種方式也是 Effective Java 作者 Josh Bloch 在 item 3 討論中提倡的方式。枚舉不僅在創建實例的時候默認是線程安全的,而且在反序列化時可以自動防止重新創建新的對象。實現如下:

枚舉類型是有“實例控制”的類,確保了不會同時有兩個實例,即當且僅當 a=b 時 a.equals(b),用戶也可以用 == 操作符來替代 equals(Object) 方法來提高效率。使用枚舉來實現單例還可以不用 getInstance() 方法(當然,如果你想要適應大家的習慣用法,加上 getInstance() 方法也是可以的),直接通過 Singleton.INSTANCE 來拿取實例。枚舉類是在第一次訪問時才被實例化,是懶加載的。它寫法簡單,並板上釘釘地保證了在任何情況(包括反序列化,以及後面會談及的反射、克隆)下都是一個單例。不過由於枚舉是 JDK 1.5 才加入的特性,所以同 DCL 方式一樣,它對 JDK 的版本也有要求。因爲此法在早期 JDK 版本不支持,且和一般單例寫起來的思路不太一樣,還沒有被廣泛使用,使用時也可能會比較生疏。所以在實際工作中,很少看見這種用法,在我們的項目中甚至沒有找到一例應用的實例。

2.3.2 反射

除了多線程、反序列化以外,反射也會對單例造成破壞。反射可以通過 setAccessible(true) 來繞過 private 限制,從而調用到類的私有構造函數創建對象。我們來看下面的代碼:

將會打印:

Is singleton pattern normally valid: true
Is singleton pattern valid for Reflection: false

說明使用反射調利用私有構造器也是可以破壞單例的,要防止此情況發生,可以在私有的構造器中加一個判斷,需要創建的對象不存在就創建;存在則說明是第二次調用,拋出 RuntimeException 提示。修改私有構造函數代碼如下:

這樣一旦程序中出現代碼使用反射方式二次創建單例時,就會打印出:

Is singleton pattern normally valid: true
java.lang.reflect.InvocationTargetException
Caused by: java.lang.RuntimeException: Cannot construct a Singleton more than once!

另外,同反序列化相似,也可以使用枚舉的方式來杜絕反射的破壞。當我們通過反射方式來創建枚舉類型的實例時,會拋出“Exception in thread "main" java.lang.NoSuchMethodException: net.local.singleton.EnumSingleton.<init>()”異常。所以雖然不常見,但是枚舉確實可以作爲實現單例的第一選擇。

2.3.3 克隆

clone() 是 Object 的方法,每一個對象都是 Object 的子類,都有clone() 方法。clone() 方法並不是調用構造函數來創建對象,而是直接拷貝內存區域。因此當我們的單例對象實現了 Cloneable 接口時,儘管其構造函數是私有的,仍可以通過克隆來創建一個新對象,單例模式也相應失效了。即:

輸出爲:

Is singleton pattern normally valid: true
Is singleton pattern valid for clone: false

所以單例模式的類是不可以實現 Cloneable 接口的,這與 Singleton 模式的初衷相違背。那要如何阻止使用 clone() 方法創建單例實例的另一個實例?可以 override 它的 clone() 方法,使其拋出異常。(也許你想問既然知道了某個類是單例且單例不應該實現 Cloneable 接口,那不實現該接口不就可以了嗎?事實上儘管很少見,但有時候單例類可以繼承自其它類,如果其父類實現了 clone() 方法的話,就必須在我們的單例類中複寫 clone() 方法來阻止對單例的破壞。)

輸出:

Is singleton pattern normally valid: true
java.lang.CloneNotSupportedException

P.S. Enum 是沒有 clone() 方法的。

2.4 登記式單例——使用 Map 容器來管理單例模式

在我們的程序中,隨着迭代版本的增加,代碼也越來越複雜,往往會使用到多個處理不同業務的單例,這時我們就可以採用 Map 容器來統一管理這些單例,使用時通過統一的接口來獲取某個單例。在程序的初始,我們將一組單例類型注入到一個統一的管理類中來維護,即將這些實例存放在一個 Map 登記薄中,在使用時則根據 key 來獲取對象對應類型的單例對象。對於已經登記過的實例,從 Map 直接返回實例;對於沒有登記的,則先登記再返回。從而在對用戶隱藏具體實現、降低代碼耦合度的同時,也降低了用戶的使用成本。簡易版代碼實現如下:

Android 的系統核心服務就是以如上形式存在的,以達到減少資源消耗的目的。其中最爲大家所熟知的服務有 LayoutInflater Service,它就是在虛擬機第一次加載 ContextImpl 類時,以單例形式註冊到系統中的一個服務,其它系統級的服務還有:WindowsManagerService、ActivityManagerService 等。JVM 第一次加載調用 ContextImpl 的 registerService() 方法,將這些服務以鍵值對的形式(以 service name 爲鍵,值則是對應的 ServiceFetcher)存儲在一個 HashMap 中,要使用時通過 key 拿到所需的 ServiceFetcher 後,再通過 ServiceFetcher 的 getService() 方法來獲取具體的服務對象。在第一次使用服務時,ServiceFetcher 調用 createService() 方法創建服務對象,並緩存到一個列表中,下次再取時就可以直接從緩存中獲取,無需重複創建對象,從而實現單例的效果。

3 關於單例模式的其他問題(Q & A)

3.1 還有其他情況會使單例模式失效嗎?

是的,其實前文有提到過,上述的所有討論都是基於一個類加載器(class loader)的情況。由於每個類加載器有各自的命名空間,static 關鍵詞的作用範圍也不是整個 JVM,而只到類加載器,也就是說不同的類加載器可以加載同一個類。所以當一個工程下面存在不止一個類加載器時,整個程序中同一個類就可能被加載多次,如果這是個單例類就會產生多個單例並存失效的現象。因此當程序有多個類加載器又需要實現單例模式,就須自行指定類加載器,並要指定同一個類加載器。基於同樣的原因,分佈式系統和集羣系統也都可能出現單例失效的情況,這就需要利用數據庫或者第三方工具等方式來解決失效的問題了。

3.2 單例的構造函數是私有的,那還能不能繼承單例?

單例是不適合被繼承的,要繼承單例就要將構造函數改成公開的或受保護的(僅考慮 Java 中的情況),這就會導致:

1)別的類也可以實例化它了,無法確保實例“獨一無二”,這顯然有違單例的設計理念。
2) 因爲單例的實例是使用的靜態變量,所有的派生類事實上是共享同一個實例變量的,這種情況下要想讓子類們維護正確的狀態,順利工作,基類就不得不實現註冊表(Registry)功能了。

要實現單例模式的代碼非常簡潔,任意現有的類,添加十數行代碼後,就可以改造爲單例模式。也許繼承並不是一個好主意。同時,也應該審視一下單例模式是否在此處被濫用了,在需要繼承和擴展的情況下,一開始就不要使用單例模式,這會爲你省下很多時間。總之,決定一下對你的需求來說,到底是單例更重要還是可繼承更重要。

3.3 單例有沒有違反“單一責任原則”?

單例確實承擔了兩個責任,它不僅僅負責管理自己的實例並提供全局訪問,還要處理應用程序的某個業務邏輯。但是由類來管理自己的實例的方式可以讓整體設計更簡單易懂,單例類自己負責實例的創建也已經是很多程序員耳熟能詳的做法了,何況單例模式的創建只需要屈指可數的幾行代碼,在結構不復雜的情況下,單獨將其移到其它類中並不一定經濟。

當然在代碼繁複的情況下優化你的設計,讓單例類專注於自己的業務責任,將它的實例化以及對對象個數的控制封裝在一個工廠類或生成器中,也是較好的解決方案。除了遵循了“單一責任原則”,這樣做的另一個好處,是可以在創建的時候傳入參數,解耦了類,對對象的創建有了更好的控制,也使使用模擬對象(Mock Object)完成測試目標成爲可能,基本上解決了文章開頭談到的單例是測試不友好的爭議。

3.4 是否可以把一個類的所有方法和變量都定義爲靜態的,把此類直接當作單例來使用?

事實上在最開始討論過的,Java 裏的 java.lang.System 類以及 java.lang.Math 類都是這麼做的,它們的全部方法都用 static 關鍵詞修飾,包裝起來提供類級訪問。可以看到,Math 類把 Java 基本類型值運算的相關方法組織了起來,當我們調用 Math 類的某個類方法時,所要做的都只是數據操作,並不涉及到對象的狀態,對這樣的工具類來說實例化沒有任何意義。所以如果一個類是自給自足的,初始化簡潔,也不需要維護任何狀態,僅僅是需要將一些工具方法集中在一起,並提供給全局使用,那麼確實可以使用靜態類和靜態方法來達到單例的效果。但如果單例需要訪問資源並對象狀態是關注點之一時,則應該使用普通的單例模式。

靜態方法會比一般的單例更快,因爲靜態的綁定是在編譯期就進行的。但是也要注意到,靜態初始化的控制權完全握在 Java 手上,當涉及到很多類時,這麼做可能會引起一些微妙而不易察覺的,和初始化次序有關的bug。除非絕對必要,確保一個對象只有一個實例,會比類只有一個單例更保險。

3.5 考慮技術實現時,如何從單例模式和全局變量中作出選擇?

全局變量雖然使用起來比較簡單,但相對於單例有如下缺點:

1) 全局變量只是提供了對象的全局的靜態引用,但並不能確保只有一個實例;
2) 全局變量是急切實例化的,在程序一開始就創建好對象,對非常耗費資源的對象,或是程序執行過程中一直沒有用到的對象,都會形成浪費;
3) 靜態初始化時可能信息不完全,無法實例化一個對象。即可能需要使用到程序中稍後才計算出來的值才能創建單例;
4) 使用全局變量容易造成命名空間(namespace)污染。

3.6 據說垃圾收集器會將沒有引用的單例清除?

比較早的 Java 版本(JVM ≤ 1.2)的垃圾收集器確實有 bug,會把沒有全局引用的單例當作垃圾清除。假設一個單例被創建並使用以後,它實例裏的一些變量發生了變化。此時引用它的類被銷燬了,除了它本身以外,再沒有類引用它,那麼一小會兒後,它會就被 Java 的垃圾收集器給清除了。這樣再次調用此單例類的 getInstance() 時會重新生成一個單例,使用時會發現之前更新過的實例的變量值都回到了最原始的設置(如網絡連接被重新設置等),一切都混亂了。這個 bug 在 1.2 以後的版本已經被修復,但是如果還在使用 Java 1.3 之前的版本,必須建立單例註冊表,增加全局引用來避免垃圾收集器將單例回收。

3.7 可以用單例對象 Application 來解決組件見傳遞數據的問題嗎?

在 Android 應用啓動後、任意組件被創建前,系統會自動爲應用創建一個 Application 類(或其子類)的對象,且只創建一個。從此它就一直在那裏,直到應用的進程被殺掉。所以雖然 Application 並沒有採用單例模式來實現,但是由於它的生命週期由框架來控制,和整個應用的保持一致,且確保了只有一個,所以可以被看作是一個單例。

一個 Android 應用總有一些信息,譬如說一次耗時計算的結果,需要被用在多個地方。如果將需要傳遞的對象塞到 intent 裏或者存儲到數據庫裏來進行傳遞,存取都要分別寫代碼來實現,還是有點麻煩的。既然 Application(或繼承它的子類)對於 App 中的所有 activity 和 service 都可見,而且隨着 App 啓動,它自始至終都在那裏,就不禁讓我們想到,何不利用 Application 來持有內部變量,從而實現在各組件間傳遞、分享數據呢?這看上去方便又優雅,但卻是完全錯誤的一種做法!!如果你使用瞭如上做法,那你的應用最終要麼會因爲取不到數據發生 NullPointerException 而崩潰,要麼就是取到了錯誤的數據。

我們來看一個具體的例子:

1) 在我們的 App 啓動後的第一個 Activity A 中,會要求用戶輸入需要顯示的字符串,假設爲 “Hello, Singlton!”,然後我們把它作爲全局變量 showString 保存在 Application 中;
2) 然後從 Activity A 中 startActivity() 跳轉到 Activity B,我們從 Application 對象中將 showString 取出來並顯示到屏幕上。目前看起來,一切都很正常。
3) 但是如果我們按了 Home 鍵將 App 退到後臺,那麼在等了較長的時間後,系統可能會因爲內存不夠而回收了我們的應用。(也可以直接手動殺進程。)
4) 此時再打開我們的 App,系統會重新創建一個 Application 對象,並恢復到剛剛離開時的頁面,即跳轉到 Activity B。
5) 當 Activity B 再次運行到向 Application 對象拿取 showString 並顯示時,就會發現現在顯示的不再是“Hello, Singlton!”了,而是空字符串。

這是因爲在我們新建的 Application 對象中,showString並沒有被賦值,所以爲 null。如果我們在顯示前先將字符串全部變爲大寫,showString.toUpperCase(),我們的程序甚至會因此而 crash!!

究其本質,Application 不會永遠駐留在內存裏,隨着進程被殺掉,Application 也被銷燬了,再次使用時,它會被重新創建,它之前保存下來的所有狀態都會被重置。

要預防這個問題,我們不能用 Application 對象來傳遞數據,而是要:

1) 通過傳統的 intent 來顯式傳遞數據(將 Parcelable 或 Serializable 對象放入Intent / Bundle。Parcelable 性能比 Serializable 快一個量級,但是代碼實現要複雜一些)。
2) 重寫 onSaveInstanceState() 以及 onRestoreInstanceState() 方法,確保進程被殺掉時保存了必須的應用狀態,從而在重新打開時可以正確恢復現場。
3) 使用合適的方式將數據保存到數據庫或硬盤。
4) 總是做判空保護和處理。

上述這個問題除了 Application 類存在,App 中的任何一個單例或者公共的靜態變量都存在,這就要求我們寫出健壯的代碼來好好來維護它們的狀態,也要在考慮是否使用單例時慎之又慎。

3.8 在 Android 中使用單例還有哪些需要注意的地方

單例在 Android 中的生命週期等於應用的生命週期,所以要特別小心它持有的對象是否會造成內存泄露。如果將 Activity 等 Context 傳遞給單例又沒有釋放,就會發生內存泄露,所以最好僅傳遞給單例 Application Context。

4 舉一個例子

我們的某個項目中單例的實現略有點特別,它把單例抽象了出來,寫了一個抽象的 Singlton 泛型類:

所有的單例創建都是在繼承了 Application 的 XXXXXApplication 類中,以其中以用於登錄和註冊的單例爲例,首先創建單例,使用時只需要調用 XXXXXApplication.getLoginManager() 就可以拿到實例了:

說實話,當年我咋一看到這個單例實現,覺得那是相當的“高大上”,似乎也很好用:同時用到了抽象類和泛型類,安全性高,靈活性好,通用性強;用全局唯一的 Application 類來統一管理各個單例也貌似再合適不過,但是如果我們仔細分析一下的話,可以發現這種實現方式有不少問題:

  1. 雖然使用泛型感覺是很有彈性的做法,但是事實上所有的單例都繼承了這個類,而父類的 get() 方法用了 final 來修飾,在子類中是不能被重寫的,這就造成了我們應用中的所有單例用的是相同的單例方式,也就是都用了 DCL 方式來實現單例,難以想象一種單例可以適用於整個項目(此項目中的單例類包括:登錄註冊管理類 LoginManager,賬戶管理類 AccountManager,用戶信息業務邏輯類 UserBusiness,主線程 Handler 類 MainHandler,數據上報 Looper 類 ReportLooper,Preference 管理類 PrefManager,WNS 數據透傳管理類 SenderManager, PUSH業務邏輯類 PushBusiness,素材業務邏輯類 MaterailBusiness,搜索業務邏輯類 SearchBusiness,消息業務邏輯類 MessageBusiness 等等,DCL 顯然不適用於所有這些單例。P.S. 感覺單例的使用也有點多了,需要檢查一下是否有濫用)。

  2. 這種方法其實是 3.2 中討論的單例的繼承的情況,爲了提高可擴展性,父類的構造函數不再是私有的,導致單例的“唯一性”遭到了破壞。工程的任意處,我調用如下代碼,即可以再得到一個 LoginManager:

    整個項目中考慮到可擴展性偶一爲之還能接受(不推薦),但所有的單例都不能確保獨一無二就是一個大問題了。

     

  3. 代碼的 owner 用了 private、final 和 static 等關鍵詞,可能是希望能確保單例的唯一性(前面已經證明這一目的並未達到),但是它們使得這些單例類在 XXXXXApplication 類加載的時候,即程序一開始運行時就被實例化了。無論這些單例類有沒有用到,它的實例都存在於內存中了。雖然因爲 DCL 方式實現的單例有延遲加載的優點,這些單例的 instance 會在使用時才創建,但是現在思路混亂地把兩者搭配在一起,不但無法體現兩者的優勢,反而會同時有兩者的限制;

    上面只列舉了幾處明顯問題,顯然這個反面教材是在沒有深刻理解單例的情況下編寫的,從而思路不清,錯漏百出。而這樣的代碼一直存在於我們的項目中,在沒有深入研究單例這個模式前,我也完全沒有看出任何問題,使用得非常歡快:(。我希望大家看了此文,瞭解了單例的方方面面後,除了能正確地使用好單例,也能體會到設計模式是久經時間考驗、多次優化後的經驗總結,在沒有理解透徹前的隨意改動可能會引入意想不到的問題。另外,代碼也不是用到的“高端”技巧越多就是越好的,“高端”往往意味着不常用,不熟悉,不通用,不易理解,所以使用時一定要謹慎!!

5 總結

關於單例模式先講到這裏,其實總結已經在文章前半部分給出了,我也沒有體力重申一遍了:P
由於內容比較多,又是利用平時的零碎時間斷斷續續撰寫此文的,難免會有錯失遺漏,大家有任何想法和建議也請不吝賜教,謝謝!

附錄

重新貼一遍“雙重檢查鎖定(DCL)”方式實現單例模式的代碼,在下面兩個分析中都會涉及:

  1. 粗略比較一下高併發的情況下,同步方法方式同 DCL 方式效率上的差別。在服務器允許的情況下,假設有一百個線程,則耗時結果如下:

    在第一次運行的時候,同步方法方式耗費的時間爲:100 * (同步判斷時間 + if 判斷時間)。以後也保持這樣的消耗不變。
    而 DCL 方式中雖然有兩個 if 判斷,但 100 個線程是可以同時進行第一個 if 判斷的(因爲此時還沒有同步),理論上 100 個線程第一個 if 判斷消耗的總時間只需一次判斷的時間,第二個 if 判斷,在第一次執行時,如果是最壞的情況會有 100 次,加上 100 個同步判斷時間,DCL 方法第一次執行會比同步方法方式多一個判斷時間,即 100 * (同步判斷時間 + if 判斷時間) + 1 * if 判斷時間。但重要的是,這種 DCL 方式只在第一次實例化的時候進行加鎖,之後就不會再通過第一個 if 判斷,也就不用加鎖,不再有同步判斷和第二次 if 判斷的時間損耗,100 個線程也只會有一個 if 判斷時間,效率相比 100 * (同步判斷時間 + if判斷時間) 大大提高。

     

  2. 雙重檢查鎖定(DCL)單例在 JDK 1.5 之前版本失效原因解釋
    在高併發環境,JDK 1.4 及更早版本下,雙重檢查鎖定偶爾會失敗。其根本原因是,Java 中 new 一個對象並不是一個原子操作,編譯時 singleton = new Singleton(); 語句會被轉成多條彙編指令,它們大致做了3件事情:
    1) 給 Singleton 類的實例分配內存空間;
    2) 調用私有的構造函數 Singleton(),初始化成員變量;
    3) 將 singleton 對象指向分配的內存(執行完此操作 singleton 就不是 null 了)
    由於 Java 編譯器允許處理器亂序執行,以及 JDK 1.5 之前的舊的 Java 內存模型(Java Memory Model)中 Cache、寄存器到主內存回寫順序的規定,上面步驟 2) 和 3) 的執行順序是無法確定的,可能是 1) → 2) → 3) 也可能是 1) → 3) → 2) 。如果是後一種情況,在線程 A 執行完步驟 3) 但還沒完成 2) 之前,被切換到線程 B 上,此時線程 B 對 singleton 第1次判空結果爲 false,直接取走了 singleton使用,但是構造函數卻還沒有完成所有的初始化工作,就會出錯,也就是 DCL 失效問題。
    在 JDK 1.5的版本中具體化了 volatile 關鍵字,將其加在對象前就可以保證每次都是從主內存中讀取對象,從而修復了 DCL 失效問題。當然,volatile 或多或少還是會影響到一些性能,但比起得到錯誤的結果,犧牲這點性能還是值得的。

參考資料

[1] 何紅輝,關愛民. Android 源碼設計模式解析與實戰[M]. 北京:人民郵電出版社,2015. 23-42.
[2] Eric Freeman,Elisabeth Freeman,Kathy Sierra,Bert Bates. Head First 設計模式(中文版)[M]. 北京:中國電力出版社,2007. 169-190.
[3] Erich Gamma,Richard Helm,Ralph Johnson,John Vlissides. 設計模式:可複用面向對象軟件的基礎[M].北京:機械工業出版社,2010. 84-90.
[4] Scott Densmore. Why singletons are evil,May 2004
[5] Steve Yegge. Singletons considered stupid, September 2004
[6] Miško Hevery. Clean Code Talks - Global State and Singletons,November 2008
[7] Joshua Bloch. Creating and Destroying Java Objects,May 2008
[8] Javin Paul. Why Enum Singleton are better in Java,July 2012
[9] Philippe Breault. Don’t Store Data in the Application Object,May 2013
[10] IcyFenix. 探索設計模式之六——單例模式,01/2010
[11] Card361401376. 設計模式-單例模式(Singleton)在Android中的應用場景和實際使用遇到的問題在Android中的應用場景和實際使用遇到的問題),05/2016
[12] liuluo129. 單例模式以及通過反射和序列化破解單例模式,09/2013

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章