分代垃圾回收,基於的是“
大部分的對象,在生成后馬上就會變成垃圾”這一經驗上的事實為設計出發點。此前討論過基於引事實的另一個垃圾回收算法,引用計數出的一些優化思路。
分代的關鍵是:
- 給對象記錄下一個age,隨着每一次垃圾回收,這個age會增加;
- 給不同age的對象分配不同的堆內內存空間,稱為某一代;
- 對某一代的空間,有適合其的垃圾回收算法;
- 對每代進行不同垃圾回收,一般會需要一個額外的信息:即每代中對象被其他代中對象引用的信息。這個引用信息對於當前代來說,扮演與"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
來看下好處:
優點:
- 吞吐量很好
缺點:
- 基於一個“很多對象年輕時會死掉”這樣一個經驗性的假設,一旦假設不成立,分代的整個基礎都不穩,因為新生對象垃圾率沒有想像的高,那么新生代會產生大量復制,同時老年代垃圾多,mark_sweep頻繁運行。
- 寫入屏障(更新記錄集的操作)是一個額外的開銷:每個對象會在記錄集中占用內存(1字節?);且當它的運行時性能消耗大於新生代回收帶來的好處時,分代回收也就失去意義。
- 老年代gc的mark_sweep,無可避免地,對於最大暫停時間有影響。
- 跨代的循環引用無法一次性回收(只能等新生代的對象年紀到了,放到老年代中才能得到處理)
對於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全都拷貝走。
優點:
- 老年代的回收不會掃全堆,而是僅僅一車廂或,會使暫停時間很短;
- 循環垃圾一次性也可回收;
缺點:
- 吞吐量略差,因為計算記錄集量大了。