隨著雲端運算的普及,降低 JVM 的整體開銷變得日益重要。Java 24 中的 JEP 475 為 G1 垃圾回收器帶來重大的技術改進,使得 G1 垃圾回收器的實作能夠更加簡化,並且可以更有效地追蹤應用程式的記憶體存取行為。
這項提案的靈感來自於 ZGC 的成功經驗。它自 JDK 14 就開始延後了屏障擴展步驟(late barrier expansion),並在 JDK 15 中達到正式可用的穩定性。因此,把此一技術導入 G1 可以為開發人員帶來更好的執行效能,與更低的系統資源消耗。
前言
雖然 JIT 編譯能夠有效地提升 Java 程式的執行效率,但同時也帶來明顯的處理時間與記憶體用量上升。初步實驗顯示,在名為 C2 的 JIT 編譯器上,如果在編譯早期就擴展 G1 屏障(barrier),會增加約 10-20% 的額外開銷(因程式而異)。因為在 C2 的中介表達式(IR,Intermediate Representation)中,每個 G1 屏障會被擴展為 100 個以上的操作,並最終轉換成約 50 條 x64 指令。因此,降低這類開銷對於提升 Java 平台在雲端環境中的效能就顯得非常重要。
另外,垃圾回收器也是 JVM 開銷的重要來源之一。G1 會與 JIT 編譯器互動,並在程式的記憶體存取操作中加入屏障程式碼。以 C2 為例,我們需要深入瞭解 C2 的內部運作,才有辦法維護並改進效能,但是熟悉這些細節的 GC 開發人員極為有限。
此外,某些屏障最佳化涉及了無法直接用 C2 中介表達式呈現的低階轉換和技術,因此限制了 G1 的發展與優化。如果我們能將 G1 屏障的置入過程與 C2 內部細節解耦,那麼 GC 開發人員將能夠改善演算法與微調底層來進一步最佳化 G1,並降低其額外開銷。
C2 處理記憶體屏障時的挑戰與優化策略
C2 會將 Java 方法編譯成機器碼,並使用稱之為「sea of nodes(節點海)」的中介表達式。它是一種程式相依圖(program dependence graph),能讓編譯器在排程機器指令順序時擁有極大的彈性。
雖然這種設計能簡化許多最佳化工作,並擴大了最佳化的範圍,但也導致它很難維持指令間相對順序的不變性(invariant)。在 G1 的記憶體屏障實作中,這些彈性已導致多起複雜的錯誤,並且也無法保證未來不會再出現類似性質的問題。
根據早期實驗,以及人工檢驗 C2 所產出的程式碼,顯示了 C2 為記憶體屏障所產生的機器碼,與 Java 位元碼直譯器目前使用的手寫組合語言程式碼十分相似。這表示 C2 為記靜體屏障所產生的程式碼,其最佳化的空間相當有限。
如果我們將記憶體屏障的具體實作細節隱藏於 C2 內部,直到編譯流程的最後階段才擴展,那麼它仍然能產生品質相近的程式碼,同時能減少錯誤風險並提升編譯器實作的簡潔性。
補充說明
- 節點海(sea of nodes):一種圖形化的 IR 形式,它將運算節點彼此連接,讓編譯器自由地重新排序以達成最佳化
- 記憶體屏障(barrier):G1 GC 所需的程式碼,用來追蹤或保護記憶體存取,通常會要求特定的指令順序
- 不變性(invariant):在最佳化過程中希望維持的某些條件或性質,例如某兩條指令的先後順序
JEP 475 概觀
JEP 475 將 G1 垃圾回收器屏障的擴展步驟,從 C2 JIT 編譯管線中的早期延遲到後期,以簡化 G1 垃圾回收器屏障的實作。這些屏障用於記錄應用程式記憶體存取的資訊。
目標
- 在使用 G1 回收器時,減少 C2 的執行時間
- 讓不了解 C2 的 HotSpot 開發人員能夠理解 G1 屏障
- 保證 C2 保留關於記憶體存取、安全點和屏障相對順序的不變性
- 保持 C2 產生程式碼的品質,包括執行速度和產出大小
優點
- 顯著降低 C2 的執行時間與資源消耗
- 簡化 G1 屏障的實作以降低維護難度,並保持或提升程式碼的執行效能
- 提升程式碼的可靠性,減少因指令排序錯誤導致的問題
- 可重用 ZGC 的現有機制,減少開發成本
缺點
- 需要謹慎評估在生產環境中的使用
- 可能需要調整現有的最佳化策略
- 對超過 8TB 堆積空間的支援有限(除非使用 ZGC)
介紹
C2 編譯與 G1 屏障擴展的現狀
當 C2 編譯 Java 方法時,它會將屏障操作與該方法的原始操作混合在中介表達式中。當 C2 剖析位元組碼成中介表達式時,它會在編譯管線的早期階段中為每個記憶體存取操作擴展(expand)相對應的屏障操作。
這個擴展過程依賴 Heap Access API(JEP 304)。一旦屏障完成擴展,C2 就會統一在流程中轉換並最佳化所有操作。下圖中概括了整個流程,其中 IR<C,P> 代表特定於垃圾回收器(C)與目標作業系統/處理器架構平台(P)的 IR 中介表達式:

