JavaZGC深度剖析及其在構建低延遲流系統中的實踐心得

架構互聯高可用 2024-06-30 13:44:34

01

前言

在 Java 應用程序中,垃圾回收(Garbage Collection,以下簡稱 GC)是一個不可避免的過程,它負責釋放不再使用的內存空間以避免內存泄漏。然而,GC 操作通常會導致短暫的停頓時間(Stop the World,以下簡稱 STW),這對于對延遲敏感的應用程序來說是一個嚴重的問題——STW 會導致應用程序暫停響應,從而影響用戶體驗和系統性能。

爲了解決這個問題,Java 引入了 Z Garbage Collector(以下簡稱 ZGC),它是一種低延遲垃圾回收器,旨在減少 GC 引起的停頓時間。ZGC 通過使用並發和分區收集技術,大大減少了 STW 的時間和頻率,使得應用程序可以在 GC 期間繼續運行,從而提供更加平滑和一致的性能。

AutoMQ 基于 ZGC 進行了一系列調優,以獲得更低的延遲。在本文中,我們將詳細介紹 ZGC 的工作原理,以及如何通過調整和優化 ZGC 的配置來實現更低的延遲,從而提高 Java 應用程序的性能和響應能力。

02

ZGC 特點

在介紹 ZGC 的實現原理之前,我們先來了解一下 ZGC 的特點,以便更好地理解 ZGC 的工作原理:

可擴展性:ZGC 支持各種規模的內存大小,從 8MB 到 16TB,可以滿足不同規模和需求的應用程序。

極低延遲:單次 GC 操作 STW 時間低于 1ms(一般不超過 200 μs),平均僅需數十微秒。

可預測性:ZGC 的 STW 時長不會隨著堆大小的增加、對象數量的增加或者 GC 操作的頻率而增加,因此可以提供可預測的性能。

高吞吐量:ZGC 的吞吐量與 G1GC 相當,可以滿足高吞吐量的應用程序需求。

自動調優:ZGC 會自動調整自身的配置參數,以適應不同的應用程序和環境,減少了手動調優的工作量。

03

ZGC 工作原理

下面我們將詳細介紹 ZGC 的工作原理,以便更好地理解 ZGC 的優勢和特點。

注意:以下介紹均基于 JDK 17 版本的 ZGC,部分內容可能與其他版本有所不同,例如,沒有涉及到 JDK 21 中引入的分代(Generational)ZGC。

3.1 核心概念

著色指針與多重映射

ZGC 使用了一種稱爲“著色指針(Colored Pointers,又稱染色指針)”的技術,它將對象指針的高位用于存儲額外的信息,這些額外的信息可以用于標記對象的狀態,進而幫助 ZGC 實現高效的並發垃圾回收。ZGC 中著色指針的結構如下圖所示:

如上圖所示,著色指針的高位包含了 20 位的元數據,這 20 位元數據用于存儲對象的標記信息。目前,ZGC 中使用了其中的 4 位,剩余的 16 位保留用于未來的擴展。這 4 位的作用如下:

Marked0 & Marked1:這兩位表示對象是否已被 GC 標記,以及是在哪個周期標記。ZGC 在每個 GC 周期中交替使用這兩位,以確定對象是在上個周期亦或當前周期被標記。

Remapped:該位表示指針是否已經進行了重映射,即指針不再指向遷移集合(Relocation Set)中的對象。

Finalizable:該位表示對象是否僅通過 finalizer 可達。需要注意的是,JDK 18 中的 JEP 421 已經將 finalization 標記爲過時,並將在未來的版本中移除。

Java 應用程序本身不會感知到著色指針,當從堆內存中加載對象時,著色指針的讀取由讀屏障處理。

相較于傳統的垃圾回收器將對象存活信息記錄在對象頭中,ZGC 基于著色指針記錄了對象狀態,在修改狀態時僅爲寄存器操作,無需訪問內存(對象頭的 Mark Word),速度更快。

由于著色指針在對象地址的高位存儲了額外的信息,因此會有多個虛擬地址映射到同一個對象,此即多重映射(Multi-Mapping)。在 ZGC 中,每個對象的物理地址會映射到三個虛擬地址,分別對應著色指針的三種狀態,下圖展示了多重映射的實際情況:

值得一提的是,某些監控工具(比如 top)沒有處理這種多重映射的場景,這會導致其無法正確識別開啓了 ZGC 的 Java 進程占用的內存——監控值會顯示爲實際值的 3 倍,甚至可能會出現使用 100%+ 物理內存的現象。

讀屏障

在上一小節中,我們提到了著色指針的讀取由讀屏障處理。讀屏障(Load barriers)是 JIT 編譯器(C2)注入到類文件中的代碼段,它會在 JVM 解析類文件時添加到所有從堆中檢索對象的地方。下面的 Java 代碼示例展示了讀屏障會被添加的地方:

Object o = obj.fieldA; // 從堆中讀取 Object,會觸發讀屏障Object p = o; // 沒有從堆中加載,不會觸發讀屏障o.doSomething(); // 沒有從堆中加載,不會觸發讀屏障int i = obj.fieldB // 加載的不是對象,不會觸發讀屏障

具體的插入方式形如:

Object o = obj.fieldA;// 觸發讀屏障if (o & bad_bit_mask) { // o 的著色指針的顔色不對,進行修複 slow_path(register_for(o), address_of(obj.fieldA));}

實際的彙編實現:

mov 0x20(%rax), %rbx // Object o = obj.fieldA; // %rax 寄存 obj 地址,0x20 爲 fieldA 在其中的偏移量,%rbx 用于寄存 Object o 的地址test %rbx, %r12 // if (o & bad_bit_mask) // %r12 寄存染色指針當前 bad color 的掩碼 // ZGC 不支持壓縮對象指針(compressed oops),故可以利用爲壓縮指針預留的 %r12 寄存器jnz slow_path // %rbx 中的指針爲 bad color,修複顔色——按需修改 0x20(%rax) 與 %rbx

ZGC 中,讀屏障注入的代碼會檢查對象指針的顔色,如果顔色是“壞的”,那麽讀屏障會嘗試修複顔色——更新指針,使它指向對象的新位置,或者遷移對象本身。

這種處理方式保證了,在一次 GC 期間,對象遷移等重操作僅會在首次加載對象時發生,之後的加載操作則會直接讀取對象的新位置,額外開銷僅爲一次位運算判斷。據官方測試,ZGC 讀屏障帶來的額外性能開銷在 4% 左右。

區域化內存管理

類似于 G1GC,ZGC 會動態地將堆劃分爲獨立的內存區域(Region),但是,ZGC 的區域更加靈活,包括小、中、大三種尺寸,活躍區域的數量會根據存活對象的需求而動態增減。

將堆劃分爲區域可以帶來多方面的性能優勢,包括:

分配和釋放固定大小的區域的成本是恒定的。

當區域內的所有對象都不可達時,GC 可以釋放整個區域。

相關對象可以被分組到同一個區域中。

值得注意的是,所謂的“小區域”、“中區域”和“大區域”並不是指區域的大小,而是指區域的類別和用途。例如,一個大區域可能比一個中等區域還要小。下面將介紹不同區域尺寸及其用途:

小區域:小區域的大小爲 2 MB,用于存儲小于 1/8 區域大小(即 256 KB)的對象。小區域的大小是固定的,不會隨著堆的大小而變化。

中區域:中區域的大小會根據堆的大小(-XX:MaxHeapSize,-Xmx)而變化。如下表所示,中區域的大小可能爲 4 / 8 / 16 / 32 MB,特別地,如果堆大小小于 128 MB,則不會有中區域。中區域用于存儲小于 1/8 區域大小的對象。

大區域:大區域用于存儲巨大對象,其大小與對象的大小緊密匹配,以 2 MB 爲增量。例如,一個 13 MB 的對象將被存儲在一個 14 MB 的大區域中。任何無法適應中區域的對象都將被放置在自己的大區域中,每個大區域僅會放置一個大對象,並且不會被重複利用。

壓縮與遷移

上一小節中提到,區域化的優勢之一是可以利用“大多數同一時間創建的對象也會在同一時間離開作用域”的特點。然而,並非所有對象都是這樣,在區域內部必然會産生碎片,導致內存利用率下降。

基于內部的啓發式算法,ZGC 會將主要由不可訪問對象組成的區域中的對象複制到新區域中,以便釋放舊區域並釋放內存,這就是壓縮與遷移(Compaction and Relocation)。ZGC 通過兩種遷移方法實現壓縮:就地遷移和非就地遷移。

