天天看點

前沿實踐:垃圾回收器是如何演進的?

前沿實踐:垃圾回收器是如何演進的?

作者 | 齊光

來源 | 阿裡技術公衆号

點選閱讀上篇:底層原理:垃圾回收算法是如何設計的?

https://developer.aliyun.com/article/777750

注:如無特别說明,本文中垃圾回收器的内容都是基于 HotSpot Java 虛拟機展開的。

一 垃圾回收器簡介

工業界的垃圾回收器,一般都是上篇中幾種垃圾回收算法的組合實作。下圖中列舉了最常見及最新的幾種垃圾回收器,大多數的垃圾回收器均采用了分代設計(或者适用于分代場景),且一般有固定的搭配使用模式,每種垃圾回收器的用法和特性在這裡就不贅述了,有需要的話可以參考其他資料。圖中的垃圾回收器,還需要補充的一些内容有:

  • CMS 是适用于老年代的垃圾回收器,雖然在回收過程中可能也會觸發新生代垃圾回收。CMS 在 JDK 9中被聲明為廢棄的,在JDK 14中将被移除;
  • Parallel Scavenge 和大部分垃圾回收器都不相容,原因是其實作未基于 HotSpot VM 架構;
  • Parallel Scavenge + Parallel Old 的組合有自适應調節政策,适用于對吞吐量敏感的場景;
  • C4 和 ZGC 可以視為是同一種垃圾回收算法的不同實作,ZGC 目前還沒有分代設計(規劃中);
  • C4、ZGC、Shenandoah GC 的垃圾回收算法在多處是趨同的,同時各自也有比較獨特的設計理念。
前沿實踐:垃圾回收器是如何演進的?

各種垃圾回收器和垃圾回收算法間的關系如下:

  • Serial:标記-複制
  • Serial Old:标記-壓縮
  • ParNew:标記-複制
  • Parallel Scavenge:标記-複制
  • Parallel Old:标記-壓縮
  • CMS(Concurrent-Mark-Sweep):(并發)标記-清除
  • G1(Garbage-First):并發标記 + 并行複制
  • ZGC/C4:并發标記 + 并發複制
  • Shenandoah GC:并發标記 + 并發複制

可以看到,如果堆空間進行了分代,那麼新生代通常采用複制算法,老生代通常采用壓縮-複制算法。G1、C4、ZGC、Shenandoah GC 是幾種比較新的垃圾回收器,下面會結合算法實作,分别介紹這四種垃圾回收器的核心原理。

二 G1 垃圾回收器

G1是從JDK 7 Update 4及後續版本開始正式提供的,從JDK 9開始G1作為預設的垃圾回收器。

G1 的垃圾回收是分代的,整個堆分成一系列大小相等的分區(Region)。新生代的垃圾回收(Young GC)使用的是并行複制的方式,一旦發生一次新生代回收,整個新生代都會被回收(根據對暫停時間的預測值,新生代的大小可能會動态改變)。老年代回收不會回收全部老年代空間,隻會選擇一部分收益最高的 Region,回收時一般會搭便車——把待回收的老年代 Region 和所有的新生代 Region 放在一起進行回收,這個過程一般被稱為 Mixed GC,Young GC 和 Mixed GC 最大的不同就在于是否回收了老年代的 Region。注意:Young GC 和 Mixed GC 都是在進行對象标記,具體的回收過程與這兩個過程是獨立的,回收時 GC 線程會根據标記的結果選擇部分收益高的 Region 進行複制。從某種角度來說,G1 可視為是一種「标記-複制算法」的實作(注意這裡不是壓縮算法,因為 G1 的複制過程完全依賴于之前标記階段對對象生死的判定,而不是自行從 GC Roots 出發周遊對象引用關系圖)。

G1 老年代的标記過程大緻可以分為下面四個階段:

  1. 初始标記階段(STW)
  2. 并發标記階段
  3. 再标記階段(STW)
  4. 清理階段(STW)

上面的四個階段中,有三個階段都是 STW 的,每個階段的内容就不具體叙述了。為了降低标記階段中 STW 的時間,G1 使用了記錄集(Remembered Set, RSet)來記錄不同代際之間的引用關系。在并發标記階段,GC 線程和應用線程并發運作,在這個過程中涉及到引用關系的改變,G1 使用了 SATB(Snapshot-At-The-Beginning) 記錄并發标記時引用關系的改變,保證并發結束後引用關系的正确性。實作 RSet 和 SATB 的關鍵就是之前提到的寫屏障。

