引用計數

引用計數是垃圾收集中一種直接方式,其基本策略是每個對象維護一個引用計數,用於表示外界有多少地方引用自己,當有新的引用到這個對象的時候,計數+1,反之一個到這個對象的引用被拆除的時候,計數-1,當計數爲0的時候,說明沒有人引用這個對象了,這個對象就被銷燬。由於直觀,容易理解,引用計數在最早實現垃圾收集的lisp,以及早期的java中都使用過,python則是到現在還都一直在用 
注:由於上篇已經論述過的原因,之後的討論中一律用對象來表示一次申請的堆內存單元,兩者是一一對應的 

除了直觀,引用計數最大的優勢在於,內存的管理是實時的,一個對象只要計數爲0,就可以立即被釋放,而其他垃圾收集算法往往做不到這一點,實時性帶來了很多好處: 

一、對象的生存週期相對來說可控,使得析構函數在一定條件下能起到作用,例如在python中: 
def write_file(s): 
    f = open("a.txt", "w") 
    f.write(s) 
這裏無需調用f.close(),因爲python採用引用計數機制,在函數返回時f立即釋放,在文件對象的析構函數中會執行善後工作,更簡潔地可以寫成: 
open("a.txt", "w").write(s) 
而在其他語言中,例如java,雖然提供了finalize方法,但由於對象生命週期不可控,因此用得不多,一般也不推薦用 
注意,上面說了,只是在“一定條件”能較好起到作用,比如python寫個簡單腳本什麼的,事實上只要是帶垃圾收集的語言,都不推薦用析構函數,python在這方面也有不可控的情況 

二、實時響應,這對於實時系統比較重要,比如一個客戶端程序,如果採用標記清除之類的算法,可能需要暫停程序運行,如果內存較小,可能感知不到,但如果稍慢一些,就會卡頓,影響用戶體驗;另外,有些程序比如遊戲,對實時性要求是毫秒級別,佔內存又很大,影響會比較嚴重。這個問題留到後面再說 

三、符合局部性,由於一個對象在需要釋放的時候會被立刻釋放,整個進程的內存使用會控制在很小的範圍,反覆釋放申請也能充分利用cache等,提高運行效率 

四、研究表明,在分佈式系統中,引用計數是非常好的選擇 

儘管如此,很多時候引用計數並不被垃圾收集理論(不妨稱爲“狹義垃圾收集”)承認,或至少不是重點,只是作爲一種很原始的方案來介紹,原因是一個致命的弱點:循環引用 

引用計數機制的垃圾回收,是需要對象的引用計數爲0,但是存在一種情況,有一些對象從根集無法到達,即已經是垃圾了,但是它們之間互相引用,形成帶環的有向圖,這種情況下,引用計數機制無能爲力,比如python中: 
class A: 
    pass 
a = A() 
b = A() 
a.b = b 
b.a = a 
del a, b 
執行完del後,根集只有一個A(python的類也是對象),這個忽略,而A的兩個實例互相引用,因此它們的計數都不爲0,這就造成了內存泄露,還有更小的環,自己引用自己: 
a = A() 
a.a = a 
當然可以想見,更大和更復雜的環,或帶環有向圖是可能的 
關於循環引用問題的解決,一般是引入追蹤式垃圾回收機制來輔助解決,關於如何結合的問題,後面再專門討論 

如果不考慮循環引用,或可以輔助解決,則引用計數機制就是一個完整可行的垃圾回收機制了,但從實用角度講,它還有一些其他缺陷,不過這些缺陷一般來說不是那麼嚴重 

一,雖然引用計數的機制很容易理解,但不代表做起來簡單,這裏的問題是,引用計數和程序執行耦合太緊密了,實現虛擬機時,每次賦值(包括傳參數等),計算都要正確更新,這無疑是很繁瑣的,更大的麻煩是,出錯成本和風險不成比例,很可能一個很小的失誤就造成懸空指針或內存泄露,而且還很難調試,找這類bug基本是大海撈針,最典型的例子就是寫python的C擴展,必須仔細維護引用計數,防止出現問題,因此我一般都儘量用接口,哪怕慢一點 
到了C++中,這個問題有了緩解,只需要寫好一個智能指針各種情況(構造,拷貝構造,賦值等)的實現,編譯器會在對應的地方對這些方法自動觸發,就避免了手工維護引用計數的麻煩,不過,這個方式和下面說的這條有些衝突 

