Java 垃圾回收機制:JDK 8 到 JDK 18 十個版本的演進

數千個改進優化了吞吐量、延遲與記憶體使用量

Du Spirit
Java Magazine 翻譯系列
20 min readAug 21, 2023

--

Translated from “Java garbage collection: The 10-release evolution from JDK 8 to JDK 18” By Thomas Schatzl, Java Magazine June 17, 2022. Copyright Oracle Corporation.

自仍受歡迎的 JDK 8 於 2014 三月釋出,JDK 18 的正式發布標誌著第十個版本推出,這紀念正是一個好機會,暫停一下,看看 HotSpot JVM 一路以來發生了什麼變化。

本文章基於我的演講:《JDK 8 到 JDK 18 的垃圾回收機制:10 個版本,2000 多項改進

介紹垃圾回收機制、指標與取捨

HotSpot JVM 中管理您應用程式 heap 的元件稱之為垃圾回收器 (GC, garbage collector),GC 掌管應用程式 heap 中物件的生命週期,從應用程式分配記憶體開始,直到回收記憶體再利用。

譯註:heap 是個很難翻譯的字,雖然堆積算是常見的翻譯,但說真的沒什麼加分,翻譯成記憶體空間又不夠精確,在 Java 世界中,記憶體空間被分成好幾個部分,heap 只是其中一部分...

從非常高階的層次看,在 JVM 中,垃圾回收機制演算法的基本功能如下:

  • 當應用程式請求配置記憶體時,GC 提供記憶體,提供記憶體要盡可能地快。
  • GC 偵測應用程式不再使用的記憶體,同樣,這機制須非常有效率不佔用太多時間。無法造訪的記憶體一般稱之為垃圾。
  • GC 再次提供那些記憶體給應用程式,最好及時地,即快速地提供。

一個好的垃圾回收機制演算法有其他許多要求,但這三個是最基本,足夠在這討論。

有許多方式能滿足這些需求,但可惜,沒有銀子彈且適用所有情況的演算法,因此,JDK 提供幾種垃圾回收機制的演算法以供選擇,每種演算法針對不同情境最佳化。它們的實作大致決定在吞吐量、延遲與記憶體使用量三個主要性能指標方面的表現,以及對 Java 應用程式的影響。

  • 吞吐量 (Throughput) 代表在指定時間單位內完成的工作量,在這次的討論中,每單位時間內能完成更多的回收工作,是較好的垃圾回收機制演算法,能讓 Java 應用程式有更高的吞吐量。
  • 延遲 (Latency) 指應用程式的單個操作所需的時間,專注在延遲上的垃圾回收機制演算法試圖最小化延遲造成的影響,在 GC 的情境中,主要考量是其操作是否會引發暫停、暫停的程度以及暫停的持續時間。

譯註:GC 引起的暫停有分成:局部和全域,局部會影響的是相關的執行緒,但全域則是整個應用程式暫停,假設是一個 web server,在全域暫停的情況下,是無法處理任何新的 request。

  • 記憶體使用量 (Memory footprint) 在 GC 的情境中,是指除了應用程式的 Java heap 記憶體用量外,還需要額外多少記憶體讓 GC 得以正常運作,純粹用來管理 Java heap 的資料會減少 Java 應用程式可用的記憶體,GC (或更一般地說 JVM) 的記憶體使用量越少,能提供給 Java 應用程式 heap 使用的記憶體就越多。

這三個指標存在關聯,高吞吐量的回收器可能大大影響延遲 (但最小化對應用程式的影響),反之亦然。低記憶體使用量可能要使用在其他指標上較少優化的演算法。低延遲回收器可能需要並行或是小步驟地處理更多的工作,占用更多的處理器資源。

譯註:有人可能會覺得反直覺,低延遲不意味著也能提高吞吐量?一般來說,吞吐量大多是用在單位時間內一次批次地處理回收,但若看單一運算,延遲反而可能拉高。

之間的關係常用三角形來呈現,如 Figure 1 所示,每個指標位於一個角落,每個垃圾回收機制演算法,根據它們的目標與擅長的方面,都滿足三角形的一部分。