G1 中的寫屏障分為 pre_write_barrier 和 post_write_barrier,如下面的代碼所示,應用 field 将要被賦予新值 value,由于 field 指向的舊的引用對象會丢失引用關系,是以在指派之前會觸發 pre_write_barrier,更新 SATB 日志記錄,記錄下引用關系變化時舊的引用值;在正式指派之後,會執行 post_write_barrier,更新新引用對象所在的 RSet。

// 指派操作,将 value 指派給 field 所在的引用
void assign_new_value(oop* field, oop value) {  
  pre_write_barrier(field);         // 步驟1
  *field = value;                   // 步驟2
  post_write_barrier(field, value); // 步驟3
}           

SATB 和 RSet 的更新都是通過寫屏障來實作的,但是更新操作并不都是在屏障裡做的,否則會對應用線程造成很大的幹擾。G1 中的寫屏障實作為線程隊列+全局隊列的兩級結構,當寫屏障觸發後,記錄會首先加入到線程隊列(線程隊列是獨立、定長的)中,線程隊列區滿了後,就會加入到全局隊列區裡,換一個新的、幹淨的隊列繼續執行下去,全局隊列裡的記錄超過一定的門檻值,相關線程就會去做相應處理(更新 RSet 或是将記錄壓入标記棧中)。

RSet

首先來看一下 RSet,這個資料結構是為了記錄對象代際之間的引用關系而提出的,目的是加速垃圾回收的速度。引用關系的記錄方式通常有兩種方式:「我引用了誰」和「誰引用了我」,前一種記錄簡單,但是在回收時需要對記錄集做全部掃描,後一種記錄複制,占用空間大,但是在回收時隻需要關注對象本身,即可通過 RSet 直接定位到引用關系。G1 的 RSet 使用的是後一種「誰引用了我」的記錄方式,其資料結構可了解為一個哈希表。每次向引用類型字段指派時,會觸發:「寫屏障 -> 線程隊列 -> 全局隊列 -> 并發 RSet 更新」這樣一個過程。

G1 RSet 記錄的是對象之間的引用關系,那到底需要記錄哪些引用關系呢?

  • Region 内部的引用:無需記錄,因為垃圾回收時 Region 内對象肯定要掃描的;
  • 新生代 Region 間的引用:無需記錄,因為新生代在 Young GC 和 Mixed GC 中都會被整體回收:
  • 老年代 Region 間的引用:需要記錄,因為老年代回收時是按 Region 進行回收的,是以需要記錄;
  • 新生代 Region 到老年代 Region 的引用:無需記錄,Mixed GC 中會把整個新生代作為 GC Roots;
  • 老年代 Region 到新生代 Region 的引用:需要記錄,Young GC 時直接将這種引用加入 GC Roots。

具體在回收時,RSet 的作用是這樣的:進行 Young GC 時,選擇新生代所在的 Region 作為 GC Roots,這些 Region 中的 RSet 記錄了老年代->新生代的的跨代引用(「誰引用了我」),進而可以避免了掃描整個老年代。進行 Mixed GC 時,「老年代->老年代」之間的引用,可以通過待回收 Region 中的 RSet 記錄獲得,「新生代->老年代」之間的引用通過掃描全部的新生代獲得(前面提到過 Mixed GC 會搭 Young GC 的便車),也不需要掃描全部老年代。總之,引入 RSet 後,GC 的堆掃描範圍大大減少了。

前沿實踐:垃圾回收器是如何演進的?

SATB

SATB 在算法篇介紹過,其實就是在一次 GC 活動前所有對象引用關系的一個快照。之是以需要快照,是因為并發标記時,GC 線程一邊在标記垃圾對象,應用線程一邊還在生成垃圾對象,如果我們記錄下快照,以及并發标記期間引用發生過變更的對象(包括新增對象和引用發生變更的對象),則我們就可以實作一次完整的标記。

SATB 的過程可以簡單了解為:當并發标記階段引用的關系發生變化時,舊引用所指向的對象就會被标記,同時其子引用對象也會被遞歸标記,這樣快照的完整性就得到保證了。SATB 的記錄更新是由 pre_write_barrier 寫屏障觸發的,下面是 G1 論文中介紹的 SATB 原始表述,具體實作時,還是由兩級的隊列結構緩存,再由并發标記線程批量處理進入标記隊列 satb_mark_queue。

