徹底解決 GC 停頓帶來的延遲問題

ZGC 是由提議 JEP 333(https://openjdk。java。net/jeps/333)引入 Hotspot Runtime,其目標是為了徹底解決 GC 停頓帶來的延遲問題,總的設計目標為:

每次 GC 總的停頓時間控制在 10ms 以下

相對於 G1,應用的吞吐率降低不超過 15%

支援大堆和特大堆(8MB~16TB),並且停頓時間不隨堆大小的增長而增長

徹底解決 GC 停頓帶來的延遲問題

由設計目標可知,ZGC 主要是為現在及未來大堆的管理問題服務,致力於以最小的效能損失換取最大的停頓優勢。從 Oracle 釋出的測試資料來看 (參見 [1]),上圖中 SPECjbb2015 上 ZGC 的吞吐率(max-JOPS)和 Parallel GC、G1GC 相差無幾,而體現停頓影響的指標 critical-JOPS 則提升了 20%+;在暫停時間上,ZGC 則不會超過 10ms,而 Parallel GC 和 G1GC 則高達 100ms+,如下圖所示。因此,ZGC 尤其適合對延遲比較敏感的大堆任務。

徹底解決 GC 停頓帶來的延遲問題

2。1 ZGC 演算法實現

為減少停頓,需要減少 STW 中執行的任務,ZGC 主要在以下三個方面進行推進:

GC Roots 的掃描。將能夠移除到 STW 以外的 Roots 掃描外移到併發階段,Roots 掃描的併發外移需要對 Roots 的資料結構進行改造,以支援 GC 執行緒和 Java 執行緒同時操作。

Runtime 資料結構的處理。在 Runtime 中維護了很多張表來記錄 Meta(class、method、jit code 等),並且 Java 存在一類特殊的弱引用,即 java。lang。ref。Reference 及其子類,需要額外處理。

物件移動的併發化改造。為了能夠讓移動物件和 Java 執行緒同時執行,需要增加 Read barrier 來保證每次物件 field 讀取的正確性。

ZGC 在 GC 演算法的處理邏輯上有很大的變更,但是在整體邏輯上,與其前輩 GC 演算法一樣,都是 Mark&Compact 形式。具體實現上,ZGC 下面六個階段透過來實現低延遲的 GC 演算法,如下圖所示:

徹底解決 GC 停頓帶來的延遲問題

第一個階段是 Pause Mark Start:主要做一些全域性狀態的設定和全域性資料結構的初始化這類輕量化的任務,標明後續併發階段需要做 GC 的 Concurrent Mark。

第二個階段是 Concurrent Mark & Remap:將耗時佔比最大的 GC Roots 進行併發化改造,支援併發 Roots 標記。從 GC Roots 進行物件圖的併發標記。上一輪 GC 的指標更新(Remap)透過 Piggyback,放到當前階段執行,從而減少對物件圖的遍歷。

第三個階段是 Pause Mark End:這一階段做 Concurrent Mark 的同步,結束併發標記階段,同時設定部分全域性變數。

第四個階段是 Concurrent Prepare:這一階段主要做 java。lang。ref。Reference 等弱引用的處理,並選擇出需要 Compact 的 ZGC Region。

第五個階段是 Pause Relocate Start:這一階段和第三階段比較類似,主要是全域性同步,設定全域性變數,並指示 Relocate 階段的開始。

第六個階段是 Concurrent Relocate:併發的搬移物件。

相對於其他 GC,ZGC 需要三個 STW 階段來做全域性的同步,但每個 STW 中的任務都很明確,需要完成的任務的時間和 CPU 的處理速度正相關,因此可以做到 ms 級別的停頓。相對於 G1GC,ZGC 的難點在於如何進行 GC Roots 的併發化改造和物件搬移的併發化改造。

徹底解決 GC 停頓帶來的延遲問題

對於物件搬遷的併發化改造,ZGC 則採用 Colored Pointer 來實現輕量級的 Read Barrier,如上圖所示。對於 64bit 的系統,高位 bit 中拿出 4 個 bit 來指示不同的處理狀態,兩個 Mark 位表明該物件指標是否已經被標記,採用兩個 Mark bit 可以在前後不同的 GC 時使用不同的 Mark bit;Remapped 位表示當前物件指標是否已經調整為搬移之後的物件指標;Finalizable 位主要是為 Finalizable 物件服務,用來表示該物件指標是否僅經 Finalize 物件標記,主要供 Mark 階段和弱引用處理階段使用。透過 Colored 指標,不同的 GC 階段,當前 Runtime 的正確的指標顏色僅為一種顏色 (Marked 或者 Remapped),就可以透過下圖所示,測試物件指標是否為 bad color 即可,在 x86 上最終實現為一條 test 指令和一條 jne 跳轉指令。

徹底解決 GC 停頓帶來的延遲問題

Colored Pointer 導致不同的時期,物件的指標的高位是不同,如下圖中的物件指標 0x0000000012345678,在程式執行過程中,可能以下面三種狀態被 Java 執行緒感知到:Remapped 狀態、Mark1 狀態、Mark0 狀態。為了使得這幾種不同的狀態(不同值的指標),指向同一份物件,ZGC 完全利用了作業系統的虛擬地址和物理地址轉換,使得這三種狀態的虛擬地址指標指向同一份物理地址,因此 ZGC 的 Java 堆需要在虛擬地址中佔用三份地址。ZGC 透過記憶體檔案來佔用實際的物理記憶體,然後將這個記憶體檔案對映到 Remapped、Mark0 和 Mark1 指向的虛擬地址。可以看出,雖然表面上 ZGC 的 Java Heap 佔用了三份虛擬地址,但是實際的物理地址只有一份。這也是 linux 的命令 top 或者 ps 看到啟用 ZGC 的 Java 程序 RSS 記憶體膨脹三倍的原因,但開啟 ZGC 之後觀察到的 RSS 消耗並非實際物理記憶體消耗。

徹底解決 GC 停頓帶來的延遲問題

2。2 ZGC 演算法的開銷

ZGC 對於業務執行緒的影響主要集中在以下五個方面:

Read barrier 的開銷。在 Java 程式中,物件指標的讀取次數要遠超於物件指標的寫入次數,Read Barrier 的插入點要遠多於 Write Barrier 的插入點,因此 ZGC 的 Read Barrier 會對程式的效能產生較大的負面影響。

JIT 方法的 entry barrier 開銷。如果 JIT 之後的程式碼包含了已經死掉的 java 物件,那麼該方法就應該丟棄掉,因此 JIT 的程式碼需要在進入時利用一個 entry barrier 來保證自身和其包含的 meta 資訊的有效性。ZGC 對每個 JIT 程式碼都生成 nmethod entry barrier,會對 JIT 方法產生輕微的效能損失。

Frame barrier 開銷。為併發進行 Java 棧幀的掃描,降低 Stack Roots 掃描對 STW 時間的影響,當前 Hotspot 採用 StackWaterMark 來進行併發掃棧。同時為了降低業務執行緒掃描棧幀的工作量,Hotspot 中採用單個棧幀掃描的方式,即在回棧時如果超過當前 stack water mark,就會陷入 stack mark barrier,修復 caller 的 java 物件指標。參見 https://openjdk。java。net/jeps/376

其他 Runtime 改造產生的鎖結構帶來的開銷。

ZGC 中大部分的 GC 工作放在併發階段,因此併發階段 GC 執行緒和 Java 業務執行緒搶佔 CPU,導致的對業務執行緒的搶佔開銷。

可以看出 ZGC 為了降低 STW 造成的停頓影響,採取的措施是極致的併發化改造,也就是以輕微的效能損失換取最低的停頓影響。當前最新的 ZGC 實現停頓已經達到 ms 級別,低於 Linux 核心的背景噪聲,即排程開銷和系統呼叫開銷,也有可能造成 10ms 級別的影響,可以說 ZGC 使得 Java 不能服務實時業務的古板印象得到徹底的顛覆。

3。 ZGC 使用與調參

3。1 ZGC 典型應用場景

此之蜜糖、彼之砒霜,不同的 GC 演算法都有其長短處,ZGC 出現的最大優勢是能夠在保證停頓時間控制 10ms 以下,但為了實現這種高 SLA 的停頓時間,其代價是效能的損失和記憶體消耗。從前面介紹可以看出,為了降低 STW 中的工作,很多 GC 任務做了併發化改造,而併發化改造的代價則散亂在各種執行細節中,透過整個 OpenJDK 社群的持續投入,當前 ZGC 在效能損失場景中的效能下降已經控制在很小的範圍內。對於效能來說,不同的配置對效能的影響是不同的,如充足的記憶體下即大堆場景,ZGC 在各類 Benchmark 中能夠超過 G1 大約 5% 到 20%,而在小堆情況下,則要低於 G1 大約 10%;不同的配置對於應用的影響不盡相同,開發者需要根據使用場景來合理判斷。當前 ZGC 不支援壓縮指標和分代 GC,其記憶體佔用相對於 G1 來說要稍大,在小堆情況下較為明顯,而在大堆情況下,這些多佔用的記憶體則顯得不那麼突出。因此,以下兩類應用強烈建議使用 ZGC 來提升業務體驗:

超大堆應用。超大堆(百 G 以上)下,CMS 或者 G1 如果發生 Full GC,停頓會在分鐘級別,可能會造成業務的終端,強烈推薦使用 ZGC。

高 SLA 需求的應用。如對響應時間有 P999 時限要求的實時和軟實時應用,此類應用無論堆大小,均推薦採用低停頓的 ZGC。

3。2 ZGC 引數設定

ZGC 之美不僅在於其超低的 STW 停頓,也在於其引數的簡單,絕大部分生產場景都可以自適應。當然,極端情況下,還是有可能需要對 ZGC 個別引數做個調整,大致可以分為三類:

堆大小:Xmx。ZGC 能夠透過極致的低延遲滿足業務高標準 SLA 的服務准入條件,但是與所有程式語言的 concurrent GC 類似,延遲是以記憶體空間作為 trade-off 的。當分配速率過高,超過回收速率,造成堆記憶體不夠時,會觸發 Allocation Stall,這類 Stall 會減緩當前的使用者執行緒。因此,當我們在 GC 日誌中看到 Allocation Stall,通常可以認為堆空間偏小或者 concurrent gc threads 數偏小。

GC 觸發時機:ZAllocationSpikeTolerance, ZCollectionInterval。ZAllocationSpikeTolerance 用來估算當前的堆記憶體分配速率,在當前剩餘的堆記憶體下,ZAllocationSpikeTolerance 越大,估算的達到 OOM 的時間越快,ZGC 就會更早地進行觸發 GC。ZCollectionInterval 用來指定 GC 發生的間隔,以秒為單位觸發 GC。

GC 執行緒:ParallelGCThreads, ConcGCThreads。ParallelGCThreads 是設定 STW 任務的 GC 執行緒數目,預設為 CPU 個數的 60%;ConcGCThreads 是併發階段 GC 執行緒的數目,預設為 CPU 個數的 12。5%。增加 GC 執行緒數目,可以加快 GC 完成任務,減少各個階段的時間,但也會增加 CPU 的搶佔開銷,可根據生產情況調整。

由上可以看出 ZGC 需要調整的引數十分簡單,通常設定 Xmx 即可滿足業務的需求,大大減輕 Java 開發者的負擔。當前 Tencent Kona JDK11 上開啟 ZGC 的引數為:“-XX:+UnlockExperimentalVMOptions -XX:+UseZGC”。