垃圾回收算法實現之 - 分代回收(完整可運行C語言代碼)

分代垃圾回收(Mark-Sweep GC),並非一個具體的算法,只是結合了幾種垃圾回收算法,把對象按特色進行了分類,對每種特色的對象集執行不一樣的回收算法,從而提高回收效率git

閱讀本文以前,你最好已經瞭解了複製算法標記清除算法,由於文中不會過多重複介紹複製算法和清除算法的內容github

分代垃圾回收在對象中引用了 「年齡」 的概念,經過優先回收容易稱爲垃圾的對象,從而提升垃圾回收的效率。算法

大部分的對象在生成後立刻就變成了垃圾, 不多有對象能活得好久。

分代垃圾回收利用這個經驗,在對象中加入了 「年齡」 的概念,經理過一次 GC 後還活下來的對象年齡爲 1 歲。segmentfault

分代垃圾回收中把對象分爲幾代(generation),針對不一樣的代使用不一樣的 GC 算法;把新生成的對象稱爲年輕代 (Young Generation) 對象,到達必定年齡的對象稱爲老年代 (Old/Tenured  Generation) 對象。數組

因爲大多數對象都是 「朝生夕死」 的,因此能夠考慮對年輕代進行 「只標記存活對象」 的算法,由於存活對象較少,因此回收效率高。spa

年輕代 GC 稱爲 Minor GC。經歷屢次年輕代 GC 仍然存活的對象,就能夠看成老年代對象來處理。這種年輕代轉移到老年代的狀況稱爲晉升(promotion)。翻譯

由於老年代對象很難成爲垃圾(通過幾回 GC 還存活的對象,通常都是都是永久存活了),因此老年代 GC 的頻率會很低,老年代 GC 稱爲 Major GC。3d

本文參考的是 David Ungar 在 1984 年提出的算法,基於 C 語言指針

名詞解釋

對象

對象在 GC 的世界裏,表明的是數據集合,是垃圾回收的基本單位。對象

指針

能夠理解爲就是 C 語言中的指針(又或許是 handle),GC 是根據指針來搜索對象的。

mutatar

這個詞有些地方翻譯爲賦值器,但仍是比較奇怪,不如不翻譯……

mutator 是 Edsger Dijkstra 琢磨出來的詞,有 「改變某物」 的意思。說到要改變什麼,那就是 GC 對象間的引用關係。不過光這麼說可能你們仍是不能理解,其實用一句話歸納的話,它的實體就是「應用程序」。

mutatar 的工做有如下兩種:

  • 生成對象
  • 更新指針
mutator 在進行這些操做時,會同時爲應用程序的用戶進行一些處理(數值計算、瀏覽網頁、編輯文章等)。隨着這些處理的逐步推動,對象間的引用關係也會 「改變」。伴隨這些變化會產生垃圾,而負責回收這些垃圾的機制就是 GC。

GC ROOTS

GC ROOTS 就是引用的起始點,好比棧,全局變量

堆 (Heap)

堆就是進程中的一段動態內存,在 GC 的世界裏,通常會先申請一大段堆內存,而後 mutatar 在這一大段內存中進行分配

活動對象和非活動對象

活動對象就是能經過 mutatar(GC ROOTS)引用的對象,反之訪問不到的就是非活動對象。

準備工做

首先是對象類型的結構:

爲了動態訪問 「對象」 的屬性,此處使用屬性偏移量來記錄屬性的位置,而後經過指針的計算得到屬性

而後是對象的結構,雖然 C 語言中沒有繼承的概念,可是能夠經過共同屬性的 struct 來實現:

算法實現

在分代垃圾回收中,堆的結構以下圖所示。將堆分紅了 4 個部分,從左至右分別是新生成區 (Eden),兩個大小相等的倖存空間 (Survivor)From/to,以及一個老年代區 (Old Gen),Eden+Survivor 都屬於年輕代區域(New Gen)。

年輕代對象會分配在年輕代區域,老年代對象會分配在老年代。

此處還額外準備了一個記錄集(Remembered set),來存儲跨代的引用(跨代引用下面會介紹)

generational_gc.png

年輕代 GC(Minor GC)

因爲新生代對象特色是 「朝生夕死」,因此對年輕代使用複製算法;Eden 區存放的是新生成的對象,當 Eden 滿了以後,年輕代 GC 就會啓動,將生成空間的全部活動對象複製,不過目標區域是 Survivor 區。

Survivor 區分爲了兩個空間,每次回收只會使用其中的一個。當執行年輕代 GC 的時候,Eden 區的活動對象會被複制到 From 中;當第二次年輕代 GC 時,會將 Eden 和 From 區內存活的對象一塊兒複製到 To 區,以後再把 From/To 功能 「互換」(這裏的互換並無互換數據,在程序中只是把引用換了)

下面是 From/To 互換的邏輯,只是將指針互換了如下而已:

具體 「互換」 流程以下圖所示:

對象晉升 (Promotion)

對象中有一個 age 字段,表明對象經歷的年輕代 GC 次數,新建立的對象年齡爲 0,每經歷一次年輕代 GC 還存活的對象年齡會加 1;在年輕代 GC 時,每次會檢查對象的年齡,當超過必定限制(AGE_MAX)時,會將對象晉升到老年代。

如下是晉升老年代的處理:

詳細晉升流程以下圖所示(圖中包含了基本的年輕代 GC 過程):

image

跨代引用

既然有晉升的操做,那麼這裏會有一個問題:當對象晉升後,引用關係如何處理,對於老年代到年輕代的引用,可達性分析時怎麼處理,是否還須要從 GC ROOTS 開始遍歷老年代呢?

好比對象 A 晉升前,和年輕代另外一個存活的對象 B 關聯,A 在 GC ROOTS 中,B 不在;當對象 A 晉升後,對於 GC ROOTS 來講 B 是不可達(unreachable)的,可是對於 A 來講 B 是可達的

或者對象 A 晉升後,又新分配了對象 C,而後用 A 引用 C,此時對於 GC ROOTS 來講,C 也是不可達的

因爲存在跨代引用的可能,因此在年輕代 GC 時,只從 GC ROOTS 開始遍歷年輕代對象是不夠的,還須要將老年代中引用年輕代的那部分對象也做爲 GC ROOTS,這樣才能保證完整的回收年輕代

掃描老年代這部分對象看起來沒問題,但是因爲老年代的特色是長期存活的對象,空間很大對象不少,掃描老年代的成本要遠遠大於掃描 GC ROOTS,成本過高,因此直接從 GC ROOTS 遍歷老年代或者順序遍歷老年代的 free-list 不合適。

記錄集合(Remembered set)

跨代引用這個問題能夠以空間換時間的方式,使用一個額外的數組來存儲跨代引用的關係:

如上圖所示,使用一個額外的 Remembered Set 來存儲引用着年輕代那部分的老年代對象,當發生年輕代 GC 時,除了要遍歷 GC ROOTS 中那部分年輕代對象,還要遍歷 Remembered Set 中的這部分存在跨代引用的對象,這樣就避免了額外掃描老年代的問題

至於這個 Remembered Set 的添加時機也很簡單,只須要在發生晉升時檢查晉升對象是否還包含年輕代對象的引用便可,若是包含就將晉升的對象添加到 Remembered Set。

本文中只作了最簡單的實現,當晉升後的對象還保留年輕代的引用時,手動添加到 Remembered Set

手動更新引用:若老年代對象的新引用是年輕代對象,則添加到 Remembered Set

自動更新引用:對晉升的對象執行 "write_barrier",檢查是否存在跨代引用,若存在則添加到 Remembered Set

這個額外的添加操做看着有點傻,並且若是跨代引用過多還可能會致使 RS 溢出,因此有另外一種替代 Remembered Set 的方式:頁面標記(Card Marking),因爲本文沒有實現,這裏就不作介紹了

老年代 GC(Major GC)

因爲老年代對象的特色是長期存活,因此老年代空間通常會比年輕代大不少。並且基於這個長期存活的特色,老年代並不適合複製算法,由於複製算法須要頻繁移動對象,且複製算法效率取決於存活對象數量;因此老年代會使用標記 - 清除算法,和基本的清除算法同樣,具體參考以前的文章《垃圾回收算法實現之 - 標記 - 清除(完整可運行 C 語言代碼)》

優勢

「大部分的對象在生成後立刻就變成了垃圾, 不多有對象能活得好久。」 這一說法雖然並不絕對,但仍是能夠適應絕大多數場景的。以這個理論爲前提,新生代 GC 只會掃描新生代空間的對象,這樣就能夠減小 GC 的時間消耗

並且通常年輕代空間會設置的較小,並且是複製算法效率極高,因此就算新生代 GC 頻繁,在時間的消耗上通常也是能夠介紹的;老年代清除算法效率低且空間大,可是因爲老年代對象的特色是長期存活,因此老年代的 GC 頻率會很低。

綜合來看,分代回收以後,會大幅改善 GC 所消耗的時間

缺點

「大部分的對象在生成後立刻就變成了垃圾, 不多有對象能活得好久。」 這個原則畢竟只適合大多數狀況,不可能適用全部程序,因此若是出現不匹配的場景,就可能會致使如下問題:

  • 年輕代 GC 後不可達對象極少,致使複製對象過多形成耗時增長
  • 老年代被提早填滿,致使老年代 GC 頻繁

完整代碼

https://github.com/kongwu-/gc_impl/tree/master/generational

相關文章

參考

  • 《垃圾回收的算法與實現》 中村成洋 , 相川光 , 竹內鬱雄 (做者) 丁靈 (譯者)
  • 《垃圾回收算法手冊 自動內存管理的藝術》 理查德·瓊斯 著,王雅光 譯
相關文章
相關標籤/搜索