void pre_write_barrier(oop* field) {  
  oop old_value = *field;  
  if (old_value != null) {  
    if ($gc_phase == GC_CONCURRENT_MARK) {
      $current_thread->satb_mark_queue->enqueue(old_value);  
    }  
  }  
}           

是以,G1 在結束并發标記後還有一個需要 STW 的再标記(remark)階段就可以了解了,因為如果不引入一個 STW 的過程,那麼新的引用變更會不斷産生,永遠就無法達成完成标記的條件。再标記階段,因為有了SATB 的設計,則隻需要掃描 satb_mark_queue 隊列裡的引用變更記錄就可以對此次 GC 活動形成完整标記了(可以對比 CMS 的 remark 階段)。

三 ZGC/C4 垃圾回收器

G1 目前的發展已經相當成熟了,從衆多的測評結果上看,也達到了其最初的設計目标。但是 G1 也有下面這些不足之處:

  • 堆使用率不高:原因就是引入的 RSet 占用記憶體空間較大,一般會達到1%~20%;
  • 暫停時間較長:通常 G1 的 STW 時間要達到幾十到幾百毫秒,還不夠低。

G1 由于使用了并發标記,是以标記階段對暫停時間的影響較小,暫停時間主要來自于标記階段結束後的 Region 複制(一般占用整個 GC STW 的 80%),這個階段使用的是複制算法:GC 把一部分 Region 裡的活的對象複制到空 Region 裡去,然後回收原本的 Region的空間。上述過程是無法并發進行的(并發複制一般需要通過「讀屏障」來實作,G1 并未使用),因為需要一邊移動對象,同時一邊修正指向這些對象的引用(并發期間應用線程可能會通路到這些對象),G1 雖然在複制對象時也做到了并行化,但大量對象的複制會涉及到很多記憶體配置設定、變量複制的操作,非常耗時。

ZGC 就是針對上述 G1 的不足提出的,2017 年 Oracle 将 ZGC 貢獻給 OpenJDK 社群,2018年 JEP-333 正式引入:ZGC: A Scalable Low-Latency Garbage Collector (Experimental)。ZGC 的設計思路借鑒了一款商業垃圾回收器——Azul Systems公司的的 C4(Continuously Concurrent Compacting Collector) 垃圾回收器,後者是一款分代式的、并發的、協作式垃圾回收算法,目前隻在 Azul System 公司的 Zing JVM 得到實作,詳細介紹請參考論文:

http://go.azul.com/continuously-concurrent-compacting-collector

。ZGC 和 C4 背後的算法均是 Azul Systems 很多年前提出的 Pauseless GC,差別在于 C4 是一種分代的實作,而 ZGC 現在還是不分代的。

ZGC 可以視為是一種「标記-複制」算法的并發實作,其中标記階段是并發的,複制階段又分為轉移(Relocate)和重定位(Remap)兩個子階段,也都是并發的,通過全程并發,可以讓暫停時間保持在10ms以内。标記和複制看上去是兩個串行的階段,其實也是有重疊的,譬如重定位(remap)階段實際上被合并到标記階段中,即在标記的時候如果發現對象引用到老的位址,這時會先完成重定位更新對象的引用關系,然後再标記對象。

下面具體來看一下 ZGC 是如何高效地設計并發操作的。

前沿實踐:垃圾回收器是如何演進的?

算法設計

ZGC 在進行并發标記和并發複制時也會面臨引用關系改變造成的「漏标」和「漏轉移」,解決的方法是引入 SATB,和 G1 中通過寫屏障實作的 SATB 不同,ZGC 是通過「讀屏障」+「多視圖映射」來實作 SATB 的。讀屏障在算法篇已經介紹過了,它發生在從堆上加載一個對象引用時,後續使用該引用不會觸發讀屏障。

讀屏障是實作 SATB 的關鍵,除此之外,ZGC 引入讀屏障後,也實作了對象的并發複制,彌補了 G1 垃圾回收算法中最大的不足。讀屏障和寫屏障解決的問題是不一樣的,标記-清除算法是不需要讀讀屏障的,因為沒有記憶體移動的過程(壓縮或者複制),但是對于複制算法,如果不用讀屏障去跟蹤讀的情況,并發執行的應用線程可能就會讀取到錯誤的引用。引入讀屏障後,GC 線程可以并發執行,應用讀取的引用如果發生了轉移或者修改,可以在讀屏障内完成記憶體的轉移或者重定位,也就不會出現長時間的 STW 了。

