1. 程式人生 > >Generational GC (Part one )

Generational GC (Part one )

目錄

Generationanl GC

引入年齡的概念,優先回收年輕的已成為垃圾的物件。

什麼是分代垃圾回收

物件對的年齡

書上說:“人們 從眾多案例總結出一個經驗:‘大部分的物件再生成後馬上就變成了垃圾。很少有物件活的很久’。”,分代,引入年齡概念,經歷過一次GC的物件年齡為一歲。

新生代物件和老年物件

分代垃圾回收中,將物件分為幾類(幾代),針對不同的代使用不同的GC演算法。剛生成的物件稱之為新生代到達一定年齡的物件稱為老年代物件

我們對新生代物件執行的GC稱為新生代GC(minor GC)。新生代GC的前提是大部分新生代物件都沒存活下來,GC在很短時間就結束了。新生代GC將存活了一定次數的物件當做老年代物件來處理。這時候我們需要把新生代物件上升為老年代物件(promotion)。老年代物件比較不容易成為垃圾,所以我們減少對其GC的頻率。我們稱面向老年代物件的GC為老年代GC(major GC)。

分代垃圾回收是將多種垃圾回收演算法並用的一種垃圾回收機制。

Ungar的分帶垃圾回收

堆的結構

Ungar分代垃圾回收中,堆結構圖如下所示。總共需要四個空間,分別是生成空間、兩個大小相等的倖存空間、老年代空間,分別用$new_start、$survivor1_start、$survivor2_start、$old_start這四個變數指向他們開頭。

生成空間和幸運空間合稱為新生代空間,新生代物件會被分配到新生代空間,老年代物件則會被分配到老年代空間裡。Ungar 在論文裡把生成空間、倖存空間以及老年代空間的大小分別設成了 140K 位元組、28K 位元組和 940K 位元組。

此外我們準備出一個和堆不同的陣列,稱為記錄集(remembered set),設為 $rs。

  • 生成空間,是生成物件的空間。當空間滿了新生代GC就會啟動,將生成空間裡的物件複製,與GC複製演算法一樣。
  • 兩個倖存空間,一個From一個To。
  • 新生代GC將From空間和生成物件空間裡活動的物件複製到To空間中。(這有一個問題,會造成To可能不夠用)
  • 只有經過一定次數的新生代GC才能被放到老年代空間中去。

過程如下圖所示:

  • 新生代GC要注意一點,就是老年代空間到新生代空間的引用。因此除了一般GC的根,老年代空間裡也會有新生代空間物件的引用來當做根。

  • 分代垃圾回收的優點,將重點放置新生代的物件,他們容易被回收。這樣會縮減GC所需的時間。但是,如果我們讓老年代物件引用新生代物件這樣一來等同於所有物件都從根引用。這樣就沒有這樣的優勢了。
  • 所以我們引入記錄集。記錄集用來記錄老年代物件到新生代物件的引用。這樣就可以不搜尋老年代空間裡的所有物件,而是通過搜尋記錄集來發現老年代物件到新生代物件的引用關。
  • 當老年代空間滿了的時候,就要進行老年代GC了。

記錄集

記錄集用於高效的尋找從老年代物件到新生代物件的引用。在新生代 GC 時將記錄集看成根(像根一樣的東西),並進行搜尋,以發現指向新生代空間的指標。

不過如果我們為此記錄了引用的目標物件(即新生代物件),那麼在對這個物件進行晉升(老 年化)操作時,就沒法改寫所引用物件(即老年代物件)的指標了。如下圖示:

通過查詢可知物件A時新生代GC的物件,執行GC後它升級為了老年代物件A'。但在這個狀態下我們不發更新B的引用為A',記錄集裡沒有儲存老年代物件 B 引用了新生代物件 A的資訊。

所以記錄集裡記錄的不是新生代物件,而是老年代物件。他記錄的老年代物件都是有子物件是新生代物件的。這樣我們就能去更新B了。

記錄集大部分使用固定大小陣列來實現。那麼我們如何向記錄集裡插入物件呢?關於寫入屏障內容。

寫入屏障

將老年代物件記錄到記錄集裡,我們利用寫入屏障(write barrier)。write_barrier()函式。

write_barrier(obj, field, new_obj){
    if(obj >= $old_start && new_obj < $old_start && obj.remembered == FALSE)
        $rs[$rs_index] = obj
        $rs_index++
        obj.remembered = TRUE
    *field = new_obj
    
}
  • obj 是發出引用的物件,obj記憶體放要更新的指標,而field指的就是obj內的域,new_obj 是在指標更新後成為引用的目標物件。

  • 檢測發出引用的物件是不是老年代物件,指標更新後引用的目標是不是新生代物件,發出引用的物件是否還沒有被記錄到記錄集中。
  • 當這些都為真時,obj就被記錄到記錄集中了。
  • $rs_index適用於新紀錄物件的索引
  • 最後一行,用於更新指標。

物件的結構

物件的頭部除了包含物件的種類和大小之外,還有三條資訊,分別是物件的年齡(age)、已經複製完成的標識(forwarded)、向記錄集中記錄完畢的標識(remembered)。

  • age標識新生代物件存活的次數。超過一定次數,就會被當做老年代物件。
  • forwarded,用來防止重複複製相同的物件。
  • remembered用來防止登記相同的物件。不過remembered只適用於老年代物件,age和forwarded只使用新生代的物件。
  • 除上面三點之外,這裡也是用forwarding指標之前的垃圾回收一樣。在forwarding指標中利用obj.field1,用obj.forwarding訪問obj.field1。

物件結構如下圖示:

分配

在生成空間裡進行,執行new_obj()函式程式碼如下:

new_obj(size){
    if($new_free + size >= $survivor1_start)
        minor_gc()
        if($new_free + size >= $survivor1_start)
            allocation_fail()
    
    obj = $new_free
    $new_free += size
    obj.age = 0
    obj.forwarded = FALSE
    obj.remembered = FALSE
    obj.size = size
    return obj
    
}
  • $new_free指向生成空間的開頭
  • 檢測生成空間是否存在size大小的分塊。如果沒有就執行新生代GC。執行後所有物件都到倖存空間去了,生成空間絕對夠用。
  • 分配空間。
  • 對物件進行一系列的標籤之類的設定(初始化)。然後返回。

新生代GC

生成空間被物件沾滿後,新生代GC就會啟動。minor_gc()函式負責吧生成空間 和From空間的活動物件移動到To空間。

我們先來了解minor_gc()中進行復制物件的函式copy()。

copy(obj){
    if(obj.forwarded == FALSE) // 檢測物件是否複製完畢
        if(obj.age < AGE_MAX)  //  沒有複製則檢查物件年齡
            copy_data($to_survivor_free, obj, obj.size)// 開始複製物件操作
            obj.forwarede = TRUE
            obj.forwarding = $to_survivor_free
            $to_survivor_free.age++
            $to_survivor_free += obj.size// 複製物件結束
            for(child :children(obj)) // 遞迴複製其子物件
                *child = copy(*child)
        else
            promote(obj) //如果年齡夠了,則進行晉級的操作,升級為老年代物件。
    return obj.forwarding  //返回索引
}
promote(obj){
    new_obj = allocate_in_old(obj)
    if(new_obj == NULL) // 判斷能否將obj放入老年代空間中。
        major_gc() //不能去就啟動gc
        new_obj = allocate_in_old(obj)// 再次查詢 
        if(new_obj == NULL) //再次查詢。
         allocation_fail()//不能放入的話就報錯啦。
    obj.forwarding = new_obj // 能放入則設定物件屬性
    obj.forwarded = TRUE
        
    for(child :children(new_obj)) //啟動GC
        if(*child < $old_start) // obj是否有指向新生代物件的指標
            $rs[$rs_index] = new_obj // 如果有就將obj寫到記錄集裡。
            $rs_index++
            new_obj.remembered = TRUE
            return
}
minor_gc(){
    $to_survivor_free = $to_survivor_start // To空間開頭
    for(r :$roots) // 尋找能從跟複製的新生代物件
        if(*r <$old_start)
            *r = copy(*r)
    i = 0 // 開始搜尋記錄集中的物件$rs[i] 執行子物件的複製操作。
    while(i<$rs_index)
        has_new_obj = FALSE
        for(child :children($rs[i]))
            if(*child <$old_start)
                *child = copy(*child)
                if(*child < $old_start) //檢查複製後的物件在老年代空間還是心神的古代空間 
                    has_new_obj = TRUE  //如果在新生代空間就設定為False否則True 
        if(has_new_obj ==FALSE) // 如果為False,$rs[i]就沒有指向新生代空間的引用。接下來就要自己在記錄集裡的資訊了。
            $rs[i].remembered = FALSE
            $rs_index--
            swap($rs[i], $rs[$rs_index])
        else
            i++
    swap($from_survivor_start, $to_survivor_start) // From 和To互換空間
    
}