Figure 1. The GC performance metrics triangle

試圖優化 GC 其中一個或多指標,可能不利於其他指標。

JDK 18 的 OpenJDK 垃圾回收器

OpenJDK 提供多樣的五種垃圾回收器,專注於不同的性能指標,Table 1 列出它們的名字、專注點及一些完成期望特性的核心概念。

譯註:由於 Medium 沒有表格的功能,只好用圖片代替,原文中表格裡的連結,會放在之後每個垃圾回收器介紹的段落。

Table 1. OpenJDK’s five GCs

JDK 8 後續的版本,Parallel GC 是預設的回收器,專注在吞吐量,試著讓工作盡可能快地完成,最小化對延遲 (暫停) 的影響。

Parallel GC 將使用中的記憶體撤出 (複製) 到其他位置,以更緊湊的形式來釋放記憶體,在 STW 暫停期間,留下大片的閒置區域。當分配記憶體的請求無法滿足時,STW 暫停發生,JVM 會完全暫停應用程式的運行,讓垃圾回收機制演算法使用盡可能多的處理器執行緒執行記憶體壓縮工作,分配請求所需要的記憶體,然後恢復應用程式的執行。

Parallel GC 同時也是一種世代回收器,最大化垃圾回收機制的效率。關於世代回收器稍後討論。

自 JDK 9 起G1 GC 成為預設的回收器,G1 試圖平衡吞吐量與延遲的考量。一方面,仍在 STW 暫停期間,使用分代的進行記憶體回收作業以最大化效率,如同 Parallel GC,但與此同時,它試著在暫停期間進行長時間的操作。

G1 與應用程式平行進行長時間的工作,當應用程式運行多個執行緒,會大幅減少暫停的時間,犧牲少量吞吐量作為代價。

ZGCShenandoah 專注於降低延遲,代價是吞吐量,它們試圖在沒有明顯暫停的強況下進行所有垃圾回收的工作。它們都不是世代回收器,分別在 JDK 15 與 JDK 12 以非實驗版本的形式加入。

Serial GC 專注在記憶體使用量與啟動時間,這個垃圾回收器像是 Parallel GC 的簡化且較慢的版本,在 STW 暫停期間,只使用單一執行緒執行所有的工作,heap 依舊是以世代組織。然而,Serial GC 在記憶體使用量與啟動時間非常優異,因為降低了複雜度,讓它特別適合用在小型且短時間運行的應用程式。

譯註:簡單來說 Serial GC 是為雲端世代設計的垃圾回收器,特別是 serverless 的應用,更小的記憶體使用量能讓平台運行更多的 serverless 應用,較快的啟動時間可以縮短從呼叫到執行的時間。

OpenJDK 提供另一種 GC:Epsilon,我在 Table 1 省略了,為什麼?因為 Epsilon 只允許配置記憶體,然後不做任何回收,它不滿足所有 GC 的要求,然,Epsilon 在某些非常狹隘且特殊用途的應用程式中相當有用。

簡短介紹 G1 垃圾回收器

G1 GC 在 JDK 6 update 14 中作為實驗性功能加入,並在 JDK 7 update 4 開始正式支援,因其多功能性,G1 自 JDK 9 後作為 HotSpot JVM 預設的回收器。它穩定、成熟、非常積極地維護,並時時在改進,我希望本文剩餘的部分能證明這點。

G1 如何在吞吐量與延遲之間達成平衡呢?

一個關鍵技術是世代垃圾回收。它利用一個發現:多數近期分配的物件幾乎馬上被回收 (迅速死亡),因此 G1 與其它世代型的垃圾回收器,將 Java heap 分成年輕世代 (young generation),其中的物件都是剛分配的;與年老世代 (old generation),其中的物件都是在年輕世代存活超過數個垃圾回收週期,如此可以以較少的工作量回收。

年輕世代通常較年老世代小的多,加上像 G1 這樣的追蹤垃圾回收器僅處例年輕世代能觸及 (活著) 的物件,因此,收集的工作量意味著年輕世代的垃圾收集所花的時間通常是短的,同時回收大量的記憶體。

