學習分析 JVM 中的對象與垃圾回收機制(下)

建議按照順序閱讀

在上一章中學習了 JVM 中對象的創建及分配過程. 本章主要學習知識點如下

  • 常見垃圾回收算法
  • 三色標記法
  • 讀寫屏障的概念
  • 垃圾回收器的介紹

1. 垃圾回收的基礎算法

1.1 Mark Sweep 標記 - 清除算法

原理: 標記階段會標記出需要回收的對象, 標記完成後統一回收所有被標記的對象

GC Roots 開始, 將內存整個遍歷一次, 保留所有可以被 GC Roots 直接或間接引用到的對象, 而剩下的對象都當做垃圾對待並回收, 整個過程分爲兩步.

  • 標記階段
    找到內存中所有 GC Roots 對象, 只要是和 GC Roots對象直接或者間接相連的標記爲存活對象, 否則標記爲垃圾對象.

  • 清除階段
    當遍歷完所有的 GC Roots 之後, 則將標記爲垃圾的對象直接清除.

  • 優點: 實現簡單, 不需要將對象進行移動.

  • 缺點:

    • 效率不高: 標記和清除的兩個過程效率都不高,
    • 空間問題: 標記清除後會參數大量不連續的內存碎片, 空間碎片太多可能會導致程序運行過程中在分配大對象時, 無法找到足夠的連續內存不得不觸發另一次的垃圾回收動作. 從而提高了垃圾回收的頻率.
       
1.2 Copying 複製算法

由於標記清除算法的效率不高和內存碎片化問題, 複製 (Copying) 算法就出現了.

原理: 將現有的內存空間分爲兩塊, 每次只使用其中一塊, 在垃圾回收時將存活的對象複製到未被使用的內存塊中. 之後再清除正在使用的內存塊中的所有對象.

例如可以將內存劃分爲 A, B 兩塊, 當發生 GC 時, 會將 A 中存活對象複製到 B 中. 然後把 A 內存統一回收掉.

  • 優點: 效率比標記清除算法好, 也不會出現內存碎片的情況.
  • 缺點:
    • 內存利用率不夠: 將內存平均分爲 2 塊, 可用的內存就變成了原來的一半.
    • 如果對象的存活率比較高的話, 複製的操作就會比較頻繁.
       
1.3 Mark Compact 標記 - 整理算法

由於複製算法對於存活率高的對象進行垃圾回收需要頻繁的進行復制操作, 而 標記-清除算法又會造成內存碎片化, 所以又有人提出了 標記-整理算法.

原理: 需要先從根節點開始懟所有可達對象做一次標記, 之後它並不簡單的清理未標記的對象, 而是將所有存活對象向另一端移動.順序排放, 最後清理邊界外所有的控件, 因此標記整理也是分爲兩個步驟

  • 優點: 避免了碎片的產生, 又不需要浪費內存. 因此,性價比比較高
  • 缺點: 所謂整理, 仍需要進行局部對象移動, 所以一定程度上還是降低了效率.

 

1.4 小結

垃圾回收基礎算法是後面算法改進的基礎. 下面對這幾種算法做一個小結.

算法 優點 缺點
標記清除 算法簡單, 高效, 無需移動對象 內存碎片化, 分配慢 (需要找到合適的空間)
標記整理 堆的使用率高, 沒有內存碎片. 需要對對象進行移動
複製 分配效率高, 沒有內存碎片 浪費內存空間

 

2. 三色標記法

2.1 什麼是三色標記法

無論是標記-清除, 還是標記-整理. 標記都是必要的一步, 首先需要標記出哪些是垃圾, 才能進行回收, 而標記也可以有很多種.

現代使用可達性分析的垃圾回收器幾乎都借鑑了三色標記的算法思想, 只是實現的方式不同.
CMS 與 G1 採用的都是三色標記法. (CMS 與 G1 都是垃圾回收器, 見下文.)

在上一章學習分析 JVM 中的對象與垃圾回收機制(上)中 對象的存活和引用 知道根據可達性分析從 GC Roots 開始進行遍歷訪問, 可達的則爲存活對象, 而最終不可達的就是需要被回收的對象. 三色標記法大致流程就是把遍歷對象圖過程中遇到的對象按照 是否訪問過 這個條件標記成下面三個顏色.

  • 白色: 尚未訪問過
  • 黑色: 本對象已經訪問過, 而且本對象引用到的其他對象也全部訪問過了.
  • 灰色: 本對象已經訪問過, 但是本對象引用到的其他對象尚未完全訪問完, 全部訪問後會變爲黑色.

