垃圾回收算法(5)分代回收


分代垃圾回收,基於的是“ 大部分的對象,在生成后馬上就會變成垃圾”這一經驗上的事實為設計出發點。此前討論過基於引事實的另一個垃圾回收算法,引用計數出的一些優化思路。
 
分代的關鍵是:
  1. 給對象記錄下一個age,隨着每一次垃圾回收,這個age會增加;
  2. 給不同age的對象分配不同的堆內內存空間,稱為某一代;
  3. 對某一代的空間,有適合其的垃圾回收算法;
  4. 對每代進行不同垃圾回收,一般會需要一個額外的信息:即每代中對象被其他代中對象引用的信息。這個引用信息對於當前代來說,扮演與"root"一樣的角色,也是本代垃圾回收的起點。
 
 
分代垃圾回收的典型是Ungar的分代垃圾回收。
它將堆分成如下形式:
 
如上,分成新生代與老年代。
在新生代內,又分成了生成空間與幸存空間。當生成空間滿了,會以復制算法進行垃圾回收,復制到幸存空間中。和前面的復制算法匹配,幸存空間又一分為二,分成from和to空間。每次新生代的垃圾回收,會同時進行生成空間到to、from空間到to的兩個垃圾回收。
 
對於老年代,則直接進行mark_sweep回收。
對於“記錄集”(record set),是記錄代間引用的一個數組。它內部不能只記錄被引用對象,因為被引用對象被復制到to空間后,引用者本身的引用指針要更新,只記錄被引用的新生代內對象是無法找到被引用者的。所以,必須在記錄集中記錄老年代內對象。
 
更新記錄集的操作在分配新對象,並設置成老對象的一個field時進行:
 
write_barrier(obj, field, new_obj) {
  if obj >= $old_start && new_obj < $old_start && obj.remembered == false            // 條件,很明顯
    $rs[$rs_idx++] = obj                   // 更新記錄集
    obj.remembered = true               // 這標記用於防止重復加入記錄集
 
  *field = new_obj                           // 最終更新
}

從上面可見,老年代的對象中,加了一個域:remembered,用來標記是否在新生代的記錄集中,防止重復處理。

在分代垃圾回收的對象頭中,還需要加入兩個字段給新生代對象使用:
age:如上提到,是區分放在哪一代所用
forwarded:算法中用,判斷是否已經復制過
forwarding要不要:也要。因為是原生復制算法,這字段不需要放頭中,直接取一個域。
 
下面看具體算法:
 
分配算法,流程上很簡單,不行時進行新生代的垃圾回收。注意相關頭域的初始化:
new_obj(size) {
  if $new_free + size >=$survivor1_start
    minor_gc()           // 后面重點介紹,新生代回收!!!
    if $new_free + size >=$survivor1_start
      fail()
 
  obj = $new_free
  $new_free += size
  // 下面是初始化
  obj.age = 0
  obj.forwarded = obj.remembered = false
  obj.size = size
  return obj
}
上面忽略了一個錯誤,即分配一個大於新生代大小的超大對象。
在看新生代回收的邏輯前,先中斷下思路,先看一下具體的copy流程:
 
copy(obj) {
  if obj.forwarded == false                // 處理過的對象不關心,因為已經是垃圾了。
    if obj.age < AGE_MAX                  // 判斷拷到哪一代
      copy_data($to_suvivor_free, obj, obj.size)
      obj.forwarded = true
      obj.forwarding = $to_suvivor_free
      $to_suvivor_free.age++
 
      $to_suvivor_free += obj.size
      for child : children(obj.forwarding)          // 注意是forwarding,原書上應該錯了!拷貝並更新各子對象
        child = copy(child)
    else
      promote(obj)                                         // 升級
 
  return obj.forwarding
}
 
promote(obj) {
  new_obj = allocate_in_old(obj)
  if new_obj == NULL
    major_gc()               // mark_sweep,沒什么好說
    new_obj = allocate_in_old(obj)
    if new_obj == NULL
      fail()
  
  // 新生代內老對象上正常更新
  obj.forwarding = new_obj
  obj.forwarded = true
 
  // 下面是關鍵,跨代復制后,需要更新記錄集
  for child : children(new_obj)
    if (child < $old_start)                  // 當前對象已經復制到老年代,一旦發現其中有一個子對象在新生代,更新新生代的記錄集
      $rs[$rs_idx++] = new_obj        // 如前所說,記錄集內記錄的,必須是老年代中的對象
      new_obj.remembered = true
      return                                      // 只需要找到一個,就可以退出不再處理了
}