目前 C2 中早期 g1 展障擴展的流程如下:
- 輸入位元組碼 bytecode
- 位元組碼解析,加入
g1屏障中介表達式 - 輸出
IR<g1> - 進行平台無關的最佳化
- 輸出
IR<g1> - 進入「平台
p的架構描述」- 指令選擇
- 輸出
IR<g1, p> - 指令排程
- 輸出
IR<g1, p> - 暫存器分配
- 輸出
IR<g1, p> - 機器碼產生
- 輸出機器碼
<g1, p>
早期擴展的優勢與限制
在編譯管線中的早期就擴展 GC 屏障的話,會擁有下列兩個潛在優勢:
- 以中介表達式內容呈現的相同 GC 屏障實作,可以在所有目標平台上重複使用
- 由於屏障已經被擴展,所以 C2 可以在整個方法的範圍內最佳化和轉換這些操作,從而潛在地提升程式碼品質
然而,在實務上這種方法的效益有限,主要有以下兩個原因:
- 平台專屬的 G1 屏障實作:除了 JIT 編譯模式,其他執行模式(如位元組碼直譯)仍然需要針對特定平台提供 G1 屏障的實作,因此 IR 層級的統一性優勢較為有限
- G1 屏障不易進行最佳化:由於 G1 屏障涉及了控制流密度(control-flow density)、記憶體操作順序的約束,以及其他技術因素,它們的最佳化空間相對有限
早期擴展模型的三大問題
因此,雖然早期擴展的模型看起來有潛在優勢,但也帶來了三個實際且顯著的缺點:
- 增加 C2 編譯的額外開銷:由於屏障操作會在編譯早期時被擴展,導致 C2 在整個編譯流程中都必須處理大量額外的 IR 操作,從而增加了編譯時間與資源消耗
- 對 GC 開發人員未保持透明度與靈活性:由於屏障的擴展與 C2 的內部運作緊密耦合,使得 GC 開發人員難以獨立改進或調整 G1 屏障的行為
- 難以保證屏障順序的正確性: C2 的節點海 IR 架構允許自由調整指令順序,使得在某些情境下要確保 G1 屏障的執行順序變得相當困難,並且容易導致錯誤
基於上述原因,尋找新的方法來延後 G1 屏障的擴展時機,或將其與 C2 內部邏輯解耦,可能會帶來更好的效能與可維護性。
延後擴展 G1 屏障(Late Barrier Expansion)
為了降低 C2 編譯的開銷並提高 G1 屏障的靈活性,JEP 475 建議將 G1 屏障的擴展時機延後。在編譯管線中,從目前的「位元組碼解析」階段延遲到機器碼產生階段,也就是 C2 將 IR 操作轉換為機器指令的最後階段:

本提案中 C2 延後 g1 展障擴展的流程如下:
- 輸入位元組碼 bytecode
- 位元組碼解析
,加入(延後此一步驟)g1屏障中介表達式 - 輸出
IR(已不需要在<g1>IR中先插入g1屏障) - 進行平台無關的最佳化
- 輸出
IR - 進入「平台
p的架構描述」- 指令選擇
- 輸出
IR<p> - 指令排程
- 輸出
IR<p> - 暫存器分配
- 輸出
IR<p> - 機器碼產生,加入平台
p的g1屏障中介表達式實作(延後到此一步驟時再加入) - 輸出機器碼
<g1, p>
延遲屏障擴展的實作細節
- 位元組碼解析(Bytecode Parsing)時會標註記憶體存取操作
- 在解析位元組碼的過程中所產生的 IR 記憶體存取操作,C2 會標註附加資訊
- 它們被用來要求在最後的機器碼產生階段時,要一併生成相應的屏障操作機器碼
- 這些資訊不會暴露給 C2 的分析與最佳化機制,因此不會影響 C2 的優化流程
- 指令選擇階段(Instruction Selection)
- C2 將抽象的記憶體存取操作轉換為特定平台和 GC 的指令,但此時仍未擴展屏障
- 此階段會插入特定 GC 的指令,例如確保暫存器配置器能夠保留足夠的暫存器來執行屏障操作
- 機器碼產生階段(Code Emission)
- 在機器碼產生時,C2 會根據先前標註的屏障資訊,將特定 GC 的記憶體存取指令轉換為完整的機器指令
- 這些指令包含標準的記憶體存取操作,以及包圍它的屏障程式碼(barrier code)
- 屏障程式碼的實作來自於位元組碼直譯器的屏障機制,並額外輔以組合語言的輔助程式(assembly-stub routines),以便讓屏障邏輯能夠呼叫 JVM 內部函式
ZGC 的成功案例
延遲擴展屏障的技術並非新概念,自 JDK 14 起的 ZGC(全並行垃圾回收器)已經成功使用了這種設計,並且它已經在 JDK 15(JEP 377)達到正式可用的穩定階段。
在 G1 上實作延遲屏障擴展時,我們重複利用了許多來自 ZGC 的機制,包括:
- 延伸了 Heap Access API 與邏輯,允許屏障程式碼呼叫 JVM 函式
- 重複使用組合語言層級的屏障實作,它們已經存在於各平台中並支援位元組碼直譯模式(bytecode interpretation)
- 這些屏障的實作使用 (類似)組合語言(pseudo-assembly) 來表達,它是所有 HotSpot 開發人員熟悉的抽象層,能夠確保所有平台都能適用
最佳化策略
初步實驗顯示,即使是未經優化且最單純的延遲屏障擴展實作,其效能已接近 C2 最佳化後的程式碼。然而,要完全縮小這個效能差距,我們需要引入 C2 目前應用的一些核心最佳化策略。
最佳化策略主要針對寫入操作的屏障,意即 x.f = y 形式的操作,其中 x 和 y 是物件,而 f 是欄位。這類操作佔據約 99% 的 G1 屏障執行次數,因此是優化的主要目標。寫入屏障由兩個部分組成:
- 前置屏障(Pre Barrier):支援並行標記(concurrent marking)
- 後置屏障(Post Barrier):支援堆積區(heap region)劃分成不同分代(generations)
移除對新物件的寫入屏障
以新配置的物件來說,如果分配記憶體區塊和初始化步驟之間沒有 safepoint 的話,那麼對它的寫入操作其實不需要屏障。
目前,C2 會在偵測到這種情境時,會主動移除對新物件的寫入屏障。在 JEP 475 延遲屏障擴展中,我們在寫入操作上標註相關資訊,那麼就能夠在機器碼產生階段時省略相對應的屏障程式碼,藉此達到相同的最佳化效果。
根據空值資訊以簡化屏障
C2 編譯器通常能保證在記憶體寫入操作中(y)要儲存的物件指標,不是空值就是非空值。它可以輕易地從原始位元組碼中推導出來,或者由 C2 的型別分析(type analysis)去推斷 y 的 null 狀態。
在機器碼生成階段時,我們可以利用 C2 的分析結果來簡化甚至移除後置屏障,並在啟用該模式時簡化物件指標的壓縮和解壓縮。
目前,C2 透過其泛用型平台獨立的分析和優化機制,無縫地實作了這些簡化。我們可以針對後期屏障擴展實作相同的簡化,做法是根據 C2 型別系統所提供的資訊,明確地跳過不必要的屏障和物件指標壓縮與解壓縮指令產生。初步實驗顯示,約 60% 被執行的寫入後置屏障可以透過這種技術被簡化或移除。
移除冗餘的解壓縮操作
當物件指標壓縮與解壓縮功能啟用時,記憶體寫入操作會儲存壓縮後的物件指標,但屏障操作的是未壓縮的物件指標。目前,C2 會對寫入操作及其屏障進行的全域分析與優化,並通常只產生單一的壓縮操作,以使物件指標的兩種版本都可以使用。
一個簡易的延遲屏障擴展實作可能會為每次寫入都產生壓縮和解壓縮兩種操作,增加了不必要的運算成本。我們可以插入「壓縮並寫入」的虛擬指令(compress-and-write pseudo-instructions),來移除冗餘的解壓縮操作。
該指令能夠匹配壓縮和寫入 IR 操作對應的組合。並且,在這些虛擬指令的範圍內,可以只進行一次壓縮操作,就使得物件指標的壓縮和未壓縮兩種版本都可用,從而達到與 C2 目前優化相同的效果。
優化屏障程式碼的佈局
前置屏障和後置屏障都會測試是否實際需要用到屏障;如果確實需要執行時,屏障會呼叫 JVM 內部,通知垃圾回收器該寫入操作;如果屏障不需要執行,則會直接跳過。在初步實驗、過往的研究記錄、以及原始的 G1 論文中都顯示,實務上屏障並不常被需要,因此大多數的屏障程式碼其實很少被執行。
目前,C2 已經自然地將這類「不常用的屏障程式碼」放置在主要執行路徑之外,以提升程式碼快取的效率。我們可以透過手動將屏障實作分割成常用和不常用,並將不常用的部分擴展到獨立的組合語言輔助程式(assembly stubs)之中,從而為後期屏障擴展達到相同的效果,確保它不影響主要執行路徑。
替代方案
GC 屏障可以在 C2 編譯管線的不同階段進行擴展,每種方式都涉及不同的 C2 負擔、C2 知識需求、指令排程控制能力、以及對不同平台的支援需求。目前主要考慮以下幾個展開時機:
- 在位元組碼解析階段:在構建 IR 時就展開 GC 屏障
- 在平台無關的最佳化階段:發生在循環最佳化(loop transformations)、逃逸分析(escape analysis)等最佳化之後
- 在指令排程之後:發生在選擇並排程特定平台的指令之後,但在註冊分配(register allocation)之前(目前 C2 並未支援在這個階段展開屏障)
- 在註冊分配之後:發生在註冊分配與最終 C2 轉換之間
- 在機器碼產生階段:發生在 IR 指令轉換為機器碼時,這也是目前 JEP 475 的方案
各展開時機的優缺點分析
| 展開時機 | C2 負擔 | 需要 C2 知識 | 可控制指令排程 | 平台無關 |
|---|---|---|---|---|
| 位元組碼解析(early) | 高 | ✅ 需要 | ❌ 無 | ✅ 是 |
| 平台無關最佳化後 | 中 | ✅ 需要 | ❌ 無 | ✅ 是 |
| 指令排程後 | 中 | ✅ 需要 | ✅ 是 | ❌ 否 |
| 註冊分配後 | 低 | ✅ 需要 | ✅ 是 | ❌ 否 |
| 機器碼產生(late) | 低 | ❌ 不需要 | ✅ 是 | ❌ 否 |
選擇後期屏障擴展的理由
- C2 編譯負擔最低:從位元組碼解析階段延後至註冊分配之後,能顯著降低 C2 負擔。如果能夠延後至機器碼產生階段才擴展屏障的話,負擔是最低的。
- 不需要 C2 專業知識:除了機器碼產生階段外,其餘所有擴展方案都需要深入的 C2 編譯器知識。另外也可避免 C2 內部最佳化影響 GC 屏障行為,減少錯誤風險。
- 避免指令排程問題:在位元組碼解析階段,或是在平台無關最佳化後擴展的話,可能導致指令排程混亂。如果在機器碼產生時才展開的話,就可以確保排程的一致性。
- 能夠重用 ZGC 的機制:ZGC 從 JDK 14 開始使用延遲屏障擴展,並在 JDK 15 進入正式環境。G1 也可沿用 ZGC 的機制與 JVM 內部呼叫邏輯,以減少開發工作。
風險與假設
- 如同任何影響核心 JVM 組件互動的變更一樣(在這個案例中是 G1 垃圾回收器和 C2 編譯器),導入新流程可能會產生故障與效能衰退的問題,並存在不可忽略的風險。為了降低這種風險,團隊會進行內部程式碼審查,並會執行比日常更新和錯誤修復任務還要更多的測試。
- 在 G1 的背景下,不精確的卡片標記是一種最佳化,它避免了對同一物件的不同欄位進行寫入序列的多個 JVM 呼叫。目前基於早期屏障擴展的這種最佳化實作,尚未顯示出顯著的應用程式層級效能優勢。因此,團隊假設為了達到目前產生的程式碼品質,我們不需要為後期屏障擴展實作不精確的卡片標記。然而,團隊可能會在未來開發更有效的非精確卡片標記實作。
總結
JEP 475 提出的延遲屏障擴展技術代表了 Java 平台在效能優化方面的重要進展。透過將 G1 垃圾回收器的屏障擴展時機延後,不僅能夠降低系統開銷,還能提升程式碼的可維護性和可靠性。這項改進特別適合在雲端環境中運行的 Java 應用程式。
隨著這項技術的逐步成熟,我們可以期待看到更多的效能提升和穩定性改進。ZGC 的成功經驗已經證明了這種方法的可行性,而將其引入 G1 垃圾回收器將為整個 Java 生態系統帶來更多效益。這也反映了 Java 平台在持續改進和創新方面的承諾。
本篇文章的內容為老喬原創、二創或翻譯而來。雖已善盡校對、順稿與查核義務,但人非聖賢,多少仍會有疏漏之處難以避免。如果大家有任何問題、建議或指教,都歡迎在底下留言與老喬討論!


發佈:
更新:
瀏覽:
分類:
標籤:




