1. 程式人生 > >那些年,我們一起寫過的單例模式

那些年,我們一起寫過的單例模式

題記

度娘上對設計模式(Design pattern)的定義是:“一套被反覆使用、多數人知曉的、經過分類編目的、程式碼設計經驗的總結。”它由著名的“四人幫”,又稱 GOF (即 Gang of Four),在《設計模式》(《Design Patterns: Elements of Reusable Object-Oriented Software》)一書中提升到理論高度,並將之規範化。在我看來,設計模式是前人對一些有共性的問題的優秀解決方案的經驗總結,一個設計模式針對一類不斷重複發生的問題給出了可複用的、經過了時間考驗的較完善的解決方案。使用設計模式可以提高程式碼的可重用性、可靠性,從而大大提高開發效率,值得我們細細研究。

在這裡,我想結合我們的 Android 專案,談談大家在其中使用到的一些設計模式。一則,就個人的學習經驗看來,研究例子是最容易學會設計模式的方式;二則,其實設計模式的應用同所使用的程式語言和環境都是有關係的,譬如說,我們最先要討論的單例模式,在 Java 中實現的時候就要特別注意不同 JDK 版本對該模式造成的影響。所以會特意針對我們所關注的 Android 專案進行一些分析。希望通過理論與實踐相結合的方式,深入學習設計模式,並自然而然地合理運用到將來,從而完美解決更多問題。

引言

單例模式(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)、網路請求等。

  • 某種型別的物件應該有且只有一個。如果製造出多個這樣的例項,可能導致:程式行為異常、資源使用過量、結果不一致等問題。如果多人能同時操作一個檔案,又不進行版本管理,必然會有的修改被覆蓋,所以:
    一個系統只能有:一個視窗管理器或檔案系統,計時工具或 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=ba.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. 感覺單例的使用也有點多了,需要檢查一下是否有濫用)。

  1. 這種方法其實是 3.2 中討論的單例的繼承的情況,為了提高可擴充套件性,父類的建構函式不再是私有的,導致單例的“唯一性”遭到了破壞。工程的任意處,我呼叫如下程式碼,即可以再得到一個 LoginManager:

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

  2. 程式碼的 owner 用了 privatefinalstatic 等關鍵詞,可能是希望能確保單例的唯一性(前面已經證明這一目的並未達到),但是它們使得這些單例類在 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中的應用場景和實際使用遇到的問題,05/2016
[12] liuluo129. 單例模式以及通過反射和序列化破解單例模式,09/2013

騰訊 Bugly是一款專為移動開發者打造的質量監控工具,幫助開發者快速,便捷的定位線上應用崩潰的情況以及解決方案。智慧合併功能幫助開發同學把每天上報的數千條 Crash 根據根因合併分類,每日日報會列出影響使用者數最多的崩潰,精準定位功能幫助開發同學定位到出問題的程式碼行,實時上報可以在釋出後快速的瞭解應用的質量情況,適配最新的 iOS, Android 官方作業系統,鵝廠的工程師都在使用,快來加入我們吧!