G1 收集器


基礎知識

性能指標

在調優Java應用程序時,重點通常放在兩個主要目標上:響應性吞吐量

 響應性Responsiveness 是指應用程序對請求的數據做出響應的速度:

  • 桌面用戶界面對事件的響應速度
  • 網站返回頁面的速度
  • 數據庫查詢的返回速度

 吞吐量Throughput 專注於最大程度地提高應用程序在特定時間段內的工作量:

  • 在給定時間內完成的事務次數
  • 批處理程序在一小時內可以完成的作業數
  • 一小時內可以完成的數據庫查詢數

較長的暫停時間pause time對於注重響應性的應用程序是不可接受的,但對於注重吞吐量的應用程序則無傷大雅。前者重點是在短時間內做出響應,后者則側重與長時間運行的處理效率。

GC 基礎

GC Root

可達性分析是 Java GC 算法的基礎,基本思路就是以一系列名為 GC Roots 對象作為起始點,通過引用關系遍歷對象圖,如果一個對象到 GC Roots 間沒有任何可達路徑相連時,則說明此對象可以被回收。

可以作為 GC Roots 的對象:

  • 虛擬機棧(棧幀中的本地變量表)中引用的對象
  • 本地方法棧中JNI(即一般說的native方法)中引用的對象
  • 方法區中類靜態屬性引用的對象
  • 方法區中常量引用的對象

三色標記

可達性分析中重要的一環就是遍歷整個堆,並標記其中的存活對象。一種常用的標記算法是 三色標記法tri-color marking

每個對象可能為以下 3 種顏色之一:

  • white — 未被標記
  • gray — 本身已標記,但部分引用的對象未被標記(動圖的黃色對象)
  • black — 本身已標記,且所有引用的對象完成標記(動圖的藍色對象)

標記算法從 GC Roots 出發遍歷堆,可達對象先標記 gray,然后再標記 為 black。

遍歷完成之后所有可達對象都是 black 的,此時所有標記為 white 的對象都是可以回收的。

當實現並發標記算法時,必須防止 white 對象被漏標,否則可能導致不該回收的對象被回收。


分代收集

傳統垃圾收集器將堆分成三個部分:年輕代YoungGen = Eden + Survivor,老年代OldGen和永久代PermGen,每個區域內存連續且大小固定。

  • 年輕代:一次性使用的臨時對象(例如:方法中構造的臨時對象)
  • 老年代:被長期引用的常駐對象(例如:緩存對象、單例對象)
  • 永久代:JVM 運行過程中一直存在的對象(例如:字符串常量、類信息)

將堆內存進行划分后,可以按照對象生命周期長短,在不同區域使用不同的回收算法,提高 GC 的效率。


算法分類

Mark and Sweep標記-清除

 用一個空閑列表free-list記錄失效對象占用的內存區域,方便后續重新分配給新對象。

  • 回收原理簡單,GC 停頓時間短
  • 維護空閑列表需要一定的空間開銷
  • 內存碎片較多,可能導致內存分配失敗

Mark-Sweep-Compact標記-整理

 將所有存活對象移動到內存區域的開頭,剩余的連續內存區域都是可用的空閑空間。

  • 通過指針碰撞查找空閑空間,分配速度快
  • 內存碎片少,內存分配失敗概率低
  • 復制對象會導致較長時間的 GC 停頓

Mark and Copy標記-復制

 將內存划分為活動區間空閑區間,前者用於動態分配對象,后者用於容納 GC 存活對象。
 GC 時只需將存活對象從前者復制到后者,然后交換兩者的角色即可。

  • 標記和復制在同一階段同時進行,當存活對象少時回收效率極高
  • 需要預留一個空閑空間用於容納存活對象,造成內存浪費

CMS 回顧

CMS Concurrent Mark-Sweep 是一個采用 標記-清除 算法的老年代收集器。
它通過與應用程序線程並發執行大多數垃圾回收工作,來最大程度地減少由於 GC 導致的暫停。

通常情況下,CMS 收集器不會復制或壓縮活動對象,這意味着無需移動活動對象即可完成垃圾回收。
然而過多的內存碎片可能造成分配失敗,最終導致 FullGC。可以通過分配更大的堆來規避這一問題。