可以通過從堆空間中加載對象的執行代碼這裡對讀屏障有更直覺的感受,這裡調用的load_barrier_on_oop_field_preloaded 就是讀屏障。

template <DecoratorSet decorators, typename BarrierSetT>
template <typename T>
inline oop ZBarrierSet::AccessBarrier<decorators, BarrierSetT>::oop_load_in_heap(T* addr) {
  verify_decorators_absent<ON_UNKNOWN_OOP_REF>();
  const oop o = Raw::oop_load_in_heap(addr);
  return load_barrier_on_oop_field_preloaded(addr, o);
}           

讀屏障觸發後,SATB 的具體執行細節就不展開了,SATB 雖然實作的方式不一樣,如 G1 中是通過寫屏障實作的,但是其核心思想是一緻的:标記開始後,把引用關系快照裡所有的活對象都看作是活的,如果出現了引用關系變更,則把舊的引用所指向的對象進行标記或記錄下來。

讀屏障的開銷是很大的,因為堆的讀操作頻率是遠高于寫操作的,ZGC 是如何對對象進行标記,實作高效的 SATB 算法的呢?答案是上面提到過的「多視圖映射」,下面簡單介紹下。

多視圖映射

和 G1 一樣,ZGC 将記憶體劃分成小的分區,在ZGC中稱為頁面(page),但是 ZGC 中的頁面大小并不是固定的,分為小頁面、中頁面和大頁面,其中小頁面大小為 2MB,中頁面大小為 32MB,而大頁面則和作業系統中的大頁面的大小一緻。

多視圖映射指的是在 ZGC 的記憶體管理中,同一實體位址的對象可以映射到多個虛拟位址上,虛拟位址有 Marked0、Marked1 和 Remapped 三種,在 ZGC 中這三個虛拟空間在同一時間點有且僅有一個空間有效。下表中顯示了這三個位址空間的範圍,[0~4TB)對應的是Java的堆空間,該虛拟位址對應用程式可見,經 ZGC 映射後,真正使用的就是 Marked0、Marked1 和 Remapped 這三個視圖對應的位址空間,這三個視圖的切換是由垃圾回收的不同階段觸發的。

+--------------------------------+ 0x0000140000000000 (20TB)
|         Remapped View          |
+--------------------------------+ 0x0000100000000000 (16TB)
|     (Reserved, but unused)     |
+--------------------------------+ 0x00000c0000000000 (12TB)
|         Marked1 View           |
+--------------------------------+ 0x0000080000000000 (8TB)
|         Marked0 View           |
+--------------------------------+ 0x0000040000000000 (4TB)           

既然多個視圖映射的是同一個實體對象,那麼就需要對引用(指針)進行若幹改造,ZGC 在堆引用(指針)上增加了若幹中繼資料資訊:前42位保留為對象的實際位址(在源代碼中作為偏移量引用),42位位址理論上提供了4TB的堆限制,其餘的位用于标記:Finalizable、Remapped、Marked1 和 Marked0 (保留一位以備将來使用),這種引用也被稱為着色指針(Color Pointers)。

6                  4 4 4   4 4                                             0
3                  7 6 5   2 1                                             0
+-------------------+-+----+-----------------------------------------------+
|00000000 00000000 0|0|1111|11 11111111 11111111 11111111 11111111 11111111|
+-------------------+-+----+-----------------------------------------------+
|                  | |    |
|                  | |    * 41-0 Object Offset (42-bits, 4TB address space)
|                   | |
|                  | * 45-42 Metadata Bits (4-bits)  0001 = Marked0
|                  |                                 0010 = Marked1
|                  |                                 0100 = Remapped
|                  |                                 1000 = Finalizable
|                  |
|                  * 46-46 Unused (1-bit, always zero)
|
* 63-47 Fixed (17-bits, always zero)           

為什麼要使用多視圖映射呢?最直接的好處就是可以加快标記和轉移的速度。比如在标記階段,标記某個對象時隻需要轉換位址視圖即可,而位址視圖的轉化非常簡單,隻需要設定位址中第42~45位中相應的标記位即可。而在以前的垃圾回收器實作中,需要修改相應對象頭的标記位,而這會有記憶體存取通路的開銷。在 ZGC 标記對象中無須任何對象通路,這就是ZGC在标記和轉移階段速度更快的原因。