同時,長期存活的物件會被搬到年老世代。

因此,不時,當填滿了,需要從年老世代收集垃圾並回收記憶體,因為年老世代通常較大,且常包含大量活的物件,這要花上不少時間 (舉例,Parallel GC 完整收集花數倍時間於年輕世代的收集)。

出於這原因,G1 將年老世代的垃圾回收分成兩階段:

  • 首先,G1 在 Java 應用程式運作的同時追蹤活的物件,這將在垃圾回收暫停中從年老世代回收記憶體的大部分工作移出,從而降低延遲,實際的記憶體回收,如果一次做完,對大型的應用程式 heap 來說仍然十分耗時。
  • 因此,G1 漸進地回收年老世代的記憶體。在追蹤活物件後,在接下來幾次常規的年輕世代回收後,G1 還對年老世代的一部分進行壓縮,雖著時間推移,回收記憶體。

由於追蹤活物件的關係圖不夠精確,以及支援管理漸進式垃圾回收的資料結構所帶來的時間與空間的開銷,和一次回收全部 (像 Parallel GC) 相比,漸進式回收年老世代記憶體效率較差。但這顯著減少暫停所佔的最大部份時間。作為粗略的準則,漸進式垃圾回收的暫停大約與僅回收年輕世代記憶體的時間相當。

此外,您可透過命令列選項 MaxGCPauseMillis 來設置這兩種垃圾回收機制的目標暫停時間,G1 會試圖將所花費的時間保持在該值以下,預設為 200 毫秒,這或許適用於您的應用程式,也可能不適用。但這僅是一個最大值的準則,G1 會盡可能讓暫停時間低於該值。因此,想改善暫停時間,首先可嘗試降低 MaxGCPauseMillis 的值。

從 JDK 8 到 JDK 18 的進展

我已經介紹 OpenJDK 的垃圾回收器,我將詳細介紹過去 10 個 JDK 版本在三個指標:吞吐量、延遲與記憶體使用量所進行的改進。

G1 的吞吐量增益。為了展現吞吐量與延遲的改進,本文使用 SPECjbb2015 基準測試,SPECjbb2015 是業界常見的基準測試,透過模擬超級市場內的各種操作,混合來衡量 Java 伺服器的效能,這基準測試提供兩個指標:

  • maxjOPS 對應系統能提供的最大交易數量,這是吞吐量的指標。criticaljOPS 量測在數個服務水平協議 (SLA),例如從 10 毫秒到 100 毫秒的反應時間條件下的吞吐量。

這文章使用 maxjOPS 作為比較 JDK 版本間吞吐量的基準,以時間暫停時間的改善比較延遲。儘管 criticaljOPS 對應暫停時間引起的延遲,仍有其他因素影響這分數,直接比較暫停時間避免這問題。

Figure 2 顯示在 16 GB 的 Java heap 中,G1 在混和模式下 maxjOPS 的結果,以相對於 JDK 8,JDK 11 和 JDK 18 的數值繪製。如您所見,透過使用更新的 JDK 版本,吞吐量的分數顯著增加。相較 JDK 8,JDK 11 改進約 5%,JDK 18 改進約 18%,簡單說,更新的 JDK 讓應用程式能有更多的資源應用在實際工作上。

Figure 2. G1 throughput gains measured with SPECjbb2015 maxjOPS

以下討論試圖將吞吐量的改進歸因於特定的垃圾回收機制變化,然而,垃圾回收機制的效能,特別是吞吐量,非常因其他通用的改善像程式碼編譯等因素而受益,因此,垃圾回收機制的變化並不負責所有的提升。

JDK 9 早期一個顯著的改善是 G1 開始延遲年老世代回收,盡可能地晚。

在 JDK 8,使用者需要手動設置 G1 何時開始同時 (concurrent) 追蹤年老世代的活物件,如果設的太早,在開始回收工作前,JVM 還沒有完全使用分配給年老世代的 heap。一個缺點是這無法給年老世代的物件足夠的時間變成可回收的,因此,因為更多物件仍然存活,G1 不只使用更多的處理器資源來分析活躍性,G1 也需要更多工作來釋放年老世代的記憶體。

