JDK 16 中的 ZGC:平均暫停時(shí)間 0.05 毫秒
原文鏈接:https://malloc.se/blog/zgc-jdk16
博客文章:https://glavo.site/translate/2021/03/23/zgc-jdk16/

JDK 16?已經(jīng)發(fā)布。與往常一樣,新版本 JDK 會(huì)帶來(lái)一系列新功能、功能增強(qiáng)以及 bug 修復(fù)。在這個(gè)版本中?Zgc?有?46 個(gè)功能增強(qiáng)以及?25 個(gè) bug 修復(fù)。這里我會(huì)介紹一些更有趣的增強(qiáng)功能。
摘要
通過(guò)并行線程棧掃描,Zgc?現(xiàn)在暫停時(shí)間是微秒級(jí)別,平均暫停時(shí)間約為 50 微秒(0.05 毫秒),最大暫停時(shí)間約為 500 微秒(0.5 毫秒)。暫停時(shí)間不受堆、活動(dòng)集和根集大小的影響。
不再有保留堆區(qū)域,Zgc?在需要時(shí)進(jìn)行就地移動(dòng)。這節(jié)約了內(nèi)存,同時(shí)也能保證堆在所有情況下都能成功壓縮。
轉(zhuǎn)發(fā)表現(xiàn)在更有效地進(jìn)行分配和初始化,這縮短了完成 gc 周期所需的時(shí)間,特別是在收集稀疏的大型堆時(shí)。
亞毫秒級(jí)最大暫停時(shí)間
(又稱并發(fā)線程棧處理)
當(dāng)我們開始開發(fā) Zgc?項(xiàng)目時(shí),我們的目標(biāo)是讓 gc?暫停時(shí)間永遠(yuǎn)不超過(guò) 10 毫秒。在當(dāng)時(shí),10 毫秒似乎是一個(gè)很有野心的目標(biāo)。Hotspot 上提供的其他 gc?算法通常會(huì)產(chǎn)生比這更糟糕的最大暫停時(shí)間,尤其是在使用大堆時(shí)。實(shí)現(xiàn)這一目標(biāo)最重要的是并行處理所有繁重的操作,例如移動(dòng)(relocation)對(duì)象、引用處理以及類卸載。那時(shí)候 Hotspot 缺乏并行處理它們所需的基礎(chǔ)設(shè)施,所以需要花費(fèi)幾年的開發(fā)時(shí)間實(shí)現(xiàn)它們。

在達(dá)到最初的 10 毫秒目標(biāo)后,我們重新確定了一個(gè)更具雄心的目標(biāo)。也就是說(shuō),gc?的暫停時(shí)間應(yīng)該永遠(yuǎn)不超過(guò)?1 毫秒。從 JDK 16 開始,我很高興地向大家報(bào)告,我們達(dá)到了這個(gè)目標(biāo)。Zgc?現(xiàn)在有著?O(1)?的暫停時(shí)間。換句話說(shuō),它以恒定的時(shí)間執(zhí)行,并且不隨著堆、活動(dòng)集合(live-set)和根集合(root-set)的大?。ê推渌麅?nèi)容)增加而增加。當(dāng)然,我們依然任由操作系統(tǒng)分配 gc?線程的 CPU 時(shí)間。但是只要您的系統(tǒng)沒有過(guò)度配置,您就可以看到 gc?的平均暫停時(shí)間約為 0.05 毫秒(50 微秒),最大暫停時(shí)間約為 0.5 毫秒(500 微秒)。
所以,我們是怎么做到的?在 JDK 16 之前,Zgc?的暫停時(shí)間仍然與根集合的一個(gè)子集的大小相關(guān)。更準(zhǔn)確的說(shuō),我們?nèi)匀辉?Stop-The-World 階段掃描線程棧。這意味著如果 Java 應(yīng)用有著大量的線程,那么暫停時(shí)間就會(huì)增加。如果這些線程有著很深的調(diào)用棧,那么暫停時(shí)間會(huì)增加更多。從 JDK 16 開始,對(duì)線程棧的掃描是并行處理的,也就是說(shuō)在掃描棧的時(shí)候應(yīng)用程序可以同時(shí)運(yùn)行。
正如您的想象,在線程運(yùn)行時(shí)在堆棧里進(jìn)行掃描需要一些“魔法”。這是通過(guò)一種被稱為棧水印屏障(Stack Watermark Barrier)的技術(shù)實(shí)現(xiàn)的。簡(jiǎn)而言之,這是一種防止 Java 線程在棧幀中沒有先檢查是否安全就返回的機(jī)制。這是一個(gè)開銷很低的檢查,包括在已經(jīng)存在的方法返回時(shí)的安全點(diǎn)檢測(cè)中。概念上來(lái)說(shuō),您可以將它視為棧幀的讀屏障,在需要的時(shí)候它會(huì)強(qiáng)制 Java 線程在棧幀返回前采取某種類型的操作,使其進(jìn)入安全狀態(tài)。每個(gè) Java 線程都有一個(gè)或者多個(gè)棧水印,它告訴棧沒有特殊操作的情況下的安全行程。要通過(guò)水印,需要采取慢路徑操作,將一個(gè)或者多個(gè)幀置于安全狀態(tài),并更新水印。讓所有線程棧進(jìn)入安全狀態(tài)通常需要一個(gè)或者多個(gè) gc?線程,但因?yàn)檫@是并行進(jìn)行的,在 Java 線程線程想要返回到 gc?尚未到達(dá)的幀時(shí),它需要修復(fù)一些自己的幀。如果您多更多細(xì)節(jié)感興趣,請(qǐng)查看?JEP 376: Zgc: Concurrent Thread-Stack Processing,它描述了這項(xiàng)工作。
隨著 JEP 376 的完成,現(xiàn)在 Stop-The-World 階段 Zgc?掃描的根為 0。對(duì)于很多工作負(fù)載,即使在 Java 16 之前您也能看到非常低的最大暫停時(shí)間。但是如果您在一臺(tái)大型計(jì)算機(jī)上運(yùn)行,并且您的工作負(fù)載有大量線程,您依然會(huì)發(fā)現(xiàn)最大暫停時(shí)間會(huì)遠(yuǎn)遠(yuǎn)超過(guò) 1 毫秒。為了形象地展示這項(xiàng)改進(jìn),下面是一個(gè)比較 JDK 15 和 JDK 16 的樣例,它在一個(gè)有幾千個(gè)線程的大型機(jī)器上運(yùn)行 SPECjbb?2015。