把讀屏障、 SATB 和多視圖映射放在一起,可以總結 ZGC 中的并發算法的核心要點為:

  • SATB 保證了在并發标記和并發複制階段引用變更的正确性;
  • 在并發标記階段,通過标記引用(指針)實作對對象的周遊;
  • 在并發轉移階段,讀屏障會保證并發轉移時應用線程讀出的指針為對象的新位址;
  • 在并發重定位階段,讀屏障會保證應用線程可以擷取到轉移後的對象的新位址。

引用 R 大(RednaxelaFX)的話就是:與标記對象的傳統算法相比,ZGC 在指針上做标記,在通路指針時加入 Load Barrier(讀屏障),比如當對象正被 GC 移動,指針上的顔色就會不對,這個屏障就會先把指針更新為有效位址再傳回,也就是,永遠隻有單個對象讀取時有機率被減速,而不存在為了保持應用與 GC 一緻而粗暴整體的 Stop The World。

算法實作

下面通過一個簡單的例子看了解 ZGC 的并發執行過程。

前沿實踐:垃圾回收器是如何演進的?

第一次執行并發标記前,整個記憶體空間的位址視圖被設定為 Remapped,并發标記結束後,對象的位址視圖要麼是 Marked0,要麼是 Remapped。

  • 如果位址視圖是 Marked0,說明對象是在标記階段被标記或者是新建立的;如上圖所示 A、B 對象均可以通過 GC Roots 通路到,屬于活躍的對象,對象 D 在并發期間被建立,也屬于活躍對象,均被映射到 Marked0 位址視圖;
  • 如果位址視圖是 Remapped,說明對象在标記階段既不能通過根集合通路到(直接或間接通路),也沒有應用線程通路它,是以是不活躍的,即對象所使用的記憶體可以被回收。上圖中的對象 C 不能從 GC Roots 通路,屬于不活躍對象,位址視圖還是 Remapped,表示為垃圾對象。

在并發标記期間,如果應用線程通路對象且對象的位址視圖是 Remapped,說明對象是前一階段配置設定的,隻要把該對象的視圖從 Remapped 調整為 Marked0 就能防止對象漏标。

标記階段結束後,所有活躍對象的位址會被存儲在一個「對象活躍資訊表」的集合中,然後進入并發轉移(Relocated)階段。轉移階段轉移線程會從「對象活躍資訊表」中把活躍對象轉移到新的記憶體中,并回收對象轉移前的記憶體空間(注意:如果頁面不需要轉移,那麼頁面裡面的對象也就不需要轉移)。并發轉移結束後,對象的位址視圖要麼是 Remapped,要麼是 Marked0。

  • 如果位址視圖是 Marked0,說明該對象在垃圾回收的标記階段已經被标記,但是在轉移階段未被轉移(如下圖中的 B 和 D);
  • 如果位址視圖是 Remapped,說明對象在并發轉移階段被轉移或者被通路過(如下圖中的 G 和 F,C 因為不活躍可能就直接被回收了)。
前沿實踐:垃圾回收器是如何演進的?

在并發轉移階段,如果應用線程通路的對象在對象活躍資訊表中,且對象的位址視圖為 Marked0,說明對象是标記階段标記的活躍對象,是以需要轉移對象,對象轉移以後,對象的位址視圖從 Marked0 調整為 Remapped。

前沿實踐:垃圾回收器是如何演進的?

并發轉移結束後,會再次進入下一次的标記階段。新的标記階段為了區分「本次标記的活躍對象」和「上次标記的活躍對象」,使用了 Marked1 來辨別本次并發标記的結果,即:用 Marked1 表示本次垃圾回收中識别的活躍對象(上圖中的 H 和 F),用 Marked0 表示前一次垃圾回收的标記階段被标記過的活躍對象,且該對象在轉移階段未被轉移,但是在本次垃圾回收中被識别為不活躍對象(上圖中的 B 和 D)。注意:在并發轉移完活躍對象之後,引用還指向對象轉移之前的位址,ZGC 通過「對象轉移位址資訊表」存儲頁面對象轉移前和轉移後的位址,在新一輪垃圾回收啟動後,在标記時會執行重定位的操作。