根據上圖, 假設現在有白,灰,黑三個集合, 那麼遍歷訪問的過程如下.

  1. 初始時所有的對象都在白色集合中
  2. 將 GC Roots 直接引用到的對象移動到灰色集合中.
  3. 從灰色集合中獲取對象
    • 第一步將本對象引用到的其他對象移動到灰色集合中.
    • 第二步將本對象移動到黑色集合中.
  4. 重複步驟 3, 直至灰色集合爲空時結束. 最後仍在白色集合中的對象即爲不可達, 可以嘗試進行回收.

如果這整個過程都是 STW (Stop the world, 解釋見下文)的話, 對象的引用關係是不會改變的, 意味着標記結果是正確的, 但是如果是併發進行標記, 與用戶線程同時在運行, 那麼對象間的引用可能就會發生變化, 多標和漏標的情況就有可能發生.

併發標記: 進行標記的線程與用戶線程同時運行.

2.2 多標 (浮動垃圾)

場景: 併發標記線程遍歷到 E 了, 此時 E 是灰色, 用戶程序線程執行了 D.E = null, 那麼此時 E/F/G 應該是被回收的, 但是 GC 線程已經認爲 E 是灰色了, 扔會被當做存活對象繼續遍歷下去, 那麼最終結果就是這部分對象被標記爲存活, 本輪 GC 不會回收這部分對象.

這部分應該回收, 但是沒有被回收的對象, 被稱之爲 "浮動垃圾". 浮動垃圾不會影響垃圾回收的正確性, 但是需要等待下一次GC 的時候, 纔會被清除.

另外, 在併發標記線程開始後的新建對象, 通常做法是直接全部標記爲黑色, 本輪不會進行清除. 這部分對象期間有可能會變爲垃圾, 這也算是浮動垃圾的一部分.


2.3 漏標

場景: 併發標記線程已經遍歷到 E, 此時 E 是灰色, 但是用戶線程執行了下面的代碼

Object G = E.G;
E.G  = null;
D.G = G;

也就是執行了將 E ->G 斷開, D -> G 鏈接的操作. 此時切回 GC的併發標記線程繼續跑, 發現 E 無法到達 G, 所以無法將 G 標記爲灰色, 儘管 D 重新引用了 G, 但是因爲 D 已經被標記爲黑色了, 不會再重新做遍歷的處理. 那麼最終導致的結果就是 G 會一直停留在白色集合中, 最後被當做垃圾被清除掉. 這直接影響到了程序的正確性.



從上面可以看出漏標只有同時滿足兩個條件時纔會發生.

  • 灰色對象斷開了白色對象的引用. 即灰色對象原來的成員變量的引用發生了變化.
  • 黑色對象重新引用了該白色對象, 即黑色對象成員變量增加了新的引用.

從代碼的角度看, 是經歷了三步

Object G = E.G; //讀
E.G  = null; //寫
D.G = G;  //寫

是不是在上面三步中任意一步中做一些手段, 將 G 記錄起來, 然後將它標記爲灰色再進行遍歷即可. 比如將它放到一個特定的集合, 等待併發標記執行完, 在重新標記階段重新遍歷即可. 於是又引出了讀寫屏障的概念.

 

3. 讀寫屏障的概念

3.1 寫屏障

所謂寫屏障就是在寫操作前後加入一些處理, 類似於 AOP. 寫屏障分爲下面兩種類型

  • 寫屏障 + SATB
    當對象 E 的成員變量的引用發生變化時, 也就是執行了E.G = null;這行代碼時候, 可以利用寫屏障, 將 E 原來成員變量的引用對象 G 記錄下來, 那麼記錄下來的就叫原始快照 (Snapshot At The Beginning, 簡稱 SATB). 後續的標記也跟着 SATB 執行. SATB 破壞了漏標的條件一: 灰色對象斷開了白色對象的引用, 從而保證了不會被漏標

  • 寫屏障 + 增量更新
    當對象 D 的成員變量的引用發生變化時, 也就是執行了D.G = G 這行代碼的時候, 可以利用寫屏障將 D 新的成員變量引用對象 G 給記錄下來. 針對新增的引用, 將其記錄下來等待遍歷, 稱爲增量更新.
    增量更新破壞了漏標的條件二: 黑色對象重新引用了該白色對象. 也保證了不會漏標

3.2 讀屏障

讀屏障是直接針對第一步 Object G = E.G, 當讀取成員變量時, 一律記錄下來.

3.3 處理漏標的方案

對於讀寫屏障,以Java HotSpot VM爲例, 其併發標記時對漏標的處理方案如下.

  • CMS 回收器: 寫屏障 + 增量更新.
  • G1    回收器: 寫屏障 + SATB.
  • ZGC 回收器: 讀屏障.