另一個問題是,如果太晚啟動年老世代的回收,JVM 可能會耗盡記憶體,導致觸發非常慢的完整回收。從 JDK 9 開始,G1 自動判斷啟動年老世代追蹤的最佳時機,甚至適應目前應用程式的行為。

另一個在 JDK 9 實現的想法是試圖在年老世代回收大物件,G1 以年老世代的其他物件相較更高的頻率,自動將大物件放在年老世代,類似其他使用世代的回收器,這是垃圾回收器專注於「容易提取」的另一種方式,這方式可能具有相當大的增益,畢竟,大物件之所以稱為大物件,是因為他們佔據大量的空間。在一些 (固然罕見) 應用中,這甚至導致垃圾回收的次數和總暫停時間大幅減少,以至於 G1 在吞吐量上贏過 Parallel GC。

通常,每個版本都包含優化,當執行相同的工作,讓垃圾回收的暫停時間更短,這也自然提高了吞吐量,本文可列出許多優化,下節將指出其中一些。

類似於 Parallel GC,G1 在 JDK 14 對分配到 Java heap 實現了專屬的非均勻記憶體存取 (NUMA) 感知 。從此,在有多記憶體插槽的電腦上,記憶體存取時間是非均勻的,也就是說,記憶體是專屬於電腦的插槽,因此對某些記憶體的存取可能較慢,G1 試圖利用其局部性。

當採用 NUMA 感知,G1 假設在分配 (由一個執行緒或一個執行緒組) 在一個記憶體節點的物件,會盡可能被同個節點的其他物件所參照,因此,當一個物件待在年輕世代,G1 會將物件保留在同一節點中,並且將年老世代中長期存活的物件平均分散多個節點以最小化存取時間的變異,這與 Parallel GC 的實作類似。

我想在這指出另一個適用不常見情境的改善,最值得注意的是完整回收,一般來說,G1 試圖透過能發揮最大效益的方式調整內部參數避免完整回收,然而,在一些極端狀況下,是不可避免的。G1 需要在暫停期間進行完整的回收,直到 JDK 10 之前,演算法是單執行緒的,因此非常慢,目前的實作與 Parallel GC 完整回收的機制相當,雖然已經有變好,它仍然很慢,是您想要避免的事情。

Parallel GC 的吞吐量增益. 談到 Parallel GC,Figure 3 顯示從 JDK 8 到 JDK 18 在相同配置下,maxjOPS 分數上的改進,同樣,僅透過更換 JVM,即使使用 Parallel GC,您可在吞吐量得到 2% 到 10% 左右的改進,這改善幅度較 G1 小,這是因為 Parallel GC 從較高的絕對值開始,可以獲得的提升較少。

Figure 3. Throughput gains for the Parallel GC measured with SPECjbb2015 maxjOPS

G1 在延遲的改善。為了展示 HotSpot JVM 垃圾回收的延遲改善,本節使用 SPECjbb2015 基準測試,在固定負載下量測暫停時間。Java heap 設為 16 GB。Table 2 總結在相同時間間隔,預設目標暫停時間是 200 毫秒下,不同 JDK 版本的平均暫停時間和 99 百分位的暫停時間,及相對的總暫停時間。

Table 2. Latency improvements with the default pause time of 200 ms

JDK 8 暫停平均花 124 毫秒,P99 為 176 毫秒。JDK 11 將平均時間優化到 111 毫秒,及 P99 為 134 毫秒,總暫停時間減少 15.8%。JDK 18 再次顯著改善,暫停平均為 89 毫秒,P99 為 104 毫秒,總暫停時間減少 34.4%。

我擴展實驗,新增一個讓 JDK 18 以目標時間 50 毫秒的方式運行,因為我任意決定的 -XX:MaxGCPauseMillis 預設值 200 毫秒太長了。平均而言,G1 可以達到暫停的目標時間,P99 的垃圾回收暫停時間為 56 毫秒 (見 Table 3),整體來說,和 JDK 8 相比,總時間沒有增加太多 (0.06%)。