ZGC 雖然是全程并發設計的,但也還是有若幹個 STW 的階段的,包括并發标記中的初始化标記和結束标記階段,并發轉移中的初始轉移階段等。事實上,完全沒有 STW 的垃圾回收器是不存在的,即便是 Azul 的 PGC(原汁原味基于 Pauseless GC 算法實作),也是有非常短暫的 STW 階段,譬如 GC Roots 的掃描。

四 Shenandoah 垃圾回收器

Shenandoah GC 最早是由 Red Hat 公司發起的,後來被貢獻給了 OpenJDK,2014 年通過 JEP-189:A Low-Pause-Time Garbage Collector (Experimental)正式成為 OpenJDK 的開源項目,Shenandoah GC 出現的時間比 ZGC 要早很多,是以發展的成熟度和穩定性相較于 ZGC 來說更好一些,實作了包括括C1屏障、C2屏障、解釋器、對 JNI 臨界區域的支援等特性。

和 ZGC 一樣,Shenandoah GC 也聚焦在解決 G1 中産生最長暫停時間的「并行複制」問題,通過與 ZGC 不一樣的方式,實作了「并發複制」,在 Shenandoah GC 中也未差別年輕代與老年代。ZGC實作并發複制的關鍵是:讀屏障 + 基于着色指針(Color Pointers)的多視圖映射,而 Shenandoah GC 實作并發複制的關鍵是:讀寫屏障 + 轉發指針(Brook Pointers),轉發指針(Brook Pointers)的原理将在下面詳細介紹,其過程可以參考論文:Trading Data Space for Reduced Time and Code Space in Real-Time Garbage Collection on Stock Hardware。

Shenandoah GC 的 回收周期和 ZGC 非常類似,大緻也可以分為并發标記和并發複制兩個階段,在并發标記階段,也是通過 讀屏障+ SATB 來實作的,并發複制階段也分為并發轉移和并發重定位兩個子階段。

并發标記階段的 SATB 在這裡就不詳細介紹了,這裡主要看一下 Shenandoah GC 是如何實作并發複制的。

Shenandoah GC 将堆分成大量同樣大小的分區(Region) ,分區大小從 256KB 到 32MB不等。在進行垃圾回收時,也隻是會回收部分堆區域。上面提到,Shenandoah GC 實作高效讀屏障的關鍵是增加了 轉發指針(Brook Pointers)這個結構,這是對象頭上增加的一個額外的資料,在讀寫屏障觸發時時可以通過 Brook Pointer 直接通路對象。轉發指針要麼指向對象本身,要麼指向對象副本所在的空間,如下圖所示:

前沿實踐:垃圾回收器是如何演進的?

Shenandoah GC 使用寫屏障+轉發指針完成了并發複制,其過程可以用下面的僞代碼表示:

stub evacuate(obj) {
    if(in-colleciton-set(obj) && fwd-ptrs-to-self(obj)) {
        copy = copy(obj);
        CAS(fwd-ptr-addr(obj), obj, copy);
    }
}           

上面并發轉移的詳細過程如下:首先判斷待轉移對象是否在待回收集合中(這個集合根據标記階段的結果生成),同時轉移指針是否指向了自己,如果沒有在待收回集合,則不用轉移,如果對象的轉移指針已經指向了其他位址,說明已經轉移過了,也不用轉移;然後進行對象複制;對象複制結束後,會通過 CAS 的方式更新轉移指針的值,使其指向新的複制對象所在的堆空間位址,如果 CAS 失敗,會多次重試。

Shenandoah GC 使用讀屏障+轉發指針保證轉移過程中或轉移結束後,應用線程可以讀取到真實的引用位址,保證了資料的一緻性,因為如果不這樣做,可能會導緻一些線程使用舊對象,而另一些線程使用新對象。

需要注意的是,在 ZGC 中并發重定位和并發标記階段是重合的,而在 Shenandoah GC 在某些情況下,可能會把并發标記、并發轉移和并發重定位合并到同一個并發階段内完成,這種回收方式在 Shenandoah GC 中被稱為周遊回收,細節請參考相關資料。如下圖所示,第1個回收周期會進行并發标記,第2回收周期會進行并發标記和并發轉移,第3個以後的回收周期會同時執行并發标記、并發轉移和并發重定位。

前沿實踐:垃圾回收器是如何演進的?

我們來看一下并發複制的具體過程。

步驟1:将對象從 From 複制到 to 空間,同時将新對象的轉移指針指向新對象自己。

