引用計數與垃圾收集之比較

本質上來說,引用計數策略和垃圾收集策略都屬於資源的自動化管理。所謂自動化管理,就是在邏輯層不知道資源在什麼時候被釋放掉,而依賴底層庫來維持資源的生命期。

而手工管理,則是可以準確的知道資源的生命期,在準確的位置回收它。在 C++ 中,體現在析構函數中寫明 delete 用到的資源,並由編譯器自動生成的代碼析構基類和成員變量。

所以,爲 C++ 寫一個垃圾收集器,並不和手工管理資源衝突。自動化管理幾乎在所有有點規模的 C++ 工程中都在使用,只不過用的是引用計數的策略而非垃圾收集而已。也就是說,我們使用 C++ 或 C 長期以來就是結合了手工管理和自動管理在構建系統了。無論用引用計數,還是用垃圾收集,軟件實現的細節上,該手工管理的地方我們依舊可以手工管理。

爲什麼要用資源生命期自動管理?

讓我們來看面向對象,如果一切皆對象,每個對象的生命期就應該由自己負責,我們是可以直接準確的死亡時間的。可惜,有很多東西不是純粹的對象。最重要的一個就是對象容器。它們除了自身的屬性,還保持了對一組同類對象的引用。

一個對象可以分別被幾個容器引用,這使得容器區別於貓貓狗狗這些對象實體。因爲容器引用一個東西不等於這個東西是這個容器的一部分(有時候可以,有時候不行)。當我們把希望整個世界分成一個個對象時,所有的原子被分到各層的對象上後,就會發現有零零總總的概念無法用對象提取。引用而非擁有,這是無法迴避的。

面向對象的本質在於,對許多對象提取出共性放在一起處理。這樣,各式容器的使用就是無可避免的了。

也正是如此,對象自己並不知道自己是否已經可以宣告死亡。除非瞭解自己和別的對象的聯繫(這種關係不是對象)。資源可以是對象,而自動化管理正是管理的這些對象和對象之間的關係。

引用計數就是最容易實現的一種方案:記錄對象被引用的次數,而不具體記錄是誰引用了它。這樣,降低了建立和解除引用的代價。但是,有得必有失。在引用計數的過程中,我們也丟失了重要的信息:到底是誰引用了自己。所以,引用計數在處理間接引用的問題上代價增加。

對象死亡的判定是:對象和這個世界還有沒有聯繫,無論是直接的還是間接的。所以,一個對象即使還有另外的對象直接引用它,它也可能已經脫離了世界。爲了解決這個問題,使用引用計數的系統,必須在對象和世界脫離聯繫時,通知和它有關聯的對象。對象的銷燬代價增加,就是引用計數策略的短板。

對象的銷燬頻率,取決於對象的平均生存時間。而對象的生存時間,一方面受對象粒度的影響,往往對象粒度越細,對象平均生存時間越短(雖然表面上沒有直接聯繫,但是實際設計時往往會導致這個結果);另一方面,我們往往會把容器和引用關係也實現成一種對象(概念上本不應該是對象)。比如說許多自動維持引用計數的智能指針就是一個小容器,裏面保持了對一個對象唯一的引用,它就被實現成一個小對象。

通常,對象本身的性質並不隨自己在內存空間中的位置改變而改變。但是引用關係(通常用指針來實現)卻和內存地址相關。C++ 缺乏一種對象在內存中移動的語義表達,等價物是,在新的內存塊中拷貝構造一個新對象,並銷燬原有的。

另一方面,程序的運行序中,函數調用造成的堆棧上的嵌套作用域也可以看成一個個容器,機器指令穿行於這些作用域間,臨時構造出的對對象的引用(智能指針),就被放置於這些作用域內。函數調用越頻繁,這些作用域的創建和銷燬也就越頻繁。

這些導致了 C++ 必須依賴大量的 inline 函數,讓編譯器瞭解更多的上下文信息,方能減輕小對象(智能指針)創建銷燬的負擔。 STL 庫也必須爲其做一些優化,例如 stl port 中,對 POD 類型就做了特例化處理。可惜,智能指針不是 POD ,讓編譯器聰明到合併執行序列中的引用加減,難度太大(考慮到多線程因素,除非編譯器可以知道線程的信息,否則幾乎不可能實現)。

C++ 在實現面向對象的編程上,比 C 提供了許多便利。其中之一就是,在描述一個對象是另一個對象的一部分時,通過構造和析構函數機制,可以自動化的維護這相關部分的生命期。但它沒能在語言上解決的是,當兩者之間只是引用關係時,生命期如何處理。前者,我們有幾乎唯一的簡潔明瞭的解決之道;而後者根據實際需要可以有多種選擇,顧而 C++ 在語言層面不提供一致解決方案。可惜的是 C++ 卻一直每能提供一個簡潔好用,帶有普適性的 GC 庫。大家都偏向於更爲容易實現的引用計數的方案,這個結果跟具體實現的複雜度有關。畢竟在實現 gc 的時候,C 缺乏必要的語言支持(而 C++ 在實現層面,是從 C 的基礎上發展而來)。

再來看看垃圾收集,比較成熟的算法基於標記清除(或標記整理)或其變體。簡單說,就是由收集器框架記錄下對象和對象之間的聯繫(這些聯繫信息存放的位置不重要,可以在對象的內存佈局空間上,也可以在獨立的地方,關鍵在於這些信息可以被收集器訪問)。確定一個世界的根,定期的從這個根開始遍歷這個世界,把有關聯的對象標記起來,最後回收沒有被標記的對象。

從算法上來看,建立對象和對象之間的聯繫的時間代價和引用計數的時間代價數量級上是一致的,都是 O(1) 。但實際實現時,前者的代價通常要大一些。空間代價上也是前者略大,但也沒有數量級上的差別。

而 GC 管理的對象,在銷燬時的代價要小的多。它不需要通知和它有關聯的對象。

這就是爲什麼,許多使用 GC 的軟件有時候比使用引用計數的軟件運行效率還高那麼一點的緣故。

可是,GC 有一個額外的時間代價來源於標記的過程。完成完整的一次清理過程,必然遍歷到世界中每一個活着的對象。代價是 O(N) ,N 隨着對象總體數量的增加而增加。所以我們應該減少被 GC 管理的對象的數量,在這一點上,手工管理依然有意義。即,明確一個對象是另一個對象的組成部分時,可以考慮用手工管理的方式。

另一個糟糕的地方是,在實現時,我們往往把對象間的關聯信息放在了對象本身的內存佈局空間中,遍歷這個世界中的對象意味着訪問所有對象的內存。當虛擬內存空間大於實際物理內存空間時,這意味着頁面交換。我覺得,很大程度上,java 或 C# 這樣的語言搭建起來的龐大系統偶爾運行緩慢,根本原因就在這裏。當然,這些是可以被改進的。並非算法本身的問題。

可以這樣說,GC (garbage collection) 把 RC (reference counting) 中那些短期對象的銷燬代價轉嫁到了一次性的標記清除過程。這把邏輯處理和資源管理正交分解了。這種被分解的問題,會隨着硬件的進步更容易提高性能(比如多核的發展)。但是,在較小規模的軟件或獨立模塊中,這個優勢並不會太明顯。反而 GC 本身遠高於 RC 的複雜性,會成爲其軟肋。

對於不需要面向對象的軟件,甚至連資源自動化管理都不需要。這時,無論是 GC 還是 RC 都無用武之地。

原文鏈接

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