就地移動(dòng)
在 JDK 16 中,Zgc?支持了?就地移動(dòng)(in-place relocation)。這個(gè)功能避免了在堆已滿的情況下需要 gc?回收垃圾時(shí)產(chǎn)生?。通常 Zgc?通過(guò)將可緊湊打包(densely packed)的對(duì)象從較稀疏的堆區(qū)域移動(dòng)到一個(gè)或多個(gè)空的堆區(qū)域來(lái)壓縮堆(由此釋放內(nèi)存)。這種策略很直接且簡(jiǎn)單,并且非常適合并行處理。但是,它有一個(gè)缺點(diǎn)。它需要一些空閑內(nèi)存(每個(gè)大小的類型都至少要一個(gè)空的堆區(qū)域)才能開始移動(dòng) 過(guò)程。如果堆已滿,也就是所有堆區(qū)域都在使用中,那么我們就無(wú)處移動(dòng)對(duì)象。
在 JDK 16 之前,Zgc?通過(guò)保留堆(heap reserve)來(lái)解決這個(gè)問題。保留堆是一組堆區(qū)域,這些區(qū)域被預(yù)留出來(lái),不用于 Java 線程中的常規(guī)堆分配,只允許 gc?在移動(dòng)對(duì)象時(shí)使用保留堆。這確保有空的堆區(qū)域可用,即使在 Java 線程眼中堆已滿,也可以開始移動(dòng)過(guò)程。保留堆空間通常是堆中的一小部分, 在一篇以前的博客中,我寫了在 JDK 14 中如何改進(jìn)了它以更好的支持小堆。

盡管如此,保留堆依然存在一些問題。例如,保留堆對(duì)于執(zhí)行移動(dòng)的 Java 線程不可用,所以無(wú)法強(qiáng)制保證?移動(dòng)過(guò)程可以完成,gc?能由此回收(足夠的)內(nèi)存。這對(duì)于幾乎所有正常的工作負(fù)載來(lái)說(shuō)都不是問題,但我們的測(cè)試表明構(gòu)建一個(gè)能引發(fā)這個(gè)問題的程序是可能的,這又會(huì)提前引發(fā)?。另外提前保留堆的一部分(盡管這部分很小)對(duì)于大多數(shù)工作負(fù)載來(lái)說(shuō)都是浪費(fèi)內(nèi)存。
另一種釋放連續(xù)內(nèi)存塊的方法是就地壓縮堆。其他 Hotspot 收集器(例如 G1、Parallel 和 Serial)在執(zhí)行所謂的?Full gc?時(shí)會(huì)執(zhí)行某種版本的這個(gè)操作。這種方法的優(yōu)點(diǎn)是不需要額外內(nèi)存來(lái)釋放內(nèi)存。換句話說(shuō),它可以愉快地壓縮滿地堆,而不需要某種保留堆。