非就地遷移:ZGC 的首選遷移方法,當存在空區域可用時,ZGC 會執行非就地遷移。非就地遷移的示例如下:

就地遷移:當沒有空區域可用時,ZGC 將使用就地遷移。在這種情況下,ZGC 會將對象移動到一個較爲稀疏的區域中。就地遷移的示例如下:

值得說明的是,在執行就地遷移時,ZGC 必須首先壓縮指定爲對象遷移區域內的對象,這可能會對性能産生負面影響。增加堆大小可以幫助 ZGC 避免使用就地遷移。

3.2 工作流程

值得說明的是,在執行就地遷移時,ZGC 必須首先壓縮指定爲對象遷移區域內的對象,這可能會對性能産生負面影響。增加堆大小可以幫助 ZGC 避免使用就地遷移。

如上圖,ZGC 的工作流程主要包括以下幾個步驟:

(STW)標記開始

標記階段開始的同步點,只會執行一些小的操作,例如設置一些標記位和確定全局顔色。

值得說明的是,在 JDK 16 之前,該階段的耗時和 GC Roots(靜態變量與線程棧中的局部變量)的數量成正比。因此在 JEP 376 中引入了一種新的算法,將掃描線程棧的操作轉移到並發階段,從而顯著減少了該階段的耗時。

(並發)標記與重映射

在這個並發階段,ZGC 將遍曆整個對象圖,並標記所有對象(根據 GC 周期不同,設置 Marked0 或 Marked1 標記)。同時,將上一個 GC 周期中尚未被重映射的對象(標記仍爲 Marked1 或 Marked0)進行重映射。

(STW)標記結束

標記階段結束的同步點,會處理一些邊界情況。

(並發)遷移准備

該階段會處理弱引用、清理不再使用的對象,並篩選出需要遷移的對象(Relocation Set)。

(STW)遷移開始

遷移階段開始的同步點,通知所有涉及到對象遷移的線程。

同樣的,在 JDK 16 引入 JEP 376 之後,該階段的耗時不再與 GC Roots 的數量成正比。

(並發)遷移

該階段會並發地遷移對象,壓縮堆中的區域,以釋放空間。遷移後的對象的新地址會記錄到轉發表(Forwarding Table)中,用于後續重映射時獲取對象的新的地址;該轉發表是一個哈希表,使用堆外內存,每個區域分別有一個轉發表。

可以看到,在一個 GC 周期中,STW 的階段和並發階段交替執行,並且絕大多數操作均在並發階段執行。

示例

爲了更好地理解 ZGC 的工作原理,下面通過一個例子來展示 ZGC 工作各階段執行的操作。

1. 【GC 開始】初始狀態

上圖中爲 GC 開始前 Java 堆的狀態:共有 3 個區域,9 個對象。

所有新創建的對象初始顔色均爲 Remapped。

2. 【標記階段】從 GC Roots 開始遍曆,標記所有存活的對象

每次 GC 之間的標記階段輪流使用 Marked0 與 Marked1,本次使用 Marked0。

GC Roots(例如,線程棧中引用的對象,靜態變量等)爲每次標記的起點,所有被 GC Roots 引用的對象都應被認爲是存活的;同樣的,如果未被標記(顔色仍爲 Remapped),則認爲可被回收。

3. 【遷移准備階段】選擇需要壓縮的區域,並創建轉發表

檢查各區域發現,區域 1 與區域 2 存在需要回收的對象,將它們加入遷移集合。

並爲所有遷移集合中的區域創建轉發表。

4. 【遷移階段】遍曆所有對象,遷移其中處于遷移集合中的對象

a. 遍曆到對象 1、2,發現它們位于區域 0(不在遷移集合中),無需遷移,僅將顔色恢複爲 Remapped。

b. 遍曆到對象 4、5、7,均在遷移集合中,需要遷移。

創建(或複用)一個新的區域——區域 3,用于放置這 3 個對象。

依次將這 3 個對象遷移至新的區域,並將它們新的地址記錄在轉發表中。

將這 3 個對象的顔色恢複爲 Remapped。

注意:

遷移完成後,遷移集合中的區域 1 與區域 2 即可被複用,用于分配新的對象。但爲了便于理解,圖中保留了 4、5、7 這 3 個對象的曆史位置,並加了“'”號用以區分新老位置。

值得注意的是,此時對象 2(對象 4')中記錄的對象 5(對象 7)的地址仍爲遷移前的地址,指針的顔色也仍爲標記時的顔色 Marked0。

5. 【遷移後的任意時間】用戶線程加載對象

在對象 7 遷移完成後,如果此時用戶線程嘗試加載對象 7,會觸發讀屏障(指針實際顔色 Marked0 與期望顔色 Remapped 不符,是“壞的”)。在讀屏障中,會基于轉發表,將對象 7 的地址重映射對象 7'。

6. 【下一次 GC 標記階段】重映射所有未被用戶線程加載過的對象

在下一次 GC 的標記階段,會使用 Marked1 標記出所有存活對象。

與此同時,發現對象 2 引用了對象 5,而對象 5 的顔色是“壞的”(對象 5 的實際顔色 Marked0 與期望顔色 Remapped 不符),會基于轉發表,將對象 5 的地址重映射對象 5'。

注意:

每次 GC 的 GC Roots 引用的對象可能不同,在本例中,從對象 1 與對象 4' 變成了對象 2 與對象 7'。

7. 【下一次 GC 遷移准備階段】清理轉發表

與之前的遷移准備階段類似,需要確定遷移集合、創建轉發表。此外,還需要將上一次 GC 的轉發表刪除。

04

使用 ZGC

接下來,我們將介紹如何更好地使用 ZGC,以及一些基本的調優方法。

4.1 配置

正如在本文開頭所述,ZGC 的一個設計目標是,盡可能自動調整自身的配置參數,以減少手動配置項。但是我們還是應該了解各個配置的含義以及對 ZGC 的影響,以應對實際生産中的各種需求。

-XX:+UseZGC:開啓 ZGC。

-XX:MaxHeapSize, -Xmx:堆的最大大小。它是 ZGC 最重要的調優配置,它的數值越大,ZGC 的理論性能上限越高,但同時也可能會造成部分內存浪費。

由于 ZGC 是一個並發垃圾回收器,最大堆的大小必須滿足:能夠容納應用程序的存活對象,並且有足夠的空間以便在 GC 運行期間分配新的對象。出于同樣的原因,ZGC 比傳統 GC 需要相對更多的冗余空間。

-XX:+UseDynamicNumberOfGCThreads:是否開啓並發階段動態 GC 線程數,默認爲開啓。

° 當開啓時,ZGC 會根據 GC 運行狀態(例如 GC 耗時、堆空余空間、對象分配頻率等)由內置的啓發式算法自動選擇並發階段的 GC 線程數量(最小爲 1,最大爲 -XX:ConcGCThreads)。

° 當關閉時,則會固定使用 -XX:ConcGCThreads 數量的線程。

-XX:ConcGCThreads:用于控制並發階段的 GC 線程數量。當開啓 -XX:+UseDynamicNumberOfGCThreads 時,默認值爲處理器數量的 1/4(向上取整);關閉時,默認值爲處理器數量的 1/8(向上取整)。

° 該配置過高可能會導致 GC CPU 占用過多,進而導致應用程序延遲上升。

° 過低則可能導致 GC 不及時以至于發生 Allocation Stall(無法分配新對象)。

° 推薦開啓 -XX:+UseDynamicNumberOfGCThreads 以自動調整並發階段的 GC 線程數量

-XX:ParallelGCThreads:用于控制 STW 階段的 GC 線程數量。默認值爲處理器數量的 60%(向上取整)。

-XX:+UseLargePages:用于控制是否開啓巨頁(Huge Page,又稱 Large Page)。開啓後可以提高 ZGC 吞吐、降低延遲,並加快啓動速度。默認關閉,開啓前需要在 OS 分配巨頁。

-XX:+ZUncommit、-XX:ZUncommitDelay:用于控制是否將不使用的內存返回給操作系統,以及返回前等待的時間。當 -XX:MaxHeapSize 與 -XX:MinHeapSize 相同時,則不會生效。默認值爲開啓、300 秒。

需要注意的是,開啓該功能可能會導致分配內存變慢,進而導致延遲升高。對于對延遲較爲敏感的應用程序,建議將 -Xmx 與 -Xms 設置成相同的值。特別地,可以開啓 -XX:AlwaysPreTouch 以在應用啓動前預分配內存,進而降低延遲。

-XX:ZAllocationSpikeTolerance:用于控制 GC 頻率自適應算法的“毛刺系數”。ZGC 內置了一套自適應算法,會根據對象分配頻率與堆可用空間自動調整 GC 頻率。該配置的值越大,該算法會更加敏感,即,更容易因爲對象分配頻率的增加而增大 GC 頻率。默認值爲 2。

該配置值過小會導致對象分配速率激增時 GC 不及時,進而可能導致 Allocation Stall;過大則可能會導致 GC 頻率過高,占用 CPU 資源增加,影響應用延遲。

-XX:ZCollectionInterval:用于控制每次 GC 的最大時間間隔。默認值爲 0,即不做限制。

-XX:ZFragmentationLimit:用于控制每個區域碎片的最大占比。配置爲更小的值會導致內存壓縮是更加激進,花費更多的 CPU 以換取更多的可用內存。默認值爲 25。

-XX:+ZProactive:用于控制是否啓用主動 GC 循環。如果啓用此選項,ZGC 將在預計對運行中的應用程序影響最小的情況下啓動主動 GC 循環。默認開啓。

4.2 日志

可以通過設置 -Xlog:gc*:gc.log 選項以開啓 ZGC 日志。其中 "gc*" 意爲打印所有 tag 中以 "gc" 開頭的日志,"gc.log" 爲日志存儲路徑。

下面以 AutoMQ 在實際運行時的一次 GC 爲例,按照不同的 log tag,解釋 ZGC 日志的含義。

"gc,start","gc,task","gc"

[gc,start ] GC(100) Garbage Collection (Timer)[gc,task ] GC(100) Using 1 workers...[gc ] GC(100) Garbage Collection (Timer) 2240M(36%)->1190M(19%)

第 1 行標志了一次 GC 的開始,是進程啓動後的第 100 次(從 0 開始計數)GC,觸發原因爲 "Timer"。ZGC 可能的觸發條件有:

Warmup:ZGC 首次啓動後的預熱。

Allocation Rate:由 ZGC 內部自適應的 GC 頻率算法觸發。如前文所述,其敏感度受 -XX:ZAllocationSpikeTolerance 控制。

Allocation Stall:在分配對象時,堆可用內存不足時觸發。這會導致部分線程阻塞,應盡可能避免該場景。

Timer:當 -XX:ZCollectionInterval 配置不爲 0 時,定時觸發的 GC。

Proactive:當應用程序空閑時由 ZGC 主動觸發,受 -XX:+ZProactive 控制。

System.gc():在代碼中顯式調用System.gc()時觸發。

Metadata GC Threshold:元數據空間不足時觸發。

第 2 行意爲該次 GC 使用了 1 個並發線程,受 -XX:ConcGCThreads 與 -XX:+UseDynamicNumberOfGCThreads 控制。

最後 1 行標志了一次 GC 的開始,GC 開始前堆中占用的內存爲 2240M,占堆總大小的 36%;GC 完成後爲 1190M,占 19%。

"gc,phases"

[gc,phases ] GC(100) Pause Mark Start 0.005ms[gc,phases ] GC(100) Concurrent Mark 1952.113ms[gc,phases ] GC(100) Pause Mark End 0.018ms[gc,phases ] GC(100) Concurrent Mark Free 0.001ms[gc,phases ] GC(100) Concurrent Process Non-Strong References 79.422ms[gc,phases ] GC(100) Concurrent Reset Relocation Set 0.066ms[gc,phases ] GC(100) Concurrent Select Relocation Set 12.019ms[gc,phases ] GC(100) Pause Relocate Start 0.009ms[gc,phases ] GC(100) Concurrent Relocate 149.037ms

記錄了 ZGC 各個階段的耗時,其中 "Pause" 與 "Concurrent" 分別標識了 STW 階段與並發階段。每次 GC 會存在 3 個 "Pause" 階段,應主要關注它們的耗時。

"gc,load",

[gc,load ] GC(100) Load: 2.74/2.02/1.54

記錄了過去 1 分鍾、5 分鍾、15 分鍾的平均負載,即系統的平均活躍進程數。

"gc,mmu"

[gc,mmu ] GC(100) MMU: 2ms/93.9%, 5ms/97.6%, 10ms/98.8%, 20ms/99.4%, 50ms/99.7%, 100ms/99.9%

記錄了 GC 期間的最小可用性(Minimum Mutator Utilization)。以本次 GC 爲例,在任何連續的 2ms 的時間窗口中,應用至少能使用 93.9% 的 CPU 時間。

"gc,ref"

[gc,ref ] GC(100) Soft: 6918 encountered, 0 discovered, 0 enqueued[gc,ref ] GC(100) Weak: 8835 encountered, 1183 discovered, 4 enqueued[gc,ref ] GC(100) Final: 63 encountered, 3 discovered, 0 enqueued[gc,ref ] GC(100) Phantom: 957 encountered, 882 discovered, 0 enqueued

記錄了 GC 期間不同類型的引用對象的處理情況。各字段含義如下:

"Soft":軟引用(SoftReference)。軟引用對象會在內存不足時被回收。

"Weak":弱引用(WeakReference)。弱引用對象只要被垃圾收集器發現,就會被回收。

"Final":終結引用(FinalReference)。終結引用允許對象在被垃圾回收之前執行一些特定的清理操作。

"Phantom":幽靈引用(PhantomReference)。幽靈引用通常用于確保對象被完全回收後才執行某些操作,它比終結引用提供了更精確的控制。

"encountered":GC 期間遇到的引用對象的數量。

"discovered":GC 期間發現需要處理的引用對象的數量。

"enqueued":GC 期間加入到引用隊列(Reference Queue)中的引用對象的數量。

"gc,reloc"

[gc,reloc ] GC(100) Small Pages: 1013 / 2026M, Empty: 2M, Relocated: 41M, In-Place: 0[gc,reloc ] GC(100) Medium Pages: 2 / 64M, Empty: 0M, Relocated: 9M, In-Place: 0[gc,reloc ] GC(100) Large Pages: 3 / 150M, Empty: 0M, Relocated: 0M, In-Place: 0[gc,reloc ] GC(100) Forwarding Usage: 19M

前 3 行記錄了不同大小的區域在 GC 時的表現。以第 1 行爲例:

共有 1013 個小區域,總大小爲 2026 MB

整理過程中發現了 2MB 的未被使用的區域

遷移了 41MB 的對象

其中有 0 MB 是原地遷移(該值過大意味著堆可用空間不足)

第 4 行記錄了遷移對象時,各區域使用的轉發表的總大小。

"gc,heap"

[gc,heap ] GC(100) Min Capacity: 6144M(100%)[gc,heap ] GC(100) Max Capacity: 6144M(100%)[gc,heap ] GC(100) Soft Max Capacity: 6144M(100%)[gc,heap ] GC(100) Mark Start Mark End Relocate Start Relocate End High Low[gc,heap ] GC(100) Capacity: 6144M (100%) 6144M (100%) 6144M (100%) 6144M (100%) 6144M (100%) 6144M (100%)[gc,heap ] GC(100) Free: 3904M (64%) 3394M (55%) 3372M (55%) 4954M (81%) 4954M (81%) 3340M (54%)[gc,heap ] GC(100) Used: 2240M (36%) 2750M (45%) 2772M (45%) 1190M (19%) 2804M (46%) 1190M (19%)[gc,heap ] GC(100) Live: - 543M (9%) 543M (9%) 543M (9%) - -[gc,heap ] GC(100) Allocated: - 510M (8%) 534M (9%) 570M (9%) - -[gc,heap ] GC(100) Garbage: - 1696M (28%) 1694M (28%) 75M (1%) - -[gc,heap ] GC(100) Reclaimed: - - 2M (0%) 1620M (26%) - -

記錄了該 GC 周期中,不同階段(標記前、標記後、遷移前、遷移後)的各類內存的大小。具體地說:

Capacity:堆的容量。

Free:堆中空閑的內存大小,與 Used 相加即爲堆的容量。

Used:堆中使用的內存大小,其最大值即爲 GC 期間堆的最大使用量。

Live:堆中存活的對象,即,可達的對象的總大小。

Allocated:和上一階段相比,新分配的對象的大小。

Garbage:堆中垃圾對象的總大小。

Reclaimed:和上一階段相比,回收的垃圾對象的大小。

4.3 版本演進

自 2018 年 ZGC 于 JDK 11 中首次發布以來,在後續的 JDK 版本中,ZGC 也在不斷演進。在選擇使用 ZGC 前,需要了解 ZGC 的版本演進,以及每個版本的特性和限制,並確認對應版本的 ZGC 可以滿足使用需求。

JDK 11:ZGC 首次發布,支持 Linux/x64 平台

JDK 13:支持的最大堆內存大小從 4TB 提升到 16TB;支持 Linux/AArch64 平台

JDK 14:支持 MacOS 和 Windows 平台

JDK 15:首個生産就緒版本

JDK 16:引入 Concurrent Thread Stack Scanning,使得 STW 時間不再隨線程數增加而線性增加,最大 STW 時長從 10ms 降低到 1ms;支持就地遷移

JDK 17:支持 MacOS/AArch64 平台

JDK 18:支持 Linux/PowerPC 平台

JDK 21:支持 Generational ZGC,通過將堆分爲年輕代和老年代,大幅提高 ZGC 的最大吞吐

一般來說,JDK 16 及之後的 ZGC 性能已經優化得足夠好,足以適配絕大多數場景。

05

AutoMQ 的調優實踐

AutoMQ [1] 是我們基于雲重新設計的雲原生流系統,通過將存儲分離至對象存儲,在保持和 Apache Kafka 100% 兼容的前提下,可以爲用戶提供高達 10 倍的成本優勢以及百倍的彈性優勢。在流系統的應用場景中,諸如金融交易、實時推薦等場景都對延遲有非常高的要求。因此在設計 AutoMQ 時候,我們也十分重視延遲指標的優化。

在 AutoMQ 的實現中,我們需要盡可能地減少 GC 的停頓時間。而 ZGC 低延遲的特性完美匹配了我們的場景,AutoMQ 通過使用 ZGC,將 STW 時間降低到了 50μs 以下,大大提升了服務的性能,從而爲用戶提供端到端個位數毫秒的延遲能力。

5.1 案例

下面介紹一些 AutoMQ 在使用 ZGC 時遇到的問題與解決方法。

堆大小選取

使用 ZGC 的第一件事,就是確定堆的大小。有以下幾個方面需要考慮:

由于 ZGC 是一個並發垃圾回收器,相較于傳統 GC(例如 G1GC),ZGC 需要相對更多的冗余空間用于容納 GC 期間新創建的對象。

較多的空閑內存可以使得 ZGC 在遷移階段更多地使用非就地遷移(而非就地遷移),這可以加快 GC 速度,減少 CPU 消耗。但是,過多的冗余內存也會造成資源浪費。

將堆的大小配置爲動態調整可以使應用在空閑時釋放冗余內存,節約資源。但是,這樣做也會導致堆擴容時分配內存變慢,進而導致應用延遲升高。

最終經過充分壓測,將 AutoMQ 在經典機型(2 vCPU,16 GiB RAM)上堆大小相關的配置設爲:

-Xms6g -Xmx6g -XX:MaxDirectMemorySize=6g -XX:MetaspaceSize=96m

由于 AutoMQ 的緩存 Log Cache 與 Block Cache 都使用了 DirectByteBuffer,故還配置了 6 GB 的堆外內存。

在該配置下,可以做到:

通常場景下最高堆內存占用小于 50%,極端場景下小于 70%。

遷移階段不會發生就地遷移。

考慮到 AutoMQ 一般不會與其他應用混部,將堆的最大大小與最小大小設置爲同一個值,以避免堆擴容時延遲升高。

流量激增時延遲抖動

現象

當機器承載流量激增時(從 0 MBps 上升至 80 MBps),會出現數次 “Allocation Stall”(隨後自動恢複),導致內存分配阻塞,應用卡頓。

分析

默認配置下,ZGC 會基于內置的自適應算法決定 GC 頻率,在該算法下,GC 頻率主要由對象分配頻率決定。

但是,當應用壓力突然上升時,該算法可能無法及時感知,導致 GC 不及時,進而導致 Allocation Stall。

解決方法

增大 -XX:ZAllocationSpikeTolerance 的值(默認爲 2),使得 ZGC 能處理更大的抖動(代價是觸發 GC 的時機更加激進,GC 頻率升高,GC 資源消耗變多)

配置 -XX:ZCollectionInterval,以強制定期觸發 GC。

AutoMQ 將 -XX:ZCollectionInterval 設置爲 5s,沒有修改 -XX:ZAllocationSpikeTolerance(這是因爲,每 5 秒進行一次 GC 時,已經能夠承載較大的壓力,不會再有壓力大幅上升的情況)。

進行如上配置後,可以做到:

能夠正常處理流量激增的情況,不會發生 "Allocation Stall"。

通常場景下,會固定 5s 進行一次 GC(日志中記錄爲 "Timer")。

極端場景下,約 3s 進行一次 GC(日志中記錄爲 "Allocation Rate")。

應用啓動後 GC 壓力逐漸升高

現象

在應用啓動後,隨著時間的推移,GC 頻率逐漸上升、耗時變長、CPU 占用升高,並最終發生 “Allocation Stall”。

分析

檢查 GC 日志,發現每次 GC 時,存活對象的大小逐漸增加,導致可用內存減少,最終導致 Allocation Stall。

解決方法

檢查 Heap Dump,發現某模塊存在內存泄露,導致無用對象沒有及時釋放,最終導致上述問題。

修複該問題後,AutoMQ 存活對象的大小維持在 500 MB~600 MB,極端場景下不超過 800 MB。

超大規模集群中 GC 壓力高

現象

在超大規模集群壓測(90 節點、100,000 分區、6 GiB/s 流量)中,發現 Active Controller CPU 占用達 80%,檢查火焰圖發現 ZGC 占用了一半以上的 CPU 時間。

分析

檢查 GC 日志,發現 GC 耗時偏高(約 5s,主要爲標記階段耗時),且存活對象較多(約 1800 MB)。

檢查 Heap Dump,發現爲元數據相關的對象較多,導致 ZGC 遍曆標記較慢,且占用大量 CPU。

解決方法

優化元數據管理模式,將部分元數據卸載到 S3 層(而非內存),以降低元數據的內存消耗。

JDK 21 中支持了 Generational ZGC,將對象分爲老年代和新生代,可以較好地處理前述存活對象過多導致的 GC 壓力高的問題。

5.2 調優效果

AutoMQ 經過大量的壓測與調優,得益于 ZGC 並發 GC 的優勢,實現了極低的延遲。下表對比了 AutoMQ 在 ZGC 和 G1GC 下的表現:

*:測試環境爲 2 vCPU,16 GiB RAM。測試負載爲 4,800 分區,80 / 80 MBps 生産/消費流量,1,600 Produce/s,1,600 Fetch/s

**:ZGC 的配置參數爲 -XX:+UseZGC -XX:ZCollectionInterval=5

***:G1GC 的配置參數爲 -XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=35 -XX:G1HeapRegionSize=16M -XX:MinMetaspaceFreeRatio=50 -XX:MaxMetaspaceFreeRatio=80 -XX:+ExplicitGCInvokesConcurrent

可以看到,AutoMQ 在使用 ZGC 時,由于 STW 時間極短,發送延遲大幅降低;以少量的 CPU 消耗爲代價,整體性能大幅提升。

06

總結

在本文中,我們詳細介紹了 ZGC 的工作原理和調優方法,以及 AutoMQ 基于 ZGC 調優的實踐經驗。通過調整和優化 ZGC 的配置,我們成功降低了 AutoMQ 的延遲,提高了系統的性能和響應能力。我們希望這些經驗可以幫助更多的 Java 開發者更好地理解和使用 ZGC,從而提升他們的應用程序的性能和穩定性。

參考閱讀

騰訊新聞推薦架構升級:2 年、 300w行代碼的涅槃之旅

幹貨 | 攜程數據基礎平台2.0建設,多機房架構下的演進

當「軟件研發」遇上 AI 大模型

美團對 Java 新一代垃圾回收器 ZGC 的探索與實踐

本文由高可用架構轉載。技術原創及架構實踐文章,歡迎通過公衆號菜單「聯系我們」進行投稿

0 阅读:0

架構互聯高可用

簡介:感謝大家的關注