換言之,將 JDK 8 JVM 換成 JDK 18 JVM,您可在相同的目標時間,取得顯著的平均暫停時間降低,並提高吞吐量,或是讓 G1 保持在更小的暫停時間目標 (50 ms),在相同的暫停總時間下,獲得大致相同的吞吐量。

Table 3. Latency improvements by setting the pause time goal to 50 ms

Table 3 的結果獲益於自 JDK 8 以來的許多改善,以下是依些顯著的改進。

降低延遲的一個相當大的貢獻來自於收集年老世代的所需要的 metadata,所謂的記憶集 (remembered sets) 已顯著縮減,透過改善資料結構本身以及不再儲存與更新不曾使用的資訊。以現今的計算機架構,縮減需管理的 metadata 意味著更少的記憶體傳輸,有助於提升性能。

譯註:這邊 metadata 我就刻意不翻譯了,因為我目前沒有看到喜歡的翻法。

關於記憶集的另一方面是,尋找指向當前 heap 中已回收區塊的參考的演算法已經以平行化的方式優化。G1 不再以並行的方式查找資料結構並於內部迴圈過濾掉重複的項目,現在分別過濾記憶集中的重複項目,然後並行處理剩餘的部分,這讓兩個步驟更有效率並更簡單地並行處理。

譯註:這沒看過演算法本身,光靠上面的幾句話應該是看不出來到底優化了什麼東西,所以...翻譯達不達意,我也說不上來。

此外,對於記憶集的處理已經被仔細研究,修剪不需要的程式碼並優化共用的路徑。

JDK 8 之後的 JDK 版本中,另一個重點是改善內部任務的實際並行處理:嘗試讓更多階段能並行處理或是重組較小的階段成為大的並行階段,減少同步點,大量的資源投入在並行階段的工作平衡上,如果有個執行緒無法處理,它可以尋找其他執行緒時更加聰明。

順帶一提,後續的 JDK 開始關注不常見的情形,其中之一是撤離失敗,如果在垃圾回收的過程中沒有足夠的空間可以複製物件,會發生撤離失敗。

ZGC 的垃圾回收暫停。如果您的應用程式需要更短的垃圾回收暫停時間,Table 4 在與先前相同負載條件下,對一個專注於延遲時間的回收器,ZGC,進行比較,它展示之前 G1 的暫停時間以及額外最右側一欄展示 ZGC。

Table 4. ZGC latency compared to G1 latency

ZGC 實驗了次毫秒級的暫停時間目標,將所有回收工作與應用程式並行處理,只有一些提供垃圾回收階段結束的少量工作需要暫停,如預期般,這些暫停十分短暫,遠低於 ZGC 宣稱能提供的毫秒範圍。

G1 的記憶體使用量改進。本文檢視的最後一項指標是 G1 垃圾回收演算法的記憶體使用量,這裡,演算法的記憶體使用量是定義為在需要在 Java heap 外額外使用的記憶體。

在 G1,除了依賴 Java heap 大小的靜態資料占用大約 3.2% 外,另外主要的記憶體消耗是記憶集,讓世代垃圾回收,特別是漸進式年老世代的垃圾回收成為可能。

增加 G1 記憶集壓力的一類應用是物件快取:當他們新增或移除剛快取的項目,會頻繁地在年老世代的不同區間產生參考。

Figure 4 展示 G1 原生記憶體使用量從 JDK 8 到 JDK 18 的變化,該測試應用程式時做一個物件快取:以最近最少使用的方式,物件表達在 heap 中快取被查詢、新增或移除的資訊。這個例子使用 20 GB 的 Java heap,並使用 JVM 的原生記憶體追蹤 (NMT) 工具確定記憶體的使用量。

Figure 4. The G1 GC’s native memory footprint

用 JDK 8,在短暫的暖啟動後,G1 原生記憶體使用量維持在 5.8 GB 左右,JDK 11 對此進行改善,將記憶體使用量降至 4 GB 上下,JDK 17 改進至約 1.8 GB,JDK 18 為穩定維持在 1.25 GB 的記憶體使用量。這額外的記憶體用量縮減,從 JDK 8 佔約 Java heap 的 30% 降到 JDK 18 約 6%。

