單例分享(內存減少與循環引用解決)

<分享>單例

HD中由單例引起的Bug,尤其是崩潰Bug已經不止一次了,就在週末我還在解單例相關的Bug

單例這個問題讓我想起了王健上週講座上的一句話“我們遇到的大多數問題已經被大牛遇到過,總結過,並已經有成熟的解決問題的模式可供參考”。單例這個普通的不能再普通的設計模式,也不是例外。

我的分享很弱,其實就是引用大牛的總結並分享自己的一些心得,希望可以對單例問題的徹底解決有所幫助,雖然以下內容大多是從c++語言的角度介紹的,但其核心思想我覺得對於不同語言是通用的。

 

簡單介紹下我所引用主要的資料(忽略國內很多博客對單例各種遍地生根開花的討論和介紹,我們需要的是可以能夠促進深入理解和可以啓迪解決方案的東西):

C++奇才 Andrei Alexandrescu 及其大作《Modern C++ Design》的第六章,Andrei 同時也是loki庫作者和facebook folly庫的核心開發人員。《Modern C++ Design》中文版爲《C++設計新思維——泛型編程與設計模式之應用》,但因書籍畢竟是多年之前的作品,所以最好的參考資料是loki庫源碼,loki庫的源碼也是對書中所講到的各種設計模式的對等實現。

 

關於單例是什麼和簡單的單例相關的模式(單例,多例等)不再贅述,進入正題。

先介紹Andrei對單例的總結,主要表現在兩點:

1. 單例的生命週期管理(創建與銷燬)

2. 單例遇到多線程

單例遇到的多數問題都歸於這兩點,處理好這兩點涉及的所有問題,單例出問題的概率就可以降低到很低(至少完全可控) 

Andrei也對這兩點提出了他的解決方案:

1. 單例的生命週期管理

單例的生命期管理,是單例相關的最複雜,涉及點最多,也是和實際應用相關性最緊密的部分,而其根本原因在於單例之間的依賴關係,降低依賴並理清依賴是解決這個問題的關鍵,因爲在此基礎上我們才能決定:

A)單例是需要手動銷燬還是交給系統銷燬

B)單例的銷燬順序

C)單例是否可以死後重生(鳳凰涅磐)。

3. 單例遇到多線程

多線程是我們常常會遇到的,多個線程訪問同一個單例難免會出現,C++中常用的DoubleCheck機制只能減低問題發生的概率,要解決這個問題,必須瞭解平臺相關的併發元素(內存屏障等)。

接下來我開始對以上相關問題及解決方式詳細的解讀:

單例的管理往往跟實際情況是緊密相關的,不可能用一種方式解決所有單例存在的問題,必須分而治之。比如某些單例其生命週期與整個系統都無關,其構造順序和銷燬順序就可以交給系統。而對於其生命週期有依賴性的,就必須管理其創建及銷燬時機,控制其創建及銷燬順序。

 空談的理論沒有任何看頭,還是看代碼,如下單例實現;

Singleton& getInstance()

{

    static Singleton instance;

return instance;

}

 爲Scott meyers 所作,也叫meyer單例,靜態全局實例變量實現與其等同。其特點是單例在第一次被使用時才創建,創建於銷燬時機都交給編譯器決定。顯然,它適用於那些生命期與整個系統無關的單例,只要單例與其他單例之間有生存期依賴關係,這種方式就是不合適的,因爲我們無法確定到底是哪個先銷燬的,也不確定單例被銷燬後是否還會被使用。

再看下另一種實作,也是等同於HD c++部分大多單例的實現方式,靜態全局指針或靜態成員指針的方式實現:

 Singleton* getInstance()

{

    If(! m_instance) { m_instance = new Singleton(); }

return  m_instance;

}

void destroy(){ delete m_instance ; }

這種方式同樣是按需創建,退出時銷燬,如果管理好銷燬依賴,它在實現上沒有大的問題,但會產生另一個問題:

每一個單例都是new出來的,而通常libc對內存分配的實現都是基於內存池的分配和回收,

但是我們知道所new出來的內存一定不止一個單例對象的大小,具體由libc的內存池及內存分配策略實現決定,但是毫無疑問,每個單例對象都hold住了一塊堆內存,而如果一個系統中的單例很多,就會出現大量內存消耗,而這種消耗是貫穿整個程序的生命週期的(關於具體佔用多少內存,之前作健對android上針對u3相關代碼有一份研究報告,可查閱舊郵件)。其解決方案也早已成熟,將單例佔用的內存開闢在靜態存儲區上,並使用placement new/delete 構造和析構,或者編譯器通過靜態計算獲取所有單例及單例所牽連各種成員佔用空間的總大小,然後只開闢一塊堆內存,使用placement new/delete將所有對象的構造和析構在這一塊內存上處理。

 

關於一個最重要的話題,單例的依賴關係如何徹底解決,一個良好的方式是理出系統中所有的單例及其依賴關係圖,通過圖我們可以清晰的看出單例之間的依賴關係,當一個新的單例被加入後,我們就把這個新的單例加入整個圖,並根據其特點決定其生命週期管理策略。

如下圖,可以清晰的看出每個單例的依賴關係,也不難看出S1S2, S3出現了循環依賴,需要重新思考其設計,實現或者採取某些措施保證這種依賴的安全性;當增加一個單例S7後,我們也可以清楚的知道它對整個單例的系統的影響。【最重要的是可以明確的保證所有的單例都在我們的控制之下,而現在我們常說的往往是“這樣應該就沒問題了”, “這樣可能就沒問題了”】


 

關於單例的多線程語義,大多是多線程創建與銷燬的,只要注意同步就沒什麼問題了,但如果想在效率上進一步提高,需要藉助平臺相關的同步機制,這些跟平臺相關的輕量級同步機制多是用內嵌彙編的方式實現(對於這些雖然我看過一些實現源碼,但是仍舊停留在參考代碼上)。

 

而我自己也有自己的一些想法和問題,分享給大家,看能不能激起什麼漣漪或者波濤來:

1.  是不是需要多處訪問的地方就需要建模或實現爲單例(HD目前代碼實現目前存在很多這樣的例子),是不是應該更謹慎的考慮怎樣的功能或者模塊才應該以單例實現,一組靜態接口或C接口就可以解決的是否需要或者是否被習慣性的寫成了單例? 是否我們把本該是一個單例的功能弄成了一組單例,我們是不是有更優的解決方案.

2.  單例不僅是c中全局變量的昇華,它往往代表着一個獨立的功能,並在整個系統中提供統一的訪問接口,這樣的話,在遇到多線程的情景下,是否可以考慮將其訪問控制限制在一個線程之內,其他線程對它的訪問也都交到管理線程去?

 

附註:

單例一般具有單一或有限實例語義,而現實的實現體往往又附加其按序創建語義,在C++代碼實現中,我們可能需要有一些在語法角度需要注意的,補充下:

1. 如果需要唯一實例,需要私有化拷貝構造函數和賦值運算符(c++11可直接指定=delete),同時定義構造函數(當你手動實現了拷貝構造函數後,編譯器是不會幫你生成構造函數的)。

2. 如果要保證實例不在堆上構建,需要私有化new 操作符, new 操作符的調用先於對象內存分配及構造,其本身帶有靜態函數屬性,但是可不顯式指定。

3. 如果要保證實例不在棧上構建,需要私有化析構函數。

 

希望大家可以集思廣益,有分享,有學習,熱烈歡迎各位蒞臨指導。

 

 

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