是時候回頭看一下新生代GC了:

minor_gc() {
  $to_survivor_free = $to_survivor_start
  for r : $root                   // 復制算法,就是從根開始將有用的都拷走
    if r < $old_start           // 相對一般復制算法的區別,要判斷分代
      r = copy(r)
 
  // 另一個與正常復制算法的區別,是記錄集中的老年代,也要當作根處理
  i = 0
  while i < $rs_idx
    has_new_obj = false                   
    for child : children($rs[i])
      if child < $old_start              // 找到了一個子對象在新生代,需要將其復制走
        child = copy(child)
        if child < $old_start          // 復制完仍在新生代(另一可能是,復制過程中,age滿了被復制到老年代!)
          has_new_obj = true        // 記錄,意味着,當前這條記錄集仍要保留
 
    if has_new_obj == false    // 需要將記錄集中記錄干掉
      $rs[i].remembered = false
      $rs_idx--
      swap($rs[i], $rs[$rs_idx])
    else
      i++
 
  swap($from_survivor_start, $to_suvivor_start)
}

以上便是新生代回收算法。有一些額外邏輯,如to空間滿了怎么辦,直接拷到老年代也行。

至於老年代回收,不提了,就是mark_sweep
 
來看下好處:
優點:
  1. 吞吐量很好
缺點:
  1. 基於一個“很多對象年輕時會死掉”這樣一個經驗性的假設,一旦假設不成立,分代的整個基礎都不穩,因為新生對象垃圾率沒有想像的高,那么新生代會產生大量復制,同時老年代垃圾多,mark_sweep頻繁運行。
  2. 寫入屏障(更新記錄集的操作)是一個額外的開銷:每個對象會在記錄集中占用內存(1字節?);且當它的運行時性能消耗大於新生代回收帶來的好處時,分代回收也就失去意義。
  3. 老年代gc的mark_sweep,無可避免地,對於最大暫停時間有影響。
  4. 跨代的循環引用無法一次性回收(只能等新生代的對象年紀到了,放到老年代中才能得到處理)
 
對於write_barrier的開銷,可以優化優化:比如,不記錄對象級別的記錄集,而是記錄某一段內存的跨代引用。這樣就可以以位圖來記錄,大幅減少內存開銷。但同時就引入了在內存塊中遍歷的額外操作。總之都沒法完美。
對於分代垃圾回收,也可以加以強化,不只分兩代,而是分多代,也是個優化思路。
 
下面介紹一個解決第三個、第四個缺點的一個略復雜的算法: train GC
算法略復雜,引入train這個概念,正是為了使算法便於理解。實際上,是對於老年代內存進行了一次二維的划分,將垃圾回收的范圍縮小到二維的某一“行”,同時,將具備引用關系的對象放到同一“行”中。
這里,“行”這概念,被抽象成了train,火車。每行中內存划的一個個分片,被抽象成car,車廂。
跨車廂與跨列車之間的引用,都有記錄集。但因為垃圾回收只回收“第一個”火車,且從前到后回收不同車廂,因此記錄集可以大大簡化, 不關心從前到后的跨引用。
 
首先,雖然是優化 老年代的垃圾回收算法,但對於新生代,也有變化:在 新生代復制中,不按age拷貝到新生代中了,而是直接拷入老年代(因為算法本身就是解決老年代回收效率問題,所以沒必須在新生代中復制來復制去了)。復制到老年代,需要確定上文的二維坐標,即train和car。具體復制邏輯:
 
// 往老年代的某一car上復制,邏輯不復雜
copy(obj, to_car) {
  if obj.forwarded == false
    if to_car.free + obj.size >= to_car.start + CAR_SZ
      to_car = new_car(to_car)
    copy_data(to_car.free, obj, obj.size)
    obj.forwarding = to_car.free
    obj.forwarded = true
    to_car.free += obj.size
    for  child : children(obj.forwarding)
      child = copy(child, to_car)                     // 引用關系的,都往同一car拷。后面可看到,即使car空間不足重分配,那也是在同一train內的。
 
  return obj.forwarding
}

再看是怎么調用的,怎么把引用關系的對象縷到一起的:

minor_gc() {
  // root引用的,就不管了,先拷到一個新car再說(老年代回收自會處理)
  to_car = new_car(NULL)                          // new_car(NULL)這個操作,不僅會創建新car,也會創建一個新train!!!!!!!!!!!!!!!!!!!!
  for r : $root
    if r < $old_start
      r = copy(r, to_car)
 
  // 從記錄集引用來的,說明老年代中必有引用它的對象,直接找到對應車廂即可
  for remembered_obj : $young_rs
    for child : children(remembered_obj)
      if child < $old_start
        to_car = get_last_car(obj_to_car(rememberd_obj))       //關鍵點:兩個操作,1找到引用它的老年對象所在car,2找到對應car所在train的最后一個car
        child = copy(child, to_car)
}

上面有一個邏輯要關注,從root引用的對象,是復制到新火車的,而從記錄集引用的對象,是拷貝到引用所在的火車。

老年代的垃圾回收的思路是從前到后的,第一列車第一車廂的優先:
 
major_gc() {
  has_root_reference = false
  to_car = new_car(NULL)           // 注意,新火車
 
  // 先處理根出發的對象 
  for r : $roots 
    if is_in_from_train(r)           // 只處理這個from_train,這個from_train是第一個train
      has_root_reference = true      // from_train中,存在着從根來的引用。這意味着,將它拷到train中的后面car后,本train仍然是被root引用的
      if is_in_from_car(r)           // 同樣只處理from_car,這個from_car是from_train中的第一個car
        r = copy(r, to_car)         // 移走到新火車中
  // 處理到這里后,第一train中第一車廂的根引用的活動對象,都已經拷走,拷到新火車中!
 
  // 這里這個額外的判斷,很重要
  if !has_root_reference && is_empty(train_rs($from_car)) 
    // 火車回收算法的核心邏輯:如果,本列車已經沒有從根的引用,也沒有從其他列車的引用,說明這是一整車垃圾,而不僅僅是一車廂!可以全部回收。循環引用也可以在這里被回收!!!
    reclaim_train($from_car)
    return
  
  // 處理從記錄集出發的引用對象。如果有從其他火車的引用,要進行各種調整。
  scan_rs(train_rs($from_car))
  scan_rs(car_rs($from_car))
  add_to_freelist($from_car)                              // 當前這from_car是可以回收了
  $from_car = $from_car.next
}
 
// 火車算法的核心邏輯:將從記錄集出發的引用對象歸整到同一火車
scan_rs(rs) {
  for remembered_obj : rs
    for (child : rememberd_obj)
      if is_in_from_car(child)
        to_car = get_last_car(obj_to_car(remembered_obj))     // 移動到引用它對象同一個train
        child = copy(child, to_car)
}

最后,看一下wirte_barrier

// 這里的操作是obj.field = new_obj這樣的
write_barrier(obj, field, new_obj) {
  if obj >= $old_start
    if new_obj < $old_start        // 老年代引用新生代,簡單
      add(obj, $young_rs)
    else                                     // 老年代引用老年代,就有點邏輯
      src_car = obj_to_car(obj)                // 這里的src, dest是引用的關系
      dest_car = obj_to_car(new_obj)
      if src_car.train_num > dest_car.train_num
        add(obj, train_rs(dest_car)
      else if src_car.car_num > dest_car.car_num
        add(obj, car_rs(dest_car)
      //上面兩上不完全的判斷條件看似漏了一些分支未處理,但其實是不需要處理的。因為垃圾回收過程從前往后進行,只會關心這里的兩種從后往前的引用方式。
  field = new_obj
}
到這里有個疑問,通過源引用對象,將對象拷貝到源對象所在的列車中,那么, 什么情況下,源引用對象與掃描對象會不在一個train中?上面minor已經提到了,new_car(NULL)時。具體是, 不管處理新生代往老年代復制,還是老年代自己復制,只要是復制根引用對象時,都會新建立火車。這邏輯也正確,根出發的對象作“火車頭”。
最后看一個示意圖:
形象的表示了,如何處理不同火車和不同車廂的跨車廂記錄集,最終使from_car全都拷貝走。
 
優點:
  1. 老年代的回收不會掃全堆,而是僅僅一車廂或,會使暫停時間很短;
  2. 循環垃圾一次性也可回收;
缺點:
  1. 吞吐量略差,因為計算記錄集量大了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM