在上一篇文章中,介紹了在GC機制中,GC是以什么標准判定對象可以被標記的,以及最有效最常用的可達性分析法。
今天介紹另外一種非常常用的標記算法,它的應用面也相當廣泛。這就是:
引用計數法 Reference Counting
這個算法的本質,其實就是上篇文章中判斷一個對象要被回收的另外一種思路,即如果沒有其它對象調用當前對象,那么當前對象就可以被回收了。判斷有多少調用當前對象有兩種方法,一種是看看其它對象,有多少對象持有當前對象的引用。還有一種辦法就是,當前對象自身實現一個計數機制。統計來自外界引用的調用。第一個辦法就是上篇文章中可達性分析的最初思路。而第二個辦法就是現在要介紹的引用計數法的最初思路:我們不關心誰保存了我們的引用,我們只關心保存我們引用的對象究竟有多少個。
在引用計數法中,每個對象擁有一個記錄自身引用被持有的個數,當這個對象的計數器的值為0時,也就是不再有其它對象持有該對象的引用了,那么也就是不再有對象可以調用到當前對象的方法或者變量了。這一刻也就是當前對象可以被回收的時刻了。
在《垃圾回收的算法與實現》這本書中,對於該算法又一個很有意思的描述;
每個對象就像是一個明星。這個對象的引用計數的大小,就像是這個明星的人氣指數。當明星的人氣指數為0時,也就是這個明星黯然離場的時候了。
在引用計數法中,每個對象的引用計數器初始(防盜連接:本文首發自http://www.cnblogs.com/jilodream/ )值為0。沒當有一個新對象持有當前對象的引用時,計數器就會加1。沒當有一個已經持有引用的對象消失,或者拋棄持有的引用時。計數器就會-1。當計數器的值再次為0,這個計數器所代表的對象就會被回收掉。(更准確的說,是會讓空閑鏈表持有自己的引用,將自己所占用的內存空間標記為可以重新分配的區域)。
接下來說說這個算法的優缺點:
優點:
1、隨時隨地的回收垃圾
當計數器變為0的瞬間,當前對象就會被放置到空閑隊列中,作為可以被重新分配的內存空間。而其他的GC算法,如之前講到的可達性分析法,都需要進行一次全局的清理,才會統一的清理掉這個周期內的所有已知的垃圾空間。
2、最大暫停時間短
引用計數法師在每次生成、或銷毀對象或者是變更指針的時候進行一次計算的,因此對程序的影響時間是非常短暫的。
而其他的GC算法則由於需要統一的清除或者是復制等,所以暫停的時間會比較長,對程序的影響也比較長。有時這個時間長到性能上已經無法忍受時,就需要不斷的調優,減短單次暫停的最大時長。
3、核心思路簡單
引用計數法。不需要從根節點依次開始進行遍歷。每個對象只關心直接持有自己引用的對象是否發生了變化。這樣當對象發生回收時,也只影響這個對象直接持有的引用對象,而不會直接影響到更深路徑的對象。
如A持有B/C,B持有D/E。當A被回收時,只會影響B,C對象的引用計數器。當B的計數器值因此降為0時,才會影響到D/E節點。整體的計算成本非常的低。而其他的GC方式需要從根依次進行遍歷。整個過程非常復雜(如涉及到一個網狀的引用關系,如何終止掉無意義的遍歷就尤為重要)。
缺點:
1、計算的頻率過快
每次執行一條命令時,都可能會引起若干次的引用計數變化。尤其是對於一些根節點持有的對象(從根對象)的引用計數,其變化的速度更是驚人。因此計數器的工作量非常繁重。
2、計數器所占用的內存空間非常的大
引用計數法中。每個對象都需要一個屬於自己的引用計數器,盡管這個計數器使用無符號型來存儲,但是也只能節約一個bit位的空間。由於可能會發生所有對象都持有一個對象的極端條件,所以計數器所允許的最大值一定是要非常大。(防盜連接:本文首發自http://www.cnblogs.com/jilodream/ )相應的所占用的控件也會非常的大。這是一種是典型的空間換取時間的算法。
3、實現非常復雜,一旦出錯后果會非常的嚴重
盡管該算法的優點是思路非常簡單,但是實現起來卻要復雜的多。每當一個對象回收時,都需要刷新每一處使用到的對象的計數器。一旦有一處錯誤,則可能會出現永遠無法修復的內存泄露問題。
4、循環依賴
這個問題可以是引用計數法被公認的最難處理問題:當兩個(也可以使是多個)內存對象互相依賴,同時也不與外界有引用關系,從而形成一種類似孤島鏈的關系。此時每一個對象計數器都不為0,GC也就無法回收掉這些內存了。這種情況下,典型的引用計數法是無法解決掉的。往往需要結合其他的回收算法,進行改良才能解決問題。
針對這些缺點。業界提供了很多的改良算法
1、延遲引用計數法 Deffered Reference Counting
deferred [dɪ'fɜ:d] adj. 延期的,緩召的;
針對從根對象的引用非常頻繁的更新,從而導致其計數器的計算任務非常繁重的這個問題。有人提出了一種特別的思路:不維護從根引用對象的計數器。這些計數器的值始終為0。其他對象仍然正常采用引用計數器的方式。但是這就會有一個問題,GC無法判斷哪些對象是可以回收的,哪些是不能回收。因此就需要把計數器降為0(decr_ref_cnt函數)的對象暫時先放置在一個容器中,延遲它的回收。這個容器稱為ZCT(Zero Count Table)。它專門用來記錄那些計數器經過減持計算而變為0的對象。
如下圖:
那么什么時候開始真正的標記垃圾對象呢?
一般來說當我們創建(new_obj函數)對象時,發現已經沒有空余的內存空間可以分配時。就會進行一次ZCT掃描(scan_zct函數)來清理掉這些對象。然后再次嘗試分配內存,如果仍然不能成功,那么這時候就認為內存溢出了。
ZCT掃描(scan_zct函數)的步驟如下:
(1)首先將從根引用的計數器調整到正常的數值;
可見下圖:
(2)然后遍歷ZCT,將值為0的對象都清理掉(delete(obj)函數),放置到前文說的空閑隊列中。(此時這些對象的空間就可以被用來分配新的對象了)
(3)然后將所有的從根引用的計數器再調整回去。
這個算法的好處是,廢棄掉從根引用對象的計數器被頻繁刷新這些無意義的繁重耗時的操作,大大減輕了處理器的負擔。
當然它的缺點也很明顯,內存不再會被立即回收掉。只有當內存空間不夠時(防盜連接:本文首發自http://www.cnblogs.com/jilodream/ ),才開始掃描ZCT,統一進行回收。而掃描ZCT的耗時一般會隨着ZCT的增大而增大,這樣就導致了GC的最大的暫停時間變大。當然也可以通過調小ZCT來減小最大暫停時間,但是這樣又會讓GC更頻繁的進行ZCT掃描(空間與時間不可兼得)。從而導致內存回收處理的吞吐量下降。2、Sticky引用計數法
sticky 英 [ˈstɪki] adj. 粘性的; 不動的;
前文有提到計數器的控件占用非常的大。這是為了保證極端場景計數下,計數器可以正常使用。但這種算法卻恰恰是將計數器所占用的空間(計數上限)縮小。這是因為對於大部分對象來說,計數器中所能達到的最大值都不大,對象很快就會被回收了。為了保證計數器每一個對象的計數器都不會溢出,而給每一個對象都開辟一塊非常大的空間來計數,這是一種非常愚蠢的行為。
對於個別計數器會溢出的對象來說:
(1)那么就讓它溢出好了
反正它都被這么多對象引用了,概率上講,基本也不太會被回收了,可以說默認它為永生對象了。
(2)如果認為計數器溢出不好,可以加入從根尋址的變相算法,大致思路是這個樣子的:
<1>計數器歸0
<2>從根依次尋址,增加引用計數值
<3>清理掉引用計數器為0的對象
這個算法的好處是:
<1>降低計數器占用的空間
<2>清理掉循環引用的場景
3、1位引用計數算法
這個算法可以說是Sticky引用計數法的一種極端體現,也就是計數器只有1位。一旦出現共有一個內存的場景下就“溢出”了。
盡管場景很極端,但是他代表了很多的內存:這些對象從創造出來之后,只被一個對象持有,不存在多個對象共同持有的情況。
這種算法中,計數器更多像是一個標記值,標記當前對象是被其他對象引用,而不再是一個對象的計數器。
4、部分標記清除算法
這個算法可以說是純粹就是為了解決循環依賴的。算法的大致思路如下:將內存對象標記為四種狀態:
A絕對不是垃圾的對象;
B絕對是垃圾的對象;
C可能存在循環引用的對象;
D搜索完畢的對象。
這樣就可以針對對象的狀態采用不同的回收算法計算。由於內存中的大部分對象都處於循環引用的孤島連之外(防盜連接:本文首發自http://www.cnblogs.com/jilodream/ )。因此大部分的對象仍然采用的是引用計數法進行計算。只有少部分的對象可能存在循環引用中,因此只對這部分對象進行進行根可達性的計算。
盡管引用計數法自身存在諸多的缺陷,但是仍然有很多地方采用了這種回收算法:如Python的GC、Flash player的內存管理等。可惜由於循環依賴等原因,到目前為止,主流的JVM還均沒有將引用計數法作為GC的回收算法來使用。
對於這篇文章中提到的這些GC標記算法,以及這些GC標記算法的實現,在這里推薦一本前文提到的書:《垃圾回收的算法與實現》。這本書寫的非常詳細,書中的插圖也非常形象。有興趣的同學可以找來閱讀下。