然而就地壓縮堆仍然有一些挑戰(zhàn),并且通常會(huì)帶來(lái)一些開銷。例如,移動(dòng)對(duì)象對(duì)象的順序很重要,否則可能會(huì)覆蓋尚未移動(dòng)的對(duì)象。這需要 gc?線程之間更多的協(xié)作,不利于并行處理,同時(shí)這還影響 Java 線程在 gc?移動(dòng)對(duì)象時(shí)能做什么和不能做什么。
總而言之,這兩種方法都有自己的優(yōu)點(diǎn)。當(dāng)有可用的空堆區(qū)域時(shí),不就地移動(dòng)通常執(zhí)行得更好,而就地移動(dòng)可以保證移動(dòng)過(guò)程即使在沒有空堆區(qū)域可用時(shí)依然能成功完成。
JDK 16 開始 Zgc?同時(shí)使用這兩種方法來(lái)同時(shí)獲得二者的好處。這使我們不需要保留堆,同時(shí)在通常情況下保持良好的移動(dòng)性能,并保證在邊緣情況下總是能成功完成移動(dòng)。默認(rèn)情況下,只要有空的堆區(qū)域可用于移動(dòng)對(duì)象,Zgc?就不會(huì)就地移動(dòng)對(duì)象。如果沒有空堆區(qū)域,Zgc?就會(huì)切換到就地移動(dòng)。一旦空堆區(qū)域可用,Zgc?將在此切換為不就地移動(dòng)的狀態(tài)。


這些移動(dòng)模式之間切換是無(wú)縫的,如果需要,可以在同一個(gè) gc??周期中多次進(jìn)行切換。當(dāng)然,大多數(shù)工作負(fù)載中永遠(yuǎn)不會(huì)遇到需要切換的情況,但是知道 Zgc?能夠很好的處理這些情況并且不會(huì)因?yàn)闊o(wú)法壓縮堆而過(guò)早拋出??應(yīng)該會(huì)讓人更放心。
Zgc?日志也增加了對(duì)每個(gè)大小組(/
/
)中有多少個(gè)堆區(qū)域(ZPages
)被原地移動(dòng)的顯示。下面是一個(gè)示例,其中有 54MB 的小對(duì)象需要移動(dòng),3 個(gè)小頁(yè)面需要就地移動(dòng)。
...
gc(15) Small Pages: 120 / 240M, Empty: 0M, Relocated: 54M, In-Place: 3
gc(15) Medium Pages: 2 / 64M, Empty: 0M, Relocated: 0M, In-Place: 0
gc(15) Large Pages: 1 / 4M, Empty: 0M, Relocated: 0M, In-Place: 0
...
轉(zhuǎn)發(fā)表的分配和初始化
當(dāng) Zgc?移動(dòng)對(duì)象時(shí),該對(duì)象的新地址會(huì)被記錄在轉(zhuǎn)發(fā)表中,該表是在 Java 堆以外分配的數(shù)據(jù)結(jié)構(gòu)。每個(gè)被選為移動(dòng)集(需要壓縮以釋放內(nèi)存的堆區(qū)域集)一部分的堆區(qū)域都會(huì)得到一個(gè)與其關(guān)聯(lián)的轉(zhuǎn)發(fā)表。
在 JDK 16 之前,當(dāng)移動(dòng)集非常大的時(shí)候,轉(zhuǎn)發(fā)表的分配和初始化可能會(huì)占用 gc?周期中的很大一部分時(shí)間。移動(dòng)集的大小與移動(dòng)過(guò)程期間移動(dòng)的對(duì)象數(shù)量相關(guān)。例如,如果您有一個(gè)大于 100GB 的堆,并且工作負(fù)載中會(huì)產(chǎn)生大量的碎片,在堆中均勻的分布著小的孔隙,那么移動(dòng)集會(huì)很大,分配/初始化它可能需要一段時(shí)間。當(dāng)然這個(gè)工作始終在并發(fā)階段進(jìn)行,因此它不會(huì)影響 gc?暫停時(shí)間。不過(guò)這里也還有改進(jìn)的余地。
在 JDK 16 中,Zgc?現(xiàn)在會(huì)批量分配轉(zhuǎn)發(fā)表?,F(xiàn)在我們不再會(huì)多次(可能有幾千次)調(diào)用?/
?給每個(gè)表分配內(nèi)存,而是一次性分配所有表的所需內(nèi)存。著通常有助于避免分配開銷和潛在的鎖競(jìng)爭(zhēng),并顯著地減少分配這些表所需的時(shí)間。
這些表的初始化是另一個(gè)瓶頸。轉(zhuǎn)發(fā)表是一個(gè)哈希表,因?yàn)槌跏蓟枰O(shè)置一個(gè)小的表頭,并要將一個(gè)(可能很大的)轉(zhuǎn)發(fā)表 entry 數(shù)組清零。從 JDK 16 開始,Zgc?使用多個(gè)線程(而不是單個(gè)線程)并行地進(jìn)行初始化。
總而言之,這些變更顯著減少了分配和初始化轉(zhuǎn)發(fā)表所需的時(shí)間,特別是在收集一個(gè)很大的、內(nèi)容稀疏的堆時(shí),所需時(shí)間可能會(huì)降低一個(gè)甚至兩個(gè)數(shù)量級(jí)。