關於CMS, G1 與 ZGC回收器見下文分析

 

4. JVM 中常見的垃圾收集器

現在開始學習一些垃圾回收器/垃圾收集器. 在學習垃圾收集器前, 先了解一下幾個相關的術語.

  • STW - Stop the world
    在執行垃圾回收時, Java 應用程序的其他所有除了垃圾回收線程之外的線程都被掛起, 等待垃圾回收線程執行完畢後才能再次運行.

    STW 停頓時間越短就越適合需要與用戶交互的程序, 良好的響應速度能提升用戶體驗.

  • Parallel
    並行, 指兩個或多個事件在同一時刻發生. 在 JVM 垃圾回收器中的並行是指多個垃圾回收線程在操作系統上並行運行.

  • Concurrent
    併發. 指兩個或多個事件在同一事件間隔內發生. 在 JVM 垃圾回收器中的併發指的是垃圾回收線程和 Java 程序線程併發運行.

  • Incremental
    垃圾回收器對堆的某部分(增量)進行回收, 而不是掃描整個堆

  • Throughput
    吞吐量. 即是 CPU 用於運行用戶代碼的時間與 CPU 總消耗時間的比值. 即吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間 + 垃圾收集時間), 虛擬機總共運行了100分鐘, 其中垃圾收集花掉1分鐘, 那吞吐量就是99%.

    高吞吐量則可以高效率的利用 CPU 時間, 儘快完成程序的運算任務, 主要適合在後臺運算而不需要太多交互的任務.

下面將會學習 7 種垃圾回收器, 每個垃圾回收器的特點不同. 同時回收堆內存區域也不同, 有的是針對新生代的, 有的是針對老年代的.下面用一張從網上找的圖來表示各個垃圾回收器作用在什麼區域, 哪些是可以組合使用的.


實線連接的表示可以進行組合收集, 間隔虛線連接的表示在 Java 9 中不能組合. 左下角虛線表示當 CMS 發生 CMS Concurrent mode failure 時可以使用 Serial Old 作爲 CMS 的備用方案.

 

4.1 Serial / Serial Old

Serial (串行) 垃圾回收器針對新生代使用單線程進行垃圾回收, 在回收的時候需要暫停其他的工作線程, 使用了複製算法.

Serial Old 垃圾回收器是 Serial 針對老年代的版本, 同樣是單線程的, 使用了 標記-清除 算法

Serial 與 Serial Old 垃圾回收器的線程交互圖如下.


可以通過 -XX:+UseSerialGC 來告訴虛擬機使用 Serial 收集器

 

4.2 ParNew 回收器

ParNew 回收器是 Serial 回收器的多線程版本, 除了使用多線程進行垃圾回收外, 其餘行爲包括 Serial 回收器可用的所有控制參數, 回收算法(複製算法), STW, 對象分配規則, 回收策略等與 Serial 完全相同. 兩者共用了很多代碼.

ParNew 回收器 線程交互如下.

可以通過 -XX:+UseParNewGC 來告訴虛擬機使用 ParNew 收集器。它默認開啓的收集線程數與 CPU 的數量相同,也可以通過-XX:ParallerGCThreads 來設置 GC 線程數量

 

4.3 Parallel Scavenge / Parallel Old 回收器

Parallel Scavenge 回收器是一個並行的多線程針對新生代的回收器. 同樣採用了複製算法. 它的關注點與其他回收器不同, 別的收集器關注點是儘可能縮短垃圾回收時用戶線程停頓的時間, 也就是縮短 STW 的時間, 而 Parallel Scavenge 關注的是 Throughput 吞吐量.

Parallel Old 回收器是 Parallel Scavenge 針對老年代回收的版本, 採用了 標記-整理 算法. 該回收器與 JDK 1.6 版本開始提供, 在此之前新生代的 Parallel Scavenge 只能和 Serial Old 進行搭配使用. 但是 Serial Old 回收器在服務端應用性能上表現不好, 所以纔有了 Parallel Old 的出現. 在注重吞吐量和 CPU 資源敏感的場景, 都可以優先考慮使用 Parallel ScavengeParallel Old 回收器.

通過 -XX:+UseParallelGC 來啓用 Parallel Scavenge 回收器.
通過 -XX:MaxGCPauseMillis 來設置吞吐量, 也可以直接設置吞吐量 -XX:GCTimeRatio, 值爲 0 ~ 100.