CMS 對老年代的回收可以分為以下幾個步驟:

  • Initial Mark \(\tiny \textsf{(STW)}\) 初始標記

    • 標記 GC Roots 直接可達的老年代對象
    • 遍歷新生代存活對象,標記直接可達的老年代對象

  • Concurrent Mark 並發標記

    GC 線程遍歷 Initial Mark 階段標記出來存活的老年代對象,然后遞歸標記這些可達的對象。

    該階段與應用線程並發運行,期間會發生新生代對象晉升、老年代對象引用關系更新,需要對這些對象進行重新標記,避免發生遺漏。

    CMS 用一個card-table管理老年代,並發標記過程中,某個對象的引用關系發生了變化,則將對象所在的內存塊標記為 Dirty Card

    CMS 使用增量更新incremental update解決並發修改導致的漏標問題:把 black 對象重新標記為 grey,下次重新掃描其引用。

  • Preclean 預清理

    這一階段主要是處理 Concurrent Mark 階段中引用關系改變,導致沒有標記到的存活對象的。通過並發地重新掃描這些對象,預清理階段可以減少 Remark 階段的 STW。

    這個階段會處理前一個階段被標記為 Dirty Card 的部分,將其中變化了的對象作為 GC Root 再進行掃描並重新標記。

  • Abortable Preclean 可終止的預清理

    這個階段作用與 Preclean 類似,但可以通過設置 掃描時長(默認5秒)或 Eden 區使用占比(默認50%)控制本階段的結束時機。

    增加這一階段的原因,是期待這期間能發生一次 YoungGC 清理無效的年輕代對象,減少 Remark 階段掃描年輕代的時間。

  • Remark \(\tiny \textsf{(STW)}\) 重新標記

    這個階段同時掃描 YoungGen 與 OldGen,重新標記整個老年代中所有存活對象。

    由於之前的 Concurrent MarkPreclean 階段是與用戶線程並發執行的,年輕代對老年代的引用可能已經發生了改變,Remark 要花很多時間處理這些改變,會導致長時間的 STW。

    此外,即使新生代的對象已經不可達了,CMS 也會使用這些不可達的對象當做的 GC Roots 來掃描老年代,導致部分失效的老年代對象無法被及時回收。

    可以加入參數 -XX:+CMSScavengeBeforeRemark,在重新標記之前,先執行一次 YoungGC,回收掉年輕代的對象無用的對象。這樣進行年輕代掃描時,只需要掃描 Survivor 區的對象即可,一般 Survivor 區非常小,這大大減少了掃描時間。

  • Concurrent Sweep 並發清理

    至此,老年代所有存活的對象已經被標記完成。這個階段主要是清除那些沒有標記的對象並且回收空間。

    被回收的空間會被添加到 空閑列表中,以供以后分配。這一過程可能會對空閑空間進行合並,但是不會移動存活對象。

    由於該階段是與應用線程並發運行的,自然就還會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之后,無法在當次收集中處理掉它們。只好留待下一次GC時再清理掉。這一部分垃圾就稱為 浮動垃圾

  • Resetting 重置

    清除數據結構,並重置定時器,為下一輪 GC 做准備。

G1 算法

設計目的

G1 Garbage-First 是一種服務器端的垃圾收集器:

  • 可以與應用程序線程並行運行,減少 STW
  • 整理空閑空間減少內存碎片,但不引入較長的 GC 暫停時間
  • 提供可預測的GC暫停時間,無需犧牲很多吞吐量

G1 能夠在大內存的多處理器計算機上,保證 GC 暫停時間可控,並實現高吞吐量。

其最終目的是取代 CMS 成為服務端 GC 更好的解決方案:

  • 采用 標記-整理 算法,可以避免使用細粒度的空閑列表進行分配。簡化了收集器設計並消除了潛在的碎片問題。
  • 使用 增量回收incremental collecting 算法,其 GC 暫停時間比 CMS 更具可預測性,並允許用戶指定期望的暫停時間。

基本概念

G1 將堆划分為一組大小相等的且連續的堆區域Region

G1 中新生代與老年代不再連續,每個區域可以在 EdenSurvivorOld 之間切換角色。此外,還有一類被稱為 Humongous 的巨型區域,用於容納體積 ≥ 標准區域大小的50%的對象。

JVM 通常會將內存划分為 2000個區域,每個大小從 1 到 32Mb 不等,由 JVM 在啟動時通過 -XX:G1HeapRegionSize 指定。

每個區域會被進一步細分成多個卡片Card,每個大小為 512Kb,用於實現細粒度的引用統計。

分區設計可以避免一次收集整個堆,每次 GC 只收集區域的一個子集 CSetcollection set

根據回收區域的不同,可以將 GC 分為:

  • YoungGCCSet 只包含 Young 區域
  • MixedGCCSet 同時包含 YoungOld 區域
  • FullGC: 回收整個堆(可用空間耗盡時觸發,單線程執行)

G1 根據存活對象的字節數統計每個區域的 活躍度liveness,然后根據期望停頓時間來確定該 CSet 的大小,並保證那些垃圾多(活躍度低)的區域會被優先回收,故此得名 垃圾優先

G1 的執行過程可以表示為由 3 個階段組成的循環:


Young GC

堆中一開始只有 YoungGen,因此只會觸發 YoungGC,將 EdenSurvivor 區域中的活動對象復制到另一個空閑的 Survivor 區域。

G1 中將 將存活對象復制到其他區域 的過程稱為 疏散Evacuation。為了減少停頓時間,疏散工作由多個 GC 線程並行完成。

YoungGC 過程中會根據預期目標停頓時間 -XX:MaxGCPauseMillis 動態調整新生代的大小,通過 -XX:G1NewSizePercent 參數可以人為干預這一過程,但會讓預期停頓時間參數失效。

當堆的整體占用空間足夠大時(超過45%),就會進入 Concurrent Marking 階段。通過 -XX:InitiatingHeapOccupancyPercent 選項可以配置這一行為。


Concurrent Marking