前沿實踐:垃圾回收器是如何演進的?

步驟2:将舊對象的轉移指針通過 CAS 的方式指向新對象。

前沿實踐:垃圾回收器是如何演進的?

步驟3:将堆中其他指向舊對象的引用,更新為新對象的位址,如果在這個過程中有應用線程通路到了舊對象,則會通過讀屏障的方式将新對象的位址傳回給新的應用。

前沿實踐:垃圾回收器是如何演進的?

步驟4:所有的引用被更新,舊對象所在的分區可以被回收。

前沿實踐:垃圾回收器是如何演進的?

再次回顧一下 Shenandoah GC 裡使用的各種屏障:讀對象時,會首先通過讀屏障來解析對象的真實位址,當需要更新對象(或對象的字段),則會觸發寫屏障,将對象從 From 空間複制到 to 空間。讀寫屏障在底層的應用,可以用下面的一個例子去了解。

void updateObject(Foo foo) {
  // 讀操作
  Bar b1 = foo.bar;
 
  // 讀操作
  Baz baz = b1.baz;
  // 寫操作         
  b1.x = makeSomeValue(baz);  
}           

Shenandoah GC 中讀寫屏障出現的位置:

void updateObject(Foo foo) {
  // 讀屏障
  Bar b1 = readBarrier(foo).bar;             
  
  // 讀屏障
  Baz baz = readBarrier(b1).baz;             
  X value = makeSomeValue(baz);
  // 寫屏障
  writeBarrier(b1).x = readBarrier(value);  
}           

一言以蔽之,Shenandoah GC 的并發複制是基于讀屏障+寫屏障共同實作的( ZGC 隻使用了讀屏障)。Shenandoah GC 中所有的資料寫操作均會觸發寫屏障,包括對象寫、擷取鎖、hash code 的計算等,是以在具體實作時 Shenandoah GC 對寫屏障也有若幹的優化(譬如從循環邏輯中移除寫屏障)。Shenandoah GC 還使用了一種稱之為「比較屏障」的機制來解決對象引用間的比較操作,特别是同一個對象分别處于 From 和 to 空間時的比較。此外,Shenandoah GC 裡屏障也不需要特殊的硬體支援和作業系統支援。

Shenandoah GC 更适合用在大堆上,如果CPU資源有限,記憶體也不大,比如小于20GB,那麼就沒有必要使用Shenandoah GC。Shenandoah GC 在降低了暫停時間的同時,也犧牲了一部分的吞吐,如果對吞吐有較高的要求,則還是建議使用傳統的基于 STW 的 GC 實作,譬如 Parallel 系列垃圾回收器。

五 總結與回顧

在這一篇文章中,我們看到了幾種比較前沿的垃圾回收器:G1/C4/ZGC/Shenandoah GC,在它們的諸多實作細節中,我們也可以看到 Java 垃圾回收器的一大技術趨勢:在大記憶體的前提下,通過并發的方式降低 GC 算法在标記和轉移對象時對應用程式的影響。CMS 做到了并發标記,G1降低了并發标記的成本,同時還通過并行複制的方式對部分堆記憶體進行了整理,ZGC、C4、Shenandoah GC 進一步降低了并發标記時的 STW 的時間,同時通過并發複制的方式将對象轉移時的暫停時間最小化。并發算法降低了應用暫停的時間,但與此同時我們也需要看到:并發算法可以正常執行的前提是「垃圾回收的速度大于對象的配置設定速度」,這也就意味着并發算法需要更大的堆空間,同時需要預留部分空間用來「喘息」。

在并發算法中,讀寫屏障和SATB是非常關鍵的,它們共同保證了并發操作時引用關系的正确性,相信通過對上述垃圾回收器的介紹,可以對這幾個概念了解得更加透徹。

參考資料

[1]

http://dinfuehr.github.io/blog/a-first-look-into-zgc/ [2] https://rkennke.wordpress.com/2013/06/10/shenandoah-a-pauseless-gc-for-openjdk/ [3] https://shipilev.net/talks/devoxx-Nov2017-shenandoah.pdf [4] [5] https://dl.acm.org/doi/10.1145/800055.802042 [6] http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.63.6386&rep=rep1&type=pdf [7] https://www.infoq.com/articles/tuning-tips-G1-GC/ [8] https://developers.redhat.com/blog/2019/06/27/shenandoah-gc-in-jdk-13-part-1-load-reference-barriers/