如果打開 -XX:+UseAdaptiveSizePolicy 開關, 就不需要通過 -Xmn手動指定新生代的大小, 以及 Eden 區與 from, to 區的比例 -XX:SurvivorRatio. 晉升老年代對象的年齡 -XX:PretenureSizeThreshold 等細節參數了. 虛擬機會根據當前系統的運行情況收集性能監控信息, 動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量, 這種方式稱爲 GC 自適應的調節策略(GC Ergonomics). 自適應策略也是 Parallel Scavenge 與 ParNew 回收器的一個重要區別

 

4.4 CMS - Concurrent Mark Sweep 回收器

CMS 回收器是一種以獲取最短回收停頓時間爲目標的收集器, 針對老年代. 基於 標記-清除 算法實現的, 是 JDK1.7 之前最主流的垃圾回收器. 可與 Serial 回收器或 Parallel New 回收器配合使用。

CMS 運作過程相對於前面幾種回收器來說相對更復雜一些, 整體過程分爲 4 個階段.

  • 初始標記
    短暫, 僅僅只是標記一下 GC Roots 能直接關聯到的對象, 速度很快. 需要 STW.

  • 併發標記
    耗時最長, 進行 GC Roots 追蹤的過程. 但是這個階段是和用戶應用程序線程同時進行的.

  • 重新標記
    短暫, 爲了修正併發標記期間因用戶程序繼續運行而導致標記產生變動的那一部分對象的標記記錄, 這個階段的停頓時間一般會比初始標記稍微長一點, 但是遠比並發標記的時間短, 此階段也需要 STW.

  • 併發清除
    清除那些被標記爲可以回收的對象, 由於這一階段用戶程序也在運行, 這時候產生的浮動垃圾就不能被處理, 只能等下一次 GC 時再清理.

雖然 CMS 很優秀, 支持併發, 低延遲. 但是也有幾個明顯的缺點. 其中就有多標和漏標.

  • 多標 (浮動垃圾)

  • 漏標 : CMS 解決漏標的方案在上面說過了是 寫屏障 + 增量更新. 下面是具體的方案, 可做了解.

    我們知道在併發標記階段, 是和用戶程序線程一起運行的, 既然用戶線程也在運行那麼就有可能會觸發新生代的垃圾回收, 即Minor GC / Young GC. 那麼如果觸發了新生代的垃圾回收後, 就會出現以下三種情況導致漏標.

    • 新生代對象晉升到老年代
    • 直接在老年代分配對象
    • 新生代與老年代的引用發生變化.

    CMS 使用了Cart Table 卡表來解決標記過程中對象的變化,

    什麼是卡表
    將堆空間劃分爲一系列 2 次冪大小的卡頁 Card Page, 而卡表用於標記卡頁的狀態, 每個卡表項都對應一個卡頁.
    HotSpot 虛擬機的卡頁大小爲 512 字節, 卡表被實現爲一個簡單的字節數組, 即卡表的每個標記項爲 1 個字節.
    當對一個對象引用進行寫操作時(對象引用改變), 寫屏障邏輯就會標記對象所在的卡頁爲 dirty, 即 "髒卡"

    使用卡表記錄對象引用的變化還有一個好處就是, 在進行 Minor GC 的時候, 便可以不用掃描整個老年代, 而是在卡表中尋找髒卡, 並將髒卡中的對象加入到 Minor GC 的 GC Roots 中. 當完成所有髒卡的掃描後, JVM 便將所有髒卡的標誌位清零.

    在 Minor GC 之前, 是無法確保髒卡中包含的是指向新生代對象的引用的, 這個和寫屏障有關.
    當對一個對象引用進行寫操作的時候(對象引用改變), 寫屏障在進行標記髒卡的時候, 並不會判斷更新後的引用是否指向新生代中的對象, 而是寧殺錯, 不放過. 一律當成可能指向新生代對象的引用.
    由於 Minor GC 會伴隨着將存活對象複製到 from 區或者 to 區, 而複製需要更新指向該對象的引用. 因此, 在更新引用的同時, 又會設置引用所在卡頁的標誌位, 這個時候, 就可以確保髒卡中必定包含指向新生代對象的引用

    回到上面說的漏標, 在併發標記階段, D 和 E 的引用改變後, 就會被標記爲髒卡,

    由於卡表只有一份, 既要支持新生代 GC 又要支持 CMS , 每次 Minor GC 過程中都會設計重置和重新掃描卡表, 這樣是滿足了 Minor GC 不掃描老年代的需求, 但卻破壞了 CMS 的需求, 因爲 CMS 需要的信息可能被新生代 GC 給重置掉了. 因此爲了避免丟失信息, 就在卡表的基礎上另外加了一個 bitmap 叫做 mod-union table. 在 CMS 併發標記的運行過程中, 每當發生 Minor GC 要重置卡表中的某個記錄時, 就會更新 mod-union table 對應的bit. 這樣最後到 CMS 重新標記的時候, 就足以記錄在併發標記過程中老年代發生的所有引用變化了.

    在高併發情況下, 頻繁的寫屏障很容易發生徐共享, 從而帶來性能上的開銷. 這點就不再展開描述.

  • CPU 敏感
    因爲是併發收集的所以會佔用一部分的 CPU 資源, 雖然不會導致用戶線程暫停, 但是會導致應用程序變慢, 總吞吐量降低.
    默認情況下, 開啓的線程爲 (CPU 的數量 + 3) / 4, 當 CPU 數量不足 4 個時 (比如 2 個), CMS 對用戶程序的影響就可能變得很大, 如果本來 CPU 負載就較大, 還要分出一半的運算能力去執行回收線程, 就可能導致用戶程序的執行速度忽然降低了 50%.

  • 產生大量的空間碎片
    由於 CMS 使用的是 標記-清除 算法所以會產生大量的空間碎片.

    當老年代空間碎片過多時, 就算可用空間大, 分配對象時如果找不到足夠大的連續空間, 那麼將不得不提前觸發一次 Full GC, 所以, CMS 提供了 -XX:+UseCMSCompactAtFullCollection (默認爲打開狀態) 開關參數, 用於在 CMS 頂不住要進行 Full GC 時開啓內存碎片合併整理過程, 這個過程是無法併發的, 那麼停頓的時間就會變長.

    CMS 還提供了另外一個參數 -XX:CMSFullGCsBeforeCompaction, 設置在執行多少次 Full GC 後對內存空間進行整理. 我們知道內存壓縮整理的過程是沒法併發執行的, 所以難免要停頓, 如果 Full GC 頻繁, 只調優這個參數的情況下, 那麼就不能每次都整理內存空間, 不然積少成多, 停頓的時間也是很可觀的, 此時就要調大這個參數, 讓 CMS 在經過多次 Full GC 後再對內存空間進行整理. 而如果 Full GC 不頻繁, 間隔時間較長, 就可以設置每次 Full GC 後都對內存空間進行整理, 影響也不大.

 