倖存空間沾滿了怎麼辦?

  • 通常的GC複製演算法把空間二等分為From空間和To空間,即使From空間裡的物件都還 活著,也確保能把它們收納到To空間裡去。
  • 不過在Ungar的分代垃圾回收裡,To倖存空間必 須收納 From 倖存空間以及生成空間中的活動物件。From 倖存空間和生存空間的點大小比 To 幸 存空間大,所以如果活動物件很多,To 倖存空間就無法容納下它們。
  • 當發生這種情況時,穩妥起見只能把老年代空間作為複製的目標空間。當然,如果頻繁發生 這種情況,分代垃圾回收的優點就會淡化。
  • 然而實際上經歷晉升的物件很少,所以這不會有什麼重大問題,因此在虛擬碼中我們就把這 步操作省略掉了。

老年代GC

就之前介紹的GC都行,但是具體使用哪個看想要的效果以及記憶體的大小來決定。一般來說GC標記清除就挺好的。

優缺點

吞吐量得到改善

通過使用分代垃圾回收,可以改善 GC 所花費的時間(吞吐量)。正如 Ungar 所說的那樣:“據實驗表明,分代垃圾回收花費的時間是 GC 複製演算法的 1/4。”可見分代垃圾 回收的匯入非常明顯地改善了吞吐量。

在部分程式中會起到反作用

“很多物件年紀輕輕就會死”這個法則畢竟只適合大多數情況,並不適用於所有程式。當然, 物件會活得很久的程式也有很多。對這樣的程式執行分代垃圾回收,就會產生以下兩個問題。

  • 新生代GC花費時間增多
  • 老年代GC頻繁

除此之外,寫入屏障等也導致了額外的負擔,降低了吞吐量。當新生代GC帶來的速度提升特別小的時候,這樣做很明顯是會造成相反的效果。

記錄各代之間的引用的方法

Ungar的分帶垃圾回收,使用記錄集來記錄各個代間的引用關係。這樣每個發出引用的物件就要花費1個字的空間。此外如果各代之間引用超級多還會出現記錄集溢位的問題。(前面說過記錄集一般是一個數組。)

卡片標記

Paul R.Wilson 和 Thomas G.Moher開發的一種叫做卡片標記(card marking)的方法。

首先把老年代空間按照等大分割開來。每一個空間就成為卡片,據說卡片適合大小時128位元組。另外還要對各個卡片準備一個標誌位,並將這個作為標記表格(mark table)進行管理。

當因為改寫指標而產生從老年物件到新生代物件的引用時,要事前對被寫的域所屬的卡片設定標誌位,及時物件誇兩張卡片,也不會有什麼影響。

GC時會尋找位圖表格,當找到了設定了標誌位的卡片時,就會從卡片的頭開始尋找指向新生代空間的引用。這就是卡片的標記。

因為每個卡片只需要一個位來進行標記,所以整個位表也只是老年代空間的千分之一,此外不會出現溢位的情況。但是可能會出現搜尋卡片上花費大量時間。因此只有在區域性存在的老年代空間指向新生代空間的引用時卡片標記才能發揮作用。

頁面標記

許多作業系統以頁面為單位管理記憶體空間,如果在卡片標記中將卡片和頁面設定為同樣大小,就可以使用OS自帶的頁了。

一旦mutator對堆內的某一個頁面進行寫入操作,OS就會設定根這個也面對應的位,我們把這個位叫做重寫標誌位(dirty bit)。

卡片標記是搜尋標記表格,而頁面標記(page marking)則是搜尋這個頁面重寫標誌位。

根據 CPU 的不同,頁面大小也不同,不過我們一般採用的大小為4K位元組。這個方法只適用於能利用頁面重寫標誌位或能利用記憶體保護功能的環境。

多代垃圾回收

Multi-generational GC

將物件劃分為多個代,這樣一來能晉升的物件就會一層一層的減少了。

  • 除了最老的那一代,每一代都有一個記錄集。X代的記錄集只記錄來自比X老的其他代的引用。
  • 分代越多,無意物件越快被回收,這個方法每一層的物件都在減少。
  • 但是不能過度增加,想想一下,我們的cpu竟然同時在做很多的GC演算法,簡直不能理解是吧。
  • 書上說,2-3代是最好的。不過我想還是要看情況的。