一、 What, Why
1. GC是什么?為什么需要GC
GC,全寫是Garbage Collection , 即垃圾回收。GC是一種自動內存管理機制。通常我們在需要時手動的分配內存,在不需要某塊內存時再手動的釋放內存,但是當系統足夠復雜時,判斷某個內存區域是否需要釋放是一件很麻煩的事情,必須小心的對待,否則可能導致內存泄漏或者系統崩潰。自動內存管理機制可以自動的判斷指定的內存區域是否需要被釋放,安全的釋放指定的內存區域,進而提高開發效率和提升系統的安全性,某些算法還可以提高系統的運行性能。
二、 討論要點
這篇文章要討論的並不是GC的優化,而是GC算法的實現。要解決的主要問題是如何選擇和實現一個合適的GC算法。中間會涉及到一些有關性能的問題,因為有些GC算法本身就是基於性能的考慮設計的。這里介紹的是GC算法的一些經典實現,同一種算法在實際應用中可能在實現上會有不同,在有些情況下也會將多種GC算法組合使用。
三、 算法分類
1. 引用計數GC和追蹤式GC
引用計數GC:
引用計數式GC通過額外的計數域來實時計算對單個對象的引用次數,當引用次數為0時回收對象。引用計數式GC是實時的。
追蹤式GC:
追蹤式GC算法在達到GC條件時通過掃描系統中是否有到對象的引用來判斷對象是否存活,然后回收無用對象。
2. 保守式(Conservative)GC和精確式GC
精確式GC:
精確式GC是指在回收過程中能准確的識別和回收每一個無用對象的GC方式,為了准確識別每一個對象的引用,通常要求一些額外的數據,這些數據通常對用戶程序是透明的。
保守式GC:
和精確式GC相反,保守式GC不能准確的識別每一個無用對象,但是能保證在不會錯誤的回收存活的對象的情況下回收一部分無用對象。保守式GC並不需要額外的數據來支持查找對對象的引用,它將所有內存數據假定為指針,通過一些條件來判定這個指針是否是一個合法的對象的引用。
3. 搬遷式和非搬遷式
搬遷式GC:
搬遷式GC在GC過程中需要移動對象的在內存中位置,當然,移動對象位置后需要將所有引用到這個對象的地方更新到新位置。
非搬遷式GC:
和搬遷式GC相反,在GC過程中不需要移動對象的內存位置。
4. 實時和非實時GC
實時GC:
實時GC是指不需要停止用戶程序執行的GC方式。
非實時GC:
和實時GC相反,非實時GC在執行過程中必須停止用戶程序執行。
5. 漸進式和非漸近式GC
漸進式GC:
和實時GC一樣不需要中斷用戶程序運行,不同的地方在於漸進式GC不會在對象拋棄時立即回收占用的內存資源,而是在達成GC條件時統一進行回收操作。
四、 算法解析
1. 引用計數式
1) 引用計數
引用計數算法是即時的,漸近的,對於交互式和實時系統比較友好,因為它幾乎不會對用戶程序造成明顯的停頓(注1)。
分類:引用計數式,精確式,實時,非搬遷式,漸近式
優點:
- 引用計數方法是漸進式的,它能及時釋放無用的對象,將內存管理的的開銷實時的分布在用戶程序運行過程中。
缺點:
- 引用計數方法要求修改一個對象引用時必須調整舊對象的引用計數和新對象的引用計數,這些操作增加了指針復制的成本,在總體開銷上而言通常比追蹤式GC要大。
- 引用計數要求額外的空間來保存計數值,這通常要求框架和編譯器支持。
- 實際應用中很多對象的生命周期很短,頻繁的分配和釋放導致內存碎片化嚴重。內存碎片意味着可用內存在總數量上足夠但由於不連續因而實際上不可用,同時增加內存分配的時間。
- 引用計數算法最嚴重的問題是環形引用問題。
圖中Root對象是一個能被用戶程序訪問到的對象,比如棧上引用的對象。用戶程序通過Root間接訪問其它對象。
對象A處在A->B->C->A的環中,因此A有a,b兩個引用,當a引用斷開時,A還有一個引用b,但是實際上A->B->C->A環已經無法被用戶程序引用到了。如下圖:
弱指針解決方案:
弱指針算法使用兩個計數域來計算對對象的引用,一個稱為強引用,一個稱為弱引用。當強引用計數為0時對象不再可用。
Boost庫中的SmartPtr按以下過程工作:
SharedPtr代表強指針,WeakPtr代表弱指針。
- 當對象從強指針向強指針傳播時,強引用計數增加
- 當對象從強指針向弱指針傳播時,弱引用計數增加
- 當對象從弱指針向強指針傳播時,如果強引用計數大於0,那強引用計數增加,否則返回空。
- 當對象從弱指針向弱指針傳播時,如果強引用計數大於0,那么弱引用計數增加,否則返回空。
- 當對象強引用為0時,如果弱引用為0則釋放計數域,否則僅釋放對象不釋放計數域。
- 當對象弱引用為0時,如果強引用為0則釋放計數域,否則什么也不干。
弱指針算法必須小心的維護弱引用,如果出現兩個強互相引用,依然難以避免環形引用問題,雖然出現了一些自動避免環形引用的算法,但依然不完善,沒有廣泛的應用。
實際應用:
Python
Boost SmartPtr
JavaScript
2. 追蹤式GC
追蹤式GC算法通過遞歸的檢查對象的可達性來判斷對象是否存活,進而回收無用內存。
追蹤式的GC算法的關鍵在於准確並快速的找到所有可達對象,不可達的對象對於用戶程序來說是不可見的,因此清掃階段通常可以和用戶程序並行執行。下面主要討論了算法的標記階段的實現。
1) 標記清掃(Mark-Sweep)
標記清掃式GC算法是后面介紹的追蹤式GC算法的基礎,它通過搜索整個系統中對對象的引用來檢查對象的可達性,以確定對象是否需要回收。
分類:追蹤式,非實時,保守(非搬遷式)或者精確式(搬遷式) ,非漸進
優點:
- 相對於引用計數算法,完全不必考慮環形引用問題。
- 操縱指針時沒有額外的開銷。
- 與用戶程序完全分離。
缺點:
- 標記清掃算法是非實時的,它要求在垃圾收集器運行時暫停用戶程序運行,這對於實時和交互式系統的影響非常大。
- 基本的標記清掃算法通常在回收內存時會同時合並相鄰空閑內存塊,然而在系統運行一段時間后仍然難免會生成大量內存碎片,內存碎片意味着可用內存的總數量上足夠但實際上不可用,同時還會增加分配內存的時間,降低內存訪問的效率。
- 保守式的標記清掃算法可能會將某些無用對象當做存活對象,導致內存泄露(注3)。
實現:
用戶程序初始化時向系統預申請一塊內存,新的對象申請在此區域內分配, 用戶程序不需要主動釋放己分配的空間,當達到回收條件,或者用戶程序主動請求時開始收集內存。
標記清掃式GC算法(mark-sweep)分為兩個階段: 標記階段 和 清掃階段。
標記階段
從根結點集合開始遞歸的標記所有可達對象。
根結點集合通常包括所有的全局變量,靜態變量以及棧區(注2)。這些數據可以被用戶程序直接或者間接引用到。
清掃階段
遍歷所有對象,將沒有標記為可達的對象回收,並清理標記位。
標記前:
標記后:
保守式的標記清掃算法:
保守式的標記清掃算法缺少對象引用的內存信息(事實上它本身就為了這些Uncooperative Environment設計的),它假定所有根結點集合為指針,遞歸的將這些指針指向的內存堆區標記為可達,並將所有可達區域的內存數據假定為批針,重復上一步,最終識別出不可達的內存區域,並將這些區域回收。
保守式的GC算法可能導致內存泄漏。由於保守式GC算法沒有必需的GC信息,因此必須假設所有內存數據是一個指針,這很可能將一個非指針數據當作指針,比如將一個整型值當作一個指針,並且這個值碰巧在已經分配的堆區域地址范圍內,這將會導致這部分內存被標記為可達,進而不能被回收。
保守式的GC不能確定一個內存上數據是否是一個指針,因此不能移動對象的位置。
實際應用:
保守式標記清掃GC算法: Boehm-Demers-Weiser 算法
精確式標記清掃算法:UE3, UE4等
2) 標記縮並(Mark-Compaction)
有些情況下內存管理的性能瓶頸在分配階段,內存碎片增加了查找可用內存區域的開銷,標記縮並算法就是為了處理內存碎片問題而產生的。
分類:追蹤式,非實時,精確式,搬遷式,非漸進
優點:
- 相比於基本的標記清掃算法,減少了內存碎片,提高了內存分配和訪問效率。
- 相比於節點復制算法,對內存需求更低。
缺點:
- 需要移動對象位置,需要更新所有到對象的引用,因此需要更多的GC時間。
- 需要額外的空間保存縮並信息。
- 需要精確的識別對象引用,因此需要編譯器或者框架支持。
實現:
標記縮並式GC算法分為三個階段:
標記階段:
標記存活數據結構單元
縮並階段:
移動對象並且合並空閑區塊
更新階段:
更新所有到存活數據的引用
雙指針算法:
雙指針算法要求每次分配的對象大小必需一樣,但是並不需要額外的數據結構來保存節點信息。
這個算法包括兩個指針,執行過程如下。
(a) Free指針從堆末尾查找空閑節點,Live指針從堆頂查找存活節點,
(b) 將Live指針指向的存活節點復制到Free指針指向的空閑節點,將Free指針的地址寫入Live指針指向的位置,
(c) 移動Free指針和Live指針,重復(b)直到Free指針和Live指針相遇。
(a)
(b)
(c)
(d)
遷移地址算法:
遷移地址算法適用於可變大小的內存分配,但是它要求對象中包含一個記錄對象新位置的字段, 並且需要遍歷三次堆。
- 第一次從堆頭部開始遍歷,計算到當前位置遇到的所有存活對象的大小(不包括當前對象),將值記入當前對象的新位置字段。同時將相鄰的空閑字段合並成,以減少后面遍歷的次數。
- 第二次遍歷所有的對象,將對其它對象的引用更新到新位置,新位置==當前位置+對象的新位置字段值。
- 第三次移動所有對象到新位置,清除新位置字段的值,為下次收集做准備。
(1)GC前
(2)計算新位置
(3)GC后
3) 節點復制
節點復制GC通過將所有存活對象從一個區移動到另一個區來過濾非存活對象。
分類:追蹤式,非實時,精確式,搬遷式,非漸進
優點:
- 和基本的標記清掃算法相比,節點復制算法的開銷正比於存活數據的容量,而不是整個堆的大小。
- 減少了內存碎片,有更好的內存局部性。
- 新對象的分配更簡潔高效,並且不需要維護空閑塊的列表等輔助數據結構。
- 在低對象存活率的環境中有更高的效率。
缺點:
- 相比於標記縮並算法,需要雙倍的內存。
- 大型對象的復制消耗可能很大。
實現:
三色算法是漸進式分代GC算法的基礎。它將堆分為兩個分區,稱為From區和To區, 每次分配對象分配在From區中,當From區沒有可用空間時開始GC,將存活對象從From區復制到To區中,交換From區和To區,新對象的分配只需要在From區已分配的大小加上新對象的大小。
三色法將所有對象定義為三種“顏色”:
黑色:表示當前對象已經被回收器掃描到,並且它的所有引用成員已經被加入到掃描列表中。
灰色:當前對象已經被加入到掃描列表中,但是還沒有被掃描到; 或者被用戶程序修改, 由黑轉灰。后一種情況主要出現在漸進式GC過程中。
白色:沒有掃描到的對象並且也不再隊列中,也就是說還沒有發現有到該對象的引用。
深度優先遍歷(對象間的引用關系能讓對象遷移到相鄰的內存區域,可以獲得良好的空間局部性)
- 遞歸的掃描所有根結點,將正在掃描的節點, 從From區復制到To區,在原位置上留下新地址,並標記為灰色。
- 掃描這個節點中的所有引用,執行第一步,當這個節點掃描完成后,即所有引用到成員也已經標記為黑色,將該節點標記為黑色。
- 當所有根節點標記為黑色后,剩下的白色節點為可回收塊,仍然留在舊From區中,整個舊From區將被回收,所有存活節點密集的分布在To區前部,To區的后部是空閑塊,因此不需要維護空閑節點列表。
- 交換From和To區。
(1) (2)
(3) (4)
(5) (6)
(7) (8)
(9)
廣度優先遍歷
- 將所有根節點加入掃描隊列中,同時將對象從From區移動到To區, 在原位置留下新位置的地址,並將對象標記為灰色。
- 從隊列頭部第一個對象開始掃描,對它的成員進行以下操作:
如果它是黑色,那么它已經掃描完成了,將當前掃描對象指向它的引用更新到新位置;
如果它是灰色,那么它已經在To區中了,掃描它的成員,對它的所有成員執行第一步,再將它的顏色轉換為黑色。
- 當隊列頭指針和尾指針相等時掃描完成。剩下的白色對象即為可回收塊,仍然留在舊From區中,整個舊From區將被回收,所有存活節點緊密的分布在新From區前部,新From區的后部是連續的空閑塊,因此不需要維護空閑節點列表。
- 交換From和To區
4) 分代式GC(Generational Garbage Collection)
在程序運行過程中,許多對象的生命周期是短暫的,分配不久即被拋棄。因此將內存回收的工作焦點集中在這些最有可能是垃圾的對象上,可以提高內存回收的效率,降低回收過程的開銷,進而減少對用戶程序的中斷。
分代式GC每次回收時通過掃描整個堆中的一部分而是不是全部來降低內存回收過程的開銷。
分類:追蹤式,非實時,精確式,搬遷式,非漸進
優點:
- 只收集堆的一部分,減小了內存回收的開銷,縮短了用戶程序的中斷時間 。
- 和節點復制算法相比,只需要和需要回收分區一樣大而不是和整個已分配堆一樣大的內存。
缺點:
- 系統需要根據對象的存活時間區分年老的對象和年輕的對象,因此需要額外的內存空間保存對象的年齡數據。
- 為了快速回收年輕分代,必須維護年輕分代的根結點集合。這需要使用攔截器實現,因而需要編譯器支持。
- 攔截器的使用增加了指針復制的開銷。
實現:
分代式GC算法基於標記清掃算法或者節點復制算法。
分代式GC算法將堆按對象的存活時間分為兩個或者更多個區域,稱為分代,通過更頻繁的回收年輕分代來提高回收的效率。
在分代式GC算法中,新的對象總分配在最年輕的分代區域,當最年輕的分代填滿時,掃描這個區域中的存活對象,增加它們的年齡,如果對象的年齡達到提升條件,那么將它復制到比當前分代年齡更大一級的分代中去。其它區域依次類推,年齡最高的分代回收時不移動存活對象,只回收無用對象。
下面描述的分代式GC算法是基於兩個分代實現的,一個稱為年老分代,一個稱為年輕分代。
為了提高存活對象的回收效率,不必每次遍歷整個堆來查找年輕分代的根集合,需要維護年輕分代的根結點集合,這個根結點集合稱為記憶集。記憶集包含了所有從這個分代區域外到這個分代區域內的所有對象的引用。這些引用可能來源於全局變量,棧,和年老分代。
年老分代到年輕分代的引用關系有兩個來源,一個是在對象從年輕分代提升到年老分代的過程中被提升的對象引用到的未被提升的對象,另一個是用戶程序運行時修改了年老分代的成員變量。前一種可以由回收器維護,后一種需要使用攔截器來維護。
攔截器通常是一小段內聯代碼,在用戶程序修改對象引用時執行一些特殊的操作以保證GC程序的正確執行。引用計數算法的計數域更新操作也可以看作是一種攔截器。
攔截器分為兩種,寫攔截器和讀攔截器,寫攔截器保證了用戶程序修改了對象引用時能將修改記錄下來,比如放到記憶集中,以便后面重新掃描該對象。而讀攔截器保證了用戶程序訪問到的對象都是可達的------如果對象還沒有被標記為可達的,立即標記它。
攔截器會增加指針復制的開銷和增大代碼生成結果,而在實際應用中對象的寫訪問操作比對象的讀訪問操作少得多,所以基於寫攔截器的的GC算法更通用。
分代式GC算法的標記流程如下,假設每次GC提升存活對象。
- 從全局變量和棧上的引用查找存活對象