與 CMS 類似,G1 中的並發標記包括多個階段,其中一些階段是並發的,另一些階段則會 STW。

  • Initial Mark \(\tiny \textsf{(STW)}\) 初始標記

    掃描並標記 GC Root 對象直接可達的老年代存活對象。

    Initial Mark 並沒有獨立的執行階段,而是嵌入 YoungGC 中執行的,其停頓時間會被分攤,因此實際的開銷非常低。


  • Root Region Scan 掃描根區域

    掃描 Root Region 並標記所有可達的老年代存活對象。

    此處的 Root Region 就是先前 YoungGC 中生成的 Survivor 區域,其包含的對象都會被視為 GC Root

    為了避免移動對象對標記產生影響,該過程必須在下次 YongGC 啟動前完成。

  • Concurrent Mark 並發標記

    啟動並發標記線程,掃描並標記整個堆中的存活對象(線程數可以通過 -XX:ConcGCThread 進行配置)。

    為了避免重復標記,G1 使用 SATBsnapshot-at-the-beginning算法解決漏標問題:

    應用線程對在 Concurrent Mark 執行期間進行的所有並發更新,都應保留先前的已知標記信息。

    該約束通過預寫屏障pre-write barrier實現:

    Concurrent Mark 掃描過程中,當應用線程修改某個字段時,會將先前的引用對象存儲在 日志緩沖區 log buffers中,然后交由並發標記線程處理。

    為了避免移動對象對標記產生影響,該過程必須在下次 YoungGC 啟動前完成。所有的標記任務必須在堆滿前完成,如果堆滿前沒有完成標記任務,則會觸發擔保機制,經歷一次長時間的串行 FullGC

  • Remark \(\tiny \textsf{(STW)}\) 重新標記

    啟動並行標記線程,完成對整個堆中存活對象的標記(線程數可以通過 -XX:ParallelGCThread 進行配置)。

    該階段會暫停所有應用線程,避免發生引用更新,並完成對SATB 日志緩沖區中剩余對象的標記,找出所有未被訪問的存活對象。

    該階段還執行一些額外的清理操作,例如:

    • 卸載不可達的類(通過 -XX:+ClassUnloadingWithConcurrentMark 開啟)
    • 處理引用對象(弱引用、軟引用、虛引用、最終引用)

  • Cleanup 清理垃圾

    整理統計信息並識別出高收益的老年代分區,為 MixedGC 做准備。

    主要工作有:

    • RSet 梳理(后續說明)\(\tiny \textsf{(STW)}\)
    • 識別回收收益高的老年代分區(基於釋放空間和暫停目標)\(\tiny \textsf{(STW)}\)
    • 直接回收完全沒有活躍對象的空閑分區

    此外還會執行一些清理工作,為下一次 Concurrent Marking 做好准備。

Mixed GC

MixedGC 主要流程與 YoungGC 類似,不同的地方在於 CSet 中包含了 Old 區域。

需要注意的是,Concurrent Marking 結束后,並不一定會立即觸發 MixedGC,中間可能會穿插多次的 YoungGC

當收集某個區域時,我們必須知道是否有來自非收集區域引用,來確定它們的活動性:

  • 從非收集區域到收集區域的 incoming reference 是重要的(被非收集區引用的對象必須存活)
  • 從收集區域到非收集區域的 outgoing reference 是可忽略的(非收集區域不參與GC)

但查找整個堆非常耗時,同時也失去了增量收集的優勢。為了解決這一問題,G1 為每個區域維護了一個 RSetremembered set,用於記憶從其他區域指向自己的引用。


收集過程

在執行收集時,RSet 中引用信息會扮演局部 GC Roots 的角色,避免耗時的引用查找,保證每個區域的 GC 能夠獨立進行:

注意,象如果 Old 區域中對在 Concurrent Marking 階段被確定為垃圾,即使有外部引用,該對象也會被作為垃圾回收。

接下來發生的事情與其他收集器所做的相同:多個並行GC線程找出哪些對象是活動的,哪些對象是垃圾:

最后,釋放空閑區域,將活動對象移到 Survivor 區域,並在必要時創建新對象:


RSet 維護

為了維護 RSet,在應用線程對字段執行寫操作時,會觸發寫后屏障post-write barrier

如果更新后的引用是跨區域的(即從一個區域指向另一個區域),則對應的條目將出現在目標區域的 RSet 中。

為了減少寫屏障帶來的開銷,該過程是異步的:

應用線程只負責把更新字段所在的 Card 信息插入一個 DCQ  (Dirty Card Queue),然后由 Refine 線程將其拾取並將信息傳播到被引用區域的 RSet。

如果應用線程插入速度過快,會導致 Refine 線程來不及處理,那么應用線程將接管 RSet 更新的任務,從而導致性能下降。

總結

並發標記增量收集 是 G1 實現高性能與可預測回收的關鍵。

對於 CPU 資源充足且對延遲敏感的服務端應用來說,G1 算法能夠在大堆上提供良好的響應速度。

作為代價,額外的寫屏障與更活躍GC線程,會對應用的吞吐量產生負面影響。


參考資料


免責聲明!

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



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