二、維護引用計數需要額外開銷,後面會看到,跟蹤式垃圾收集消耗的時間一般和內存中對象數量相關,而引用計數消耗的時間和計算量相關,因爲每次賦值甚至計算,都會頻繁更新計數,每次雖然只是做加減和判斷,積少成多也很可觀了 
爲緩解這個問題,可以結合實際代碼和虛擬機實現,優化掉不必要的引用計數,例如棧虛擬機中計算c=a+b: 
load a //a->ref++,爲和源語言的屬性區別,用->符號表示內部屬性 
load b //b->ref++ 
add //top=a+b,a->ref--,b->ref--,top初始ref爲1 
store c //c->ref--,先拆除c原先的引用,top->ref++,c=top,top->ref-- 

如果嚴格按照規定實現,引用計數的變化應該是按上面的註釋進行,經過分析可以發現,其中很多操作都是不必要的,比如說最後的store,可以看做是把top“移動”給c,而不是先複製再刪除,因此top->ref先加再減就省了;再進一步,a和b入棧做運算也是先加後減,如果a和b不會被其他線程拆除引用,也可以省略 
函數調用也有類似的情況,由於調用棧是嚴格後進先出: 
func f(): 
    a = 1 
    g(a) 

由於a是局部變量,需要等到f結束,引用計數纔會爲0,那麼在g的執行中,a肯定不會被釋放,因此這個傳參也可以優化,即調用g和從g返回時,a的引用計數都不變 
但是,做這種優化是比較麻煩的,需要考慮各種情況,比如上面的f中調用的g(a+1),則臨時對象的引用計數也需要處理,另外,也不能用上面說的C++的智能指針,需要自己手動維護引用計數 
其實從實際來看,這個問題並沒有想象中那麼嚴重: 
1 很多高級語言在虛擬機解釋執行,本身速度就比較慢,維護引用計數的消耗不是那麼明顯 
2 如果換用簡單的追蹤式垃圾收集機制,的確可以省下這個消耗,但是爲了緩解簡單的追蹤收集引入的卡頓問題,改進爲短停頓收集的時候,對於每次引用的拆除和建立也都是需要維護工作的 
3 雖然引用計數的消耗和計算量相關,但這個額外附加的時間是倍數級別的,不會影響程序本身的複雜性,比如cpu消耗從60%漲到70%,那多投入一點硬件,在工程上也能接受 

三、引用計數本身是一個整數,這個整數的範圍如果越界,可能造成一些意想不到的後果,而如果用比較長的整數類型,則又耗費內存空間,不過這個問題在現在看來不是很嚴重,因爲現在一般用32位int做計數,只有在超出21億多個地方引用一個對象的時候纔會溢出,如果不是變態程序,基本不會出問題 

四、如果一個對象是容器,則當這個對象的引用減爲0的時候,釋放之前會對它所引用的對象做計數減一的操作,僞代碼表示: 
void dec_ref() 
{ 
    this.ref --; 
    if (this.ref == 0) 
    { 
        for item in this 
        { 
            item.dec_ref(); 
        } 
    } 
    delete this; 
} 

這裏出現了遞歸調用,有些時候,這個調用可能會很深,例如一個很長的單鏈表,如果棧空間不夠,搞不好就崩潰了,這不是危言聳聽,linux下棧空間一般8M還好點,vs默認只有1M,遞歸深度很有限,一般寫C++的時候,如果習慣在析構函數裏面釋放空間,也可能出現這個問題;另外,一次釋放這麼多對象也是耗時的,雖然大多數情況下引用計數的實時性表現很好,但如果需要釋放的東西太多,也會造成卡頓 
這個問題可以通過一個專門的釋放器來解決,即當一個對象的引用計數爲0時,將其交給釋放器,在釋放器中可以通過BFS之類的算法來逐步釋放空間,避免棧溢出和卡頓,在跟蹤式垃圾回收機制中,也有類似的漸進式
發佈了49 篇原創文章 · 獲贊 19 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章