譯註:JDK 8 是 5.8 GB / 20 GB = 29%,JDK 18 是 1.25 GB / 20 GB = 6.25%

如前節所述,這些改變沒有對吞吐量或延遲帶來特定的代價,實際上,縮減 G1 垃圾回收機制維護的 metadata 在其他指標也有所改善。

從 JDK 8 到 JDK 18,這些改變的主要原則是在維護垃圾回收用的 metadata 時,是非常嚴格地只維護真正需要的資料。因此,G1 並行地重新建立與管理記憶體,盡快釋放資料。JDK 18 改善 metadata 的呈現方式,以更密集的方式儲存,以改善記憶體的使用量。

Figure 4 透過觀察峰值與低谷的差異,亦呈現在後續的 JDK 版本中,G1 逐步更積極地將記憶體歸還作業系統,在最新的版本中,G1 甚至並行地處理。

譯註:JDK 17 的線圖蠻明顯的,升上去後很快又下降,且下降幅度都比 JDK 8 或 JDK 11 多很多,至於怎麼從上圖看出並行處理...雙駝現象?

垃圾回收機制的未來

雖然很難預測未來發生什麼事,以及許多專案對垃圾回收的改進,特別是 G1 的改進,但下列發展可能出現在未來的 HotSpot JVM 中。

一個已經在積極處理的問題,當 Java 物件在原生程式碼使用時,不再需要鎖定垃圾回收,當 Java 執行緒觸發垃圾回收時:需要等待沒有其他區域參考到原生程式碼使用的 Java 物件才會進行,最差的情況,原生程式碼會阻擋垃圾回收數分鐘,這會讓軟體開發者選擇不使用原生程式碼,造成吞吐量不利的影響,透過 JEP 423 (Region pinning for G1),對 G1 來說不再是問題。

譯註:這裡的原生程式碼是指使用 JNI 或是 JNA,調用使用 C/C++ 寫的程式碼,一般會是用在需要硬體加速的部分,像是影像邊解碼或是 3D 繪製等。

和吞吐量見長的 Parallel GC 相比,另一個 G1 已知缺點是對吞吐量的影響。極端情況下,使用者回報可能有 10% 到 20% 的差距,造成此問題的原因已經知曉,已有若干在不影響其他 G1 品質的情況下,改善這缺點的建議。

近期,已經發現垃圾回收機制的暫停其暫停時間,尤其是工作分配效率仍能夠繼續優化。

目前一個關注的重點是移除 G1 最大輔助資料結構的其中一半,標記位元圖 (mark bitmap),G1 的演算法使用兩個標記位元圖,幫助判斷物件仍在使用且可以安全地並行檢查其引用。一個公開的改善請求表明,其中一個標記位元圖的目的可以用另一個取代,這將立即減少 G1 metadata 占用量,約 1.5% 的 Java heap 大小。

有大量正在進行的活動,將 ZGC 及 Shenandoah GC 成為世代回收器,在許多應用程式中,目前這些回收器的單一世代設計,在吞吐量與回收的時間上有太多缺點,需要更大的 heap 來補償。

結論

本文展現了 HotSpot JVM 垃圾回收演算法從 JDK 8 到 JDK 18 顯著的改善,三個效能指標:吞吐量、延遲和記憶體使用量,都有非凡的改進。每個新 JDK 版本,即使沒有在 JEP 明確指出這些改善,仍提供實質的改進,在可預見的未來,仍會持續這情況,因此持續更新版本,享受這免費的改進吧!

感謝 OpenJDK 長期實現這些偉大改進的眾多貢獻者。

深入探討

譯者的告白

這一篇翻譯放在 backlog 非常久了,畢竟 JDK 21 都快要推出了,但這一篇非常長,也特別難翻,譯註也相當多。這讓我想起 2006 年,在圓山飯店參加國際研討會,當時的 keynote speech 主題就是 Java 的垃圾回收演算法,讓全場不少學生昏昏欲睡,即便撐住沒睡著的我,艱澀的演算法實在吸收不了多少,但還是讓我在翻譯這篇時,至少知道世代回收機制是怎麼運作的 XD

--

--