上面說了 CMS 整體過程的 4 個階段. 實際上按照 GC 日誌中顯示的, 可以細分爲 7 個階段.

  • 初始標記
    初始化標記階段, 是 CMS GC 的第一個階段, 也是標記階段的開始, 遍歷GC Roots下的新生代對象能夠可達的老年代對象, 也就是跨代引用. 發生 STW, 暫停所有應用線程. 標記範圍是老年代和新生代.

  • 併發標記
    該階段 GC線程和應用線程併發執行, 也就是說, 在初始標記階段被暫停的應用線程恢復運行. 併發標記階段主要的工作是, 通過遍歷第一階段標記出來的存活對象, 繼續遞歸遍歷老年代, 並標記可直接或間接到達的所有老年代存活對象. 不會發生 STW.


    由於在併發標記階段, 應用線程和 GC 線程是併發執行, 因此可能產生新的對象或對象關係發生變化. JVM 會將發生改變的區域標記爲髒卡(Ditry Card), 同時在 Mod-Union Table 中記錄.

  • 併發預清理
    在併發預清理階段, 將會在上階段記錄在 Mod-Union Table 的這些髒卡會被找出來, 刷新引用關係, 然後清除 dirty 標記.

  • 可中斷的預清理
    該階段發生的前提是, 新生代 Eden 區的內存使用量大於參數 CMSScheduleRemarkEdenSizeThreshold (默認 2M) , 如果新生代的對象太少, 就沒有必要執行此階段, 直接執行重新標記階段即可. 不觸發 STW.
    爲什麼會需要這個階段呢?
    因爲 CMS GC 的終極目標就是爲了降低垃圾回收時的暫停時間, 所以在該階段要盡最大努力去處理那些在併發階段被用戶應用線程更新的老年代對象, 這樣在要發送 STW 的重新標記階段就可以少處理一些, 暫停時間也會相應降低.
    在該階段主要循環做兩件事情

    1. 處理 From 區和 to 區的對象, 標記可達的老年代對象.
    2. 和 併發預清理 階段一樣, 掃描處理 髒卡和 Mod-Union Table 中的對象

    不過當然不會一直循環下去, 打斷這個循環的條件有 3 個

    1. 可以設置最多循環的次數 CMSMaxAbortablePrecleanLoops (默認是 0) , 意思是沒有循環次數的限制.
    2. 如果執行這個邏輯的時間達到了閾值 CMSMaxAbortablePrecleanTime (默認是 5s), 會退出循環.
    3. 如果新生代 Eden 區 的內存使用率達到了閾值 CMSScheduleRemarkEdenPenetration 默認 50%, 會退出循環.
  • 重新標記
    因爲預清理階段也是併發執行的, 並不一定是所有存活對象都會被標記, 因爲在併發標記的過程中對象及其引用關係還在不斷變化中, 因此需要一個 STW 的階段來完成最後的標記工作. 這就是重新標記階段, 也是 CMS 中標記階段中的最後一步. 主要目的就是重新掃描之前併發處理階段的所有殘留更新對象.
    主要工作如下

    1. 遍歷新生代對象,重新標記.
    2. 根據 GC Roots, 重新標記
    3. 遍歷老年代的 Dirty CardMod Union Table, 重新標記
  • 併發清除
    根據標記結果清除老年代中的垃圾對象, 不會觸發 STW. 速度一般.

  • 併發重置
    將清理並恢復在CMS GC過程中的各種狀態, 重新初始化CMS相關數據結構, 爲下一個垃圾收集週期做好準備. 不會觸發 STW.

