G1 收集器
阿新 • • 發佈:2020-12-17
# 基礎知識
## 效能指標
在調優Java應用程式時,重點通常放在兩個主要目標上:**響應性** 或 **吞吐量**。
**響應性**`Responsiveness` 是指應用程式對請求的資料做出響應的速度:
- 桌面使用者介面對事件的響應速度
- 網站返回頁面的速度
- 資料庫查詢的返回速度
**吞吐量**`Throughput` 專注於最大程度地提高應用程式在特定時間段內的工作量:
- 在給定時間內完成的事務次數
- 批處理程式在一小時內可以完成的作業數
- 一小時內可以完成的資料庫查詢數
較長的暫停時間`Pause Time`對於注重響應性的應用程式是不可接受的,但對於注重吞吐量的應用程式來說可以接受的。前者重點是在短時間內做出響應,後者則側重與長時間執行的處理效率。
## GC 基礎
### GC Root
### 三色標記 可達性分析中重要的一環就是遍歷整個堆,並標記其中的存活物件。一種常用的標記演算法是 **三色標記法**`tri-color marking`:
每個物件可能為以下 3 種顏色之一:
- **white** — 未被標記
- **gray** — 本身已標記,但部分引用的物件完成標記(動圖的黃色物件)
- **black** — 本身已標記,且所有引用的物件完成標記(動圖的藍色物件)
標記演算法從 GC Roots 出發遍歷堆,可達物件先標記 gray,然後再標記 為 black。
遍歷完成之後所有可達物件都是 black 的,此時所有標記為 white 的物件都是可以回收的。
當實現併發標記演算法時,必須防止 white 物件被漏標,否則可能導致不該回收的物件被回收。
### 分代收集 傳統垃圾收集器將堆分成三個部分:年輕代`YoungGen = Eden + Survivor`,老年代`OldGen`和永久代`PermGen`,每個區域記憶體連續且大小固定。
- 年輕代:一次性使用的臨時物件(例如:方法中構造的臨時物件)
- 老年代:被長期引用的常駐物件(例如:快取物件、單例物件)
- 永久代:JVM 執行過程中一直存在的物件(例如:字串常量、類資訊)
將堆記憶體進行劃分後,可以按照物件生命週期長短,在不同區域使用不同的回收演算法,提高 GC 的效率。
### 演算法分類 #### **Mark and Sweep**`標記-清除`
#### **Mark-Sweep-Compact**`標記-整理`
#### **Mark and Copy**`標記-複製`
- **Concurrent Mark** `併發標記` GC 執行緒遍歷 **Initial Mark** 階段標記出來存活的老年代物件,然後遞迴標記這些可達的物件。
- **Preclean** `預清理` 這一階段主要是處理 **Concurrent Mark** 階段中引用關係改變,導致沒有標記到的存活物件的。通過併發地重新掃描這些物件,預清理階段可以減少 **Remark** 階段的 STW。
- **Abortable Preclean** `可終止的預清理` 這個階段作用與 **Preclean** 類似,但可以通過設定 **掃描時長**(預設5秒)或 **Eden 區使用佔比**(預設50%)控制本階段的結束時機。 增加這一階段的原因,是期待這期間能發生一次 YoungGC 清理無效的年輕代物件,減少 **Remark** 階段掃描年輕代的時間。
- **Remark *(STW)*** `重新標記`: 這個階段同時掃描 YoungGen 與 OldGen,重新標記整個老年代中所有存活物件。 由於之前的 **Concurrent Mark** 與 **Preclean** 階段是與使用者執行緒併發執行的,年輕代對老年代的引用可能已經發生了改變,**Remark** 要花很多時間處理這些改變,會導致長時間的 STW。 此外,即使新生代的物件已經不可達了,CMS 也會使用這些不可達的物件當做的 GC Roots 來掃描老年代,導致部分失效的老年代物件無法被及時回收。 可以加入引數 -XX:+CMSScavengeBeforeRemark,在重新標記之前,先執行一次 YoungGC,回收掉年輕代的物件無用的物件。這樣進行年輕代掃描時,只需要掃描 Survivor 區的物件即可,一般 Survivor 區非常小,這大大減少了掃描時間。
- **Concurrent Sweep** `併發清理`
- **Resetting** `重置` 清除資料結構,並重置定時器,為下一輪 GC 做準備。 # G1 演算法 ## 設計目的 G1 `Garbage-First` 是一種伺服器端的垃圾收集器: - 可以與應用程式執行緒並行執行,減少 STW - 整理空閒空間減少記憶體碎片,但不引入較長的 GC 暫停時間 - 提供可預測的GC暫停時間,無需犧牲很多吞吐量 G1 能夠在大記憶體的多處理器計算機上,保證 GC 暫停時間可控,並實現高吞吐量。 其最終目的是取代 CMS 成為服務端 GC 更好的解決方案: - 採用 **標記-整理** 演算法,可以避免使用細粒度的空閒列表進行分配。簡化了收集器設計並消除了潛在的碎片問題。 - 使用 **增量回收**`incremental collecting` 演算法,其 GC 暫停時間比 CMS 更具可預測性,並允許使用者指定期望的暫停時間。
## 基本概念 G1 將堆劃分為一組大小相等的且連續的堆區域`Region`:
## Young GC 堆中一開始只有 **YoungGen**,因此只會觸發 **YoungGC**,將 **Eden** 與 **Survivor** 區域中的活動物件複製到另一個空閒的 **Survivor** 區域。
G1 中將 **將存活物件複製到其他區域** 的過程稱為 **疏散**`Evacuation`。為了減少停頓時間,疏散工作由多個 GC 執行緒並行完成。
**YoungGC** 過程中會根據預期目標停頓時間 **-XX:MaxGCPauseMillis** 動態調整新生代的大小,通過 **-XX:G1NewSizePercent** 引數可以人為干預這一過程,但會讓預期停頓時間引數失效。
當堆的整體佔用空間足夠大時(超過45%),就會進入 **Concurrent Marking** 階段。通過 **-XX:InitiatingHeapOccupancyPercent** 選項可以配置這一行為。
## Concurrent Marking
與 CMS 類似,G1 中的併發標記包括多個階段,其中一些階段是併發的,另一些階段則會 STW。
- **Initial Mark *(STW)*** `初始標記` 掃描並標記 **GC Root** 物件直接可達的老年代存活物件。 **Initial Mark** 並沒有獨立的執行階段,而是嵌入 **YoungGC** 中執行的,其停頓時間會被分攤,因此實際的開銷非常低。
- **Root Region Scan** `掃描根區域` 掃描 **Root Region** 並標記所有可達的老年代存活物件。 此處的 **Root Region** 就是先前 **YoungGC** 中生成的 **Survivor** 區域,其包含的物件都會被視為 **GC Root**。 為了避免移動物件對標記產生影響,該過程必須在下次 **YongGC** 啟動前完成。
- **Concurrent Mark** `併發標記` 啟動併發標記執行緒,掃描並標記整個堆中的存活物件(執行緒數可以通過 **-XX:ConcGCThread** 進行配置)。 為了避免重複標記,G1 使用 **SATB**`snapshot-at-the-beginning`演算法解決漏標問題: 應用執行緒對在 Concurrent Mark 執行期間進行的所有併發更新,都應保留先前的已知標記資訊。 該約束是通過**預寫屏障**`pre-write barrier`實現: Concurrent Mark 掃描過程中,當應用執行緒修改某個欄位時,會將先前的引用物件儲存在日誌緩衝區
- **Remark *(STW)*** `重新標記` 啟動並行標記執行緒,完成對整個堆中存活物件的標記(執行緒數可以通過 **-XX:ParallelGCThread** 進行配置)。 該階段會暫停所有應用執行緒,避免發生引用更新,並完成對**SATB 日誌緩衝區**中剩餘物件的標記,找出所有未被訪問的存活物件。 該階段還執行一些額外的清理操作,例如: - 解除安裝不可達的類(通過 **-XX:+ClassUnloadingWithConcurrentMark** 開啟) - 處理引用物件(弱引用、軟引用、虛引用、最終引用)
- **Cleanup** `清理垃圾` 整理統計資訊並識別出高收益的老年代分割槽,為 **MixedGC** 做準備。 主要工作有: - RSet 梳理(後續說明) - 識別回收收益高的老年代分割槽 (基於釋放空間和暫停目標) - 直接回收的沒有活躍物件的空閒分割槽 此外還會執行一些清理工作,為下一次 **Concurrent Marking** 做好準備。
## Mixed GC **MixedGC** 主要流程與 **YoungGC** 類似,不同的地方在於 **CSet** 中包含了 **Old** 區域。 需要注意的是,**Concurrent Marking** 結束後,並不一定會立即觸發 **MixedGC**,中間可能會穿插多次的 **YoungGC**。 當收集某個區域時,我們必須知道是否有來自非收集區域引用,來確定它們的活動性: - 從非收集區域到收集區域的 **incoming reference** 是重要的(被非收集區引用的物件必須存活) - 從收集區域到非收集區域的 **outgoing reference** 是可忽略的(非收集區域不參與GC) 但查詢整個堆非常耗時,同時也失去了增量收集的優勢。為了解決這一問題,G1 為每個區域維護了一個 **RSet**`remembered set`,用於記憶從其他區域指向自己的引用。
### 收集過程 在執行收集時,**RSet** 中引用資訊會扮演區域性 **GC Roots** 的角色,避免耗時的引用查詢,保證每個區域的 GC 能夠獨立進行:
### **RSet** 維護 為了維護 RSet,在應用執行緒對欄位執行寫操作時,會觸發**寫後屏障**`post-write barrier`: 如果更新後的引用是跨區域的(即從一個區域指向另一個區域),則對應的條目將出現在目標區域的 RSet 中。 為了減少寫屏障帶來的開銷,該過程是非同步的: 應用執行緒只負責把更新欄位所在的 Card 資訊插入一個DCQ
### 參考資料 - https://www.oracle.com/technetwork/tutorials/tutorials-1876574.html - https://plumbr.io/handbook/garbage-collection-algorithms - https://medium.com/@hansrajchoudhary_88463/evolution-of-garbage-collection-on-java-garbage-first-garbage-collection-a3f39b1a9ae0 - https://juejin.cn/post/6844903960550047757#heading-10 - https://segmentfault.com/a/119000002
可達性分析是 Java GC 演算法的基礎,基本思路就是以一系列名為 `GC Roots` 物件作為起始點,通過引用關係遍歷物件圖,如果一個物件到 `GC Roots` 間沒有任何可達路徑相連時,則說明此物件可以被回收。
可以作為 `GC Roots` 的物件: - 虛擬機器棧(棧幀中的本地變量表)中引用的物件 - 本地方法棧中JNI(即一般說的native方法)中引用的物件 - 方法區中類靜態屬性引用的物件 - 方法區中常量引用的物件
### 三色標記 可達性分析中重要的一環就是遍歷整個堆,並標記其中的存活物件。一種常用的標記演算法是 **三色標記法**`tri-color marking`:
### 分代收集 傳統垃圾收集器將堆分成三個部分:年輕代`YoungGen = Eden + Survivor`,老年代`OldGen`和永久代`PermGen`,每個區域記憶體連續且大小固定。
### 演算法分類 #### **Mark and Sweep**`標記-清除`
用一個空閒列表`free-list`記錄失效物件佔用的記憶體區域,方便後續重新分配給新物件。 - 回收原理簡單,GC 停頓時間短 - 維護空閒列表需要一定的空間開銷 - 記憶體碎片較多,可能導致記憶體分配失敗
將所有存活物件移動到記憶體區域的開頭,剩餘的連續記憶體區域都是可用的空閒空間。 - 通過指標碰撞查詢空閒空間,分配速度快 - 記憶體碎片少,記憶體分配失敗概率低 - 複製物件會導致較長時間的 GC 停頓
#### **Mark and Copy**`標記-複製`
將記憶體劃分為**活動區間**與**空閒區間**,前者用於動態分配物件,後者用於容納 GC 存活物件。 GC 時只需將存活物件從前者複製到後者,然後交換兩者的角色即可。 - 標記和複製在同一階段同時進行,當存活物件少時回收效率極高 - 需要預留一個空閒空間用於容納存活物件,造成記憶體浪費 # CMS 回顧 CMS `Concurrent Mark-Sweep` 是一個採用 **標記-清除** 演算法的老年代收集器。 它通過與應用程式執行緒併發執行大多數垃圾回收工作,來最大程度地減少由於 GC 導致的暫停。 通常情況下,CMS 收集器不會複製或壓縮活動物件,這意味著無需移動活動物件即可完成垃圾回收。 然而過多的記憶體碎片可能造成分配失敗,最終導致 FullGC。可以通過分配更大的堆來規避這一問題。 CMS 對老年代的回收可以分為以下幾個步驟: - **Initial Mark *(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 *(STW)*** `重新標記`: 這個階段同時掃描 YoungGen 與 OldGen,重新標記整個老年代中所有存活物件。 由於之前的 **Concurrent Mark** 與 **Preclean** 階段是與使用者執行緒併發執行的,年輕代對老年代的引用可能已經發生了改變,**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 中新生代與老年代不再連續,每個區域可以在 **Eden**、**Survivor** 與 **Old** 之間切換角色。此外,還有一類被稱為 **Humongous** 的巨型區域,用於容納體積 ≥ 標準區域大小的50%的物件。JVM 通常會將記憶體劃分為 2000個區域,每個大小從 1 到 32Mb 不等,由 JVM 在啟動時通過 -XX:G1HeapRegionSize 指定。 每個區域會被進一步細分成多個卡片`Card`,每個大小為 512Kb,用於實現細粒度的引用統計。 分割槽設計可以避免一次收集整個堆,每次 GC 只收集區域的一個子集 **CSet**`collection set`,其中必然包含所有 **Young** 區域,同時可能包括部分 **Old** 區域:
根據回收區域的不同,可以將 GC 分為: - **YoungGC**:**CSet** 只包含 **Young** 區域 - **MixedGC**: **CSet** 同時包含 **Young** 與 **Old** 區域 - **FullGC**: 回收整個堆(可用空間耗盡時觸發,單執行緒執行) G1 根據存活物件的位元組數統計每個區域的 **活躍度**`liveness`,然後根據期望停頓時間來確定該 **CSet** 的大小,並保證那些垃圾多(活躍度低)的區域會被優先回收,故此得名 **垃圾優先**。 G1 的執行過程可以表示為由 3 個階段組成的迴圈:
## Young GC 堆中一開始只有 **YoungGen**,因此只會觸發 **YoungGC**,將 **Eden** 與 **Survivor** 區域中的活動物件複製到另一個空閒的 **Survivor** 區域。
- **Initial Mark *(STW)*** `初始標記` 掃描並標記 **GC Root** 物件直接可達的老年代存活物件。 **Initial Mark** 並沒有獨立的執行階段,而是嵌入 **YoungGC** 中執行的,其停頓時間會被分攤,因此實際的開銷非常低。
- **Root Region Scan** `掃描根區域` 掃描 **Root Region** 並標記所有可達的老年代存活物件。 此處的 **Root Region** 就是先前 **YoungGC** 中生成的 **Survivor** 區域,其包含的物件都會被視為 **GC Root**。 為了避免移動物件對標記產生影響,該過程必須在下次 **YongGC** 啟動前完成。
- **Concurrent Mark** `併發標記` 啟動併發標記執行緒,掃描並標記整個堆中的存活物件(執行緒數可以通過 **-XX:ConcGCThread** 進行配置)。 為了避免重複標記,G1 使用 **SATB**`snapshot-at-the-beginning`演算法解決漏標問題: 應用執行緒對在 Concurrent Mark 執行期間進行的所有併發更新,都應保留先前的已知標記資訊。 該約束是通過**預寫屏障**`pre-write barrier`實現: Concurrent Mark 掃描過程中,當應用執行緒修改某個欄位時,會將先前的引用物件儲存在日誌緩衝區
log buffers
中,然後交由併發標記執行緒處理。
為了避免移動物件對標記產生影響,該過程必須在下次 **YoungGC** 啟動前完成。所有的標記任務必須在堆滿前完成,如果堆滿前沒有完成標記任務,則會觸發擔保機制,經歷一次長時間的序列 **FullGC**。
- **Remark *(STW)*** `重新標記` 啟動並行標記執行緒,完成對整個堆中存活物件的標記(執行緒數可以通過 **-XX:ParallelGCThread** 進行配置)。 該階段會暫停所有應用執行緒,避免發生引用更新,並完成對**SATB 日誌緩衝區**中剩餘物件的標記,找出所有未被訪問的存活物件。 該階段還執行一些額外的清理操作,例如: - 解除安裝不可達的類(通過 **-XX:+ClassUnloadingWithConcurrentMark** 開啟) - 處理引用物件(弱引用、軟引用、虛引用、最終引用)
- **Cleanup** `清理垃圾` 整理統計資訊並識別出高收益的老年代分割槽,為 **MixedGC** 做準備。 主要工作有: - RSet 梳理(後續說明) - 識別回收收益高的老年代分割槽 (基於釋放空間和暫停目標) - 直接回收的沒有活躍物件的空閒分割槽 此外還會執行一些清理工作,為下一次 **Concurrent Marking** 做好準備。
## Mixed GC **MixedGC** 主要流程與 **YoungGC** 類似,不同的地方在於 **CSet** 中包含了 **Old** 區域。 需要注意的是,**Concurrent Marking** 結束後,並不一定會立即觸發 **MixedGC**,中間可能會穿插多次的 **YoungGC**。 當收集某個區域時,我們必須知道是否有來自非收集區域引用,來確定它們的活動性: - 從非收集區域到收集區域的 **incoming reference** 是重要的(被非收集區引用的物件必須存活) - 從收集區域到非收集區域的 **outgoing reference** 是可忽略的(非收集區域不參與GC) 但查詢整個堆非常耗時,同時也失去了增量收集的優勢。為了解決這一問題,G1 為每個區域維護了一個 **RSet**`remembered 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執行緒,會對應用的吞吐量產生負面影響。
### 參考資料 - https://www.oracle.com/technetwork/tutorials/tutorials-1876574.html - https://plumbr.io/handbook/garbage-collection-algorithms - https://medium.com/@hansrajchoudhary_88463/evolution-of-garbage-collection-on-java-garbage-first-garbage-collection-a3f39b1a9ae0 - https://juejin.cn/post/6844903960550047757#heading-10 - https://segmentfault.com/a/119000002