- 黑色引用即為年輕分代的根節點集合

- 每次GC只掃描年輕分代區域,由年輕分代指向年輕分代之外的引用不計算

- 年老分代填滿時需要進行一次Full GC, 完全掃描整個堆

- 清理堆中所有無用內存

實際應用:Java, Ruby
5) 漸進式GC
漸進式GC要解決的問題是如何縮短自動內存管理引起的中斷,以降低對實時系統的影響。
漸進式GC算法基於分代式GC算法,它的核心在於在用戶程序運行過程中維護年輕分代的根結點集合。
分類:追蹤式,非實時,精確式,搬遷式,漸進式
優點:
- 可以和用戶程序並行執行,對於對用戶程序影響非常小。
缺點:
- 需要編譯器支持。
實現:
漸進式GC在內存回收過程中將標記階段分為多段執行,減小用戶程序的中斷時間。
在標記過程中,當用戶程序修改了對象的引用關系時,GC程序必須知道哪個對象被修改了,以便后面重新標記這個對象。
基於記憶集的漸進式GC算法:
記憶集算法需要額外的空間來保存被引用的年輕分代的對象集合,當有重復對象加入記憶集時記憶集可能會變的很大,而使用額外的字段標記對象是否加入了記憶集的則需要對象保留額外的字段來標記這個對象是否已經加入了記憶集,並且最后需要重新掃描整個堆。
其工作流程如下:
- P已經被掃描並標記為黑,正在掃描K->L