以上參考鏈接:
jvm 優化篇-(8)-跨代引用問題(RememberSet、CardTable、ModUnionTable、DirtyCard)
CMS垃圾回收器

 

4.5 G1 - Garbage First 回收器

G1 垃圾回收器是在 Java7 u4 之後引入的一個新的垃圾回收器. 在 JDK9 中更被指定爲官方GC收集器. 是一款面向服務端應用的垃圾回收器, 用於多核處理器和大內存的機器上. 實現高吞吐量的情況下, 儘可能的降低暫停時間.

G1 回收器的設計目標是取代 CMS 回收器, 它與 CMS 相比, 在以下方面表現更加出色

  • G1 是一個有整理內存過程的垃圾回收器, 不會產生很多的內存碎片, 因此可以不採用空閒列表的內存分配方式, 而可以直接採用指針碰撞的方式.
  • G1 的 STW 更可控, G1 在停頓時間上添加了預測機制, 用戶可以指定期望時間.
  • G1 可以在 Young GC 中使用, 而 CMS 只能在回收老年代時使用.

在瞭解 G1 垃圾回收器之前, 先來熟悉一下 G1 中的一些概念.

4.5.1 Region

在 G1 之前的其他垃圾回收器進行回收的範圍都是整個新生代或者老年代, 而G1不再是這樣. 使用G1回收器時, Java 堆的內存佈局就與其他回收器有很大差別, 它將整個 Java 堆劃分爲多個大小相等的獨立區域 (Region), 雖然還保留有新生代和老年代的概念, 但新生代和老年代不再是物理隔離的了, 它們都是一部分 Region (不需要連續) 的集合.

在 HotSpot 的實現中, 整個堆被劃分成 2048 個左右大小相等的 Region, 每個 Region 的大小在 1M~32M 之間, 具體多大取決於堆的大小. 而 G1 垃圾回收器的分代也是建立在這些 Region 基礎上的.

每個 Region 都可能是新生代也可能是老年代, 但是在同一時刻只能屬於某個代, Eden 區, Survivor區, 老年代這些概念都還存在, 不過是邏輯上的概念. 這樣方面複用之前分代框架的邏輯.

Survivor區 就是新生代中的 from 區 與 to 區.

分區還有一種十分特殊的類型 Humongous , 所謂 Humongous 就是一個對象的大小超過了某一個閾值 (HotSpot 中是 Region 的 1/2), 那麼它就會標記爲 Humongous , 如果遇到超出 Region 大小的 Humongous, 則可能需要將兩個 Region 合併後, 纔可以放得下.


每一個分配的 Region , 內部都可以分爲兩個部分, 已分配和未分配. 它們之間的接線被稱爲 top. 簡單來說, 將一個對象分配至 Region 內, 值需要簡單增加 top的值即可, 如下圖

每一次都只有一個 Region 處於被分配的狀態中, 稱爲 current region. 在多線程的情況下, 這樣就會帶來併發的問題, G1 回收器採用了 TLAB 與 CAS 的方式, (TLAB 本地線程分配緩衝, 這個在上篇中已經說過). 過程如下

  1. 記錄 top 值
  2. 準備分配
  3. 比較記錄的 top 值 和現在的 top 值. 如果一樣, 則執行分配, 並且更新 top 的值. 否則重複步驟 1