- 用戶程序修改了P,攔截器將P的標記由黑轉灰,並放入到記憶集末尾(注5)。

- 當掃描到記憶集的末尾時會重新掃描P

- 當掃描指針指向記憶集結尾時,GC過程結束
卡表法:
卡表法將年老分代分為多個小分區,每個小分區對應一個標記位,當小分區中的對象被修改時,更新該分區對應的標記位,在掃描結束時重新掃描所有標記位,查找所有被更新的標記位對應的小分區,重新掃描這些小分區內的所有對象。
當B.ptr和I.ptr被修改,導致Mark1和Mark6被更新,最終B, G, I被重新掃描。
實際應用:Java,Ruby
五、 參考文檔
Garbage Collection in an Uncooperative Environment. Hans-Juergen Boehm .
http://www.hboehm.info/gc/. Hans-Juergen Boehm
http://www.oracle.com/technetwork/articles/java/index-jsp-140228.html
http://www.zhihu.com
http://www.stackoverflow.com
http://www.boost.org/doc/libs/1_60_0/libs/smart_ptr/smart_ptr.htm
六、 備注
注1:引用計數算未能在某些極端情況下也可能會導致長時間的停頓,比如一個很長的單鏈引用。
注2:在一些標記清掃算法通常還包括寄存器中的數據。
注3:保守式標記清掃算法中的內存泄露是非遞增的。
注4:基於記憶集的算法通常會將C放入記憶集,表示C被外部對象引用,是當前分代的根結點之一,而卡表法將A對應的小分區標記位置位,表示分區被修改過(Dirty)。
注5:有些算法在對象中保留一個字段來標記對象是否已經在記憶集中了,以保證對象不會被重復加入到記憶集中, 當然最終需要重新掃描整個記憶集來識別這些灰色對象。
注6:攔截器的開銷均勻的分布在用戶程序運行過程中,通過某些條件可以將這種影響降低到最低,比如大部分時候對象的操作都在棧上,而棧必定是根集合的一部分因此過濾對棧上數據的攔截可以極大的降低攔截器的開銷,這對編譯器是很容易實現的,或者只對分布在年老分區內的對象修改時進行攔截。
注7:不包括棧上引用的對象。