顯然, 使用 TLAB 就會帶來碎片, 例如: 一個線程在自己的 Buffer 裏分配的時候, 雖然 Buffer 裏還有剩餘空間. 但是卻因爲分配對象過大以至於這些空閒空間無法滿足, 此時線程就會去申請新的 Buffer , 而原來 Buffer 中的空間就浪費了. Buffer 的大小和線程數量都會影響這些碎片的多少.

Region 可以還說是 G1 回收器一次回收的最小單元, 即每次回收都是回收 N 個 Region , 每一次回收 G1會優先選擇可能回收最多垃圾的 Region 進行回收.

G1使用了停頓可預測模型, 來滿足用戶設定的 GC 停頓時間, 根據用戶設定的目標時間, G1 會自動化的選擇哪些 Region 要清除, 一次清除多少個.

G1 從多個 Region 中複製存活的對象, 然後集中放入一個 Region 中, 同時整理, 清除內存. (複製算法).

例如: 對兩個 Eden 進行回收, 然後每個 Eden 都有可能有存活的對象, 那麼那些存活的對象就會集中移動到一個 Survivor 區, 原有的兩各個 Eden 被設置爲空閒 Regio

4.5.2 RSet - Remember Set

RSet 即 Remember Set, 在 G1 中用來記錄從其他 Region 指向一個 Region 的指針情況. 因此一個 Region 就會有一個 RSet. 當虛擬機發現執行一個引用類型的寫操作時, 就會產生一個寫屏障, 檢查該引用的對象是否在同一個 Region 中. 如果不是, 則將該引用信息添加到被引用對象的 RSet 中.
例如. Region9 中的對象引用了 Region2 中的對象. 那麼 Region9 就會被記錄在 Region2 對應的 RSet 中.

G1 回收器採用了雙重過濾, 過濾掉同一個 Region 內部的引用, 過濾掉空引用

那麼一個線程修改了 Region 內部的引用, 就必須要去通知 RSet, 更改其中的記錄, 爲了達到這種目的, 這裏又用到了卡表 (Card Table), 每一個 Region 內部又被分爲了固定大小的若干個卡頁關於卡表,卡頁在上面 CMS 中已經說過了, 引用發生改變, 就會被標記爲髒卡, 同時 RSet 也會記錄這個數據. 一般來說 RSet 其實是一個 Hash Table, Key 是別的 Region 的起始地址, Value 是一個集合. 裏面的元素是卡表的索引.

使用 RSet 的好處就是, 在回收時, 在 GC Roots 的枚舉範圍中加入 RSet, 就可以快速知道 Region 的引用情況. 避免整個堆的掃描. 例如要回收某個新生代的 Region, 通過 RSet 就可以快速的知道是否有老年代的 Region 引用它裏面的對象

4.5.3 CSet - Collect Set

CSet 即 Collect Set, 是 G1 垃圾回收器選擇的待回收 Region 集合. G1 每次 GC 不是全部 Region 都參與的, 可能只清理少數幾個, 這幾個就可以稱爲 CSets.

在 GC 時, 對於 Old 到 Young 和 Old 到 Old 的跨代對象引用, 只需要掃描對應 CSet 中的 RSet 即可.

G1垃圾回收器軟實時的特性就是通過 CSet 的選擇來實現的. 對於新生代回收 CSet 只容納 Eden Region 與 Survivor Region. 對於混合回收(Mixed GC), CSet 還會容納部分在全局併發標記階段標記出來的回收後收益高的老年代 Region.

4.5.4 G1 的 GC 模式

G1 提供了兩種 GC 模式, Young GC 和 Mixed GC. 兩種都是需要 STW 的.

  1. Young GC
    Young GC 主要是對 Eden 區進行 GC , 它會將 Eden 區存活的對象移動到 Survivor 區, 如果 Survivor Region 空間不夠, Eden 空間的部分數據會直接晉升到老年代 Region. 舊 Survivor Region 的數據移動到新的 Survivor Region 中, 也有部分會晉升到老年代 Region. 最終 Eden Region 數據爲空. 以通過調整 新生代 Region 的數量來達到軟實時.
    觸發條件: 在 Eden 區空間耗盡時會被觸發 同時在初始標記時也會伴隨着一次 Young GC.
  1. Mixed GC
    Mixed GC 又被稱爲混合 GC, 也就是說不僅可以進行正常的新生代GC, 同時也回收部分老年代 Region. 通過調整老年代 Region 的數量來達到軟實時. 它的 GC 過程又分爲 2 部分

    • Gloabal concurrrent marking 全局的併發標記, 執行過程分爲下面四個步驟

      Gloabal concurrent marking 在G1 GC中, 它主要是爲 Mixed GC 提供標記服務, 並不是一次 GC 的一個必要環節.

      • 初始標記:  它標記了從 GC Roots 開始直接可達的對象, 與 CMS 類似. 不同的是這個過程是和 Young GC 的暫停過程一起的. (因爲可以複用掃描 GC Roots 操作). 需要 STW.

      • 併發標記:  這個階段從GC Roots 開始對堆中的對象進行標記, 標記線程與應用程序線程併發執行, 並且收集各個 Region 的存活對象信息. 不需要 STW. 因爲是併發執行, 所以可能會有引用變更, 就是在 CMS 中說過的 漏標和多標的情況. 在介紹讀寫屏障的時候也說過 G1 使用寫屏障 + SATB 來解決.

        SATB 是最開始用於實時垃圾回收器的一種技術, G1 垃圾回收器使用該技術在標記階段記錄一個存活對象的快照, 然後在併發標記階段, 應用可能修改了原本的引用, 例如刪除了一個原本的引用, 這就會導致併發標記結束之後的存活對象的快照與 SATB 不一致. 通過在上面介紹的寫屏障, 每當存在引用更新的情況, G1 會將修改之前的值寫入到一個 Log Buffer (這個記錄會過濾掉原本是空引用的情況), 在最終標記階段掃描 SATB, 修正 SATB 誤差.

      • 最終標記:   該階段只需要掃描 SATB 記錄的 Log Buffer, 處理在併發標記階段參數的新的存活對象的引用. 需要STW.

      • 清除垃圾:  如果發現完全沒有活對象的 Region, 那麼將此 Region 加入到空閒 Region 列表中. 在該階段會重置 RSet.

    • Evacuation 拷貝存活對象: 這個階段是需要 STW 的.把一部分 Region 裏的活對象並行拷貝到空閒Region, 然後回收原本的 Region. 該階段可以自由選擇任意多個 Region 來構成收集集合 CSet.

      • Evacuation 的觸發時機在不同模式下會有一些不同. 相同點是, 只要堆的使用率達到了某個閾值, 就必然會觸發 Evacuation. 這是爲了確保在 Evacuation 時有足夠的空閒 Region 來容納存活的對象.

    G1 會自動在 Young GC 與 Mixed GC 之間切換, 並且定期觸發 Gloabal concurrrent marking . HotSpot 的 G1 實現允許指定一個參數 InitiatingHeapOccupancyPercent, 在達到該參數的情況下, 就會執行 Gloabal concurrrent marking. 當統計得到老年代的可回收對象超過了 5% 時(JDK1.8 中默認爲 5%) ,就會觸發一次 Mixed GC.

    無論出於何種模式, Young Region 都在被回收的範圍, 而老年代 Region 只能期望於 Mixed GC. 但是與 CMS 垃圾回收器中的問題一樣, Mixed GC 可能會來不及回收老年代 Region, 也就是說在需要分配老年代對象的時候, 並沒有足夠的空間, 這個時候就只能觸發一次 Full GC.

4.5.5 停頓預測模型

G1 垃圾回收器突出表現出來的一點是通過一個停頓預測模型根據用戶配置的停頓時間來選擇 CSet 的大小, 從而達到用戶期待的應用程序停頓時間. 但是停頓時間的設置也並不是越短越好.

設置的時間越短也就意味着 CSet 越小, 導致垃圾逐步積累變多, 最終不得不退化成別的垃圾回收器.
停頓時間設置的過長, 那麼會導致每次都會產生長時間的停頓, 影響了程序對外的響應時間.

G1 垃圾回收器內容參考: G1垃圾回收器詳解

 

4.6 ZGC - The Z Garbage Collector 回收器

ZGC (The Z Garbage Collector) 是 JDK 11 中推出的一款低延遲垃圾回收器, 承若在數 TB 的堆上具有非常低的停頓時間. 它的設計目標包括 停頓時間不超過 10ms, 停頓時間不會隨着堆的大小,或者活躍對象的大小而增加. 支持 8M - 4TB 級別的堆.

4.6.1 ZGC 基本原理

ZGC 採用標記-複製算法, 不過 ZGC 對該算法做了重大改進, ZGC 在標記, 複製和重定位階段幾乎都是併發的, 這就是 ZGC 實現停頓時間小於 10ms 目標的最關鍵原因.
關鍵技術

  • 着色指針
    着色指針是一種將信息存儲在指針中的技術
  • 讀屏障
    在上面有說明.

關於 ZGC 垃圾回收器是非常複雜的, 這裏沒有繼續進行學習了, 更多關於 ZGC 回收器的相關知識, 可以參考下面兩篇文章,

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章