自 Java 於 1995 年面世以來,Java 虛擬機(JVM)的垃圾回收機制一直是眾所矚目的重要功能,也是效能優化的關鍵領域。本文將介紹 JEP 423 的 G1 垃圾回收器區域釘選(Region Pinning)技術,能大幅改善 G1 垃圾回收器在處理 Java Native Interface 臨界區域時的效能。
區域釘選技術的核心目標是減少 JNI 使用臨界區域時的延遲問題,以維持垃圾回收的效率。這項技術允許 G1 垃圾回收器在 JNI 臨界區域期間繼續進行垃圾回收,而不是完全禁用它,並藉此提高 Java 程式的整體效能和回應性。
目錄
前言
Java 雖然能夠完成許多複雜的任務,但因為 Java 語言本身的特性與平台的限制,導致它在某些情況下無法完成特定的任務,或是效能比其他語言差。為了彌補這些短處並善用其他語言的長處,讓 Java 程式碼能夠與其他程式語言(如 C 或 C++)相互操作是重要且必要的。它可以讓我們不用去實作其他語言已經完成的工具,使我們專注在解決問題而不是重造輪子。
為了能夠與 C 和 C++ 等程式語言進行溝通,Java 1.1 中定義並實作了 Java Native Interface(JNI)以提供 Java 語言和其他語言之間相互操作的能力。雖然它帶來了語言間溝通的便利性,但它也帶來了一些挑戰,其中最顯著的問題是在 JNI 臨界區域期間內的效能瓶頸。
什麼是臨界區域(Critical Regions)?
在 JNI 中,當原生程式碼要訪問或修改 Java 物件或原始型別的陣列時,有兩種主要實作方式:
- 複製資料:JNI 將 Java 層的資料複製到本地記憶體中,讓原生程式碼自由操作這些複本資料,並在修改完成後覆寫回 Java 層記憶體。它的缺點是大量資料的來回複製會產生額外的時間和空間成本,特別是在處理大型陣列時
- 指標存取:JNI 提供方法讓原生程式碼取得指向 Java 資料的指標,使得原生程式碼能夠藉由指標來直接操作 Java 層資料,並在結束存取時釋放指標。它可以避免資料複製所帶來的成本開銷,但存在著直接操作指標的安全性風險
從指標存取延伸出來的臨界區域
JNI 中定義了獲取和釋放「指向 Java 堆積記憶體中物件指標」的方法,而這些方法必須要成對使用。首先,原生程式碼呼叫 JNI 方法以獲取指向某個物件的指標(例如 GetPrimitiveArrayCritical
),並在使用該物件之後釋放該指標(例如
);而從獲取到釋放這兩個方法中的程式碼區塊即被認為是在「臨界區域(critical regions)」中運行,在此期間存取的 Java 物件即是「臨界物件」。ReleasePrimitiveArrayCritical
簡單來說,JNI 的臨界區域指的是 Java 程式碼與原生程式碼在進行互動時,用來高效存取 Java 堆積(heap)中資料(如 int[]
、float[]
等基礎型別陣列)的特殊區域。它由一對 JNI 「獲取/釋放方法」之間的程式碼段落所組成。在臨界區域中,原生程式碼可以直接操作 Java 堆積中的資料,無需經過 JVM 的存取檢查。透過這種方式,不僅節省了記憶體複製的成本,也一併提升了效能。
由於直接操作 Java 堆積記憶體,當 GC 發生時有可能會導致原生程式碼出現存取異常。為了確保這些 Java 物件不會在原生程式碼存取時被垃圾回收器移動或清除,JVM 與 GC 在臨界區域執行期間需要有特殊的策略。
臨界區域的影響與注意事項
目前預設的 G1GC 採用的策略是在每個臨界區域的執行期間內完全禁用 GC。這種做法雖然確保了原生程式碼可以安全地訪問 Java 物件,但也導致了嚴重的延遲問題。如果一個 Java 執行緒觸發了 GC,那麼它就必須等待,直到沒有其他執行緒位於臨界區域中後完成 GC。其影響的嚴重程度取決於臨界區域的執行頻率和持續時間,在最壞的情況下,有時臨界區域甚至會阻塞整個程式長達數分鐘。
另外,在某些極端情況下,程式可能會因為長時間的 GC 暫停而變得無法回應。甚至由於執行緒飢餓導致不必要的記憶體消耗,而出現記憶體不足的情況,使得 VM 過早地被迫關閉。
因為臨界區域有可能會影響效能,所以我們應盡量縮小臨界區域,尤其要避免執行耗時或阻塞的操作。另外,我們也必須確保在使用完臨界物件後要立刻呼叫相應的釋放方式,以避免可能導致的記憶體洩漏或其他問題。
由於這些情況所帶來的影響對於需要頻繁使用 JNI 的程式來說尤其嚴重,因此某些 Java 函式庫和框架的維護者選擇在預設情況下不使用臨界區域(例如 JavaCPP),甚至完全不使用(例如 Netty),儘管這樣做可能會對吞吐量產生不利影響。因此,我們迫切需要一種能夠在保證記憶體存取安全性的同時,又不會嚴重影響垃圾回收效率的解決方案。
JEP 423 概觀
JEP 423 G1 區域釘選(Region Pinning for G1)提議在 G1 垃圾收集器中實現對臨界區域的釘選功能,以解決禁用 GC 的問題。這意味著在 JNI 臨界區域期間,包含臨界 Java 物件的堆積記憶體區域將會被「釘選」且固定,使得垃圾收集器不會去移動或回收這些區域中的物件,但可以繼續收集其他未被釘選的區域。
優點
- 允許垃圾收集器在 JNI 臨界區域期間內繼續運行
- 顯著減少了潛在的執行緒停滯而導致的停頓時間,從而降低了額外延遲並避免了性能損失
- 提高垃圾回收器的整體效率,並改善使用 JNI 程式的效能和回應性
- 降低記憶體不足和程式無回應的風險
缺點
- 實現複雜度增加,可能需要更多的開發和測試資源
- 在極端情況下,大量釘選區域可能會導致堆積記憶體耗盡的風險存在
- 可能需要開發者重新評估和調整現有的 JNI 使用策略
介紹
背景
G1GC 會將整塊堆積記憶體劃分成固定大小的區域。在任何特定的收集操作中,物件只會從區域的一個子集複製(即移動)到另一個子集。由於它是一種分代垃圾收集器,因此所有非空的區域都屬於年輕代或是老年代。
如果 G1 在 minor collection(即年輕代收集)的期間無法找到空間來複製一個物件,則它會將該物件留在原地,並將該物件及其所在的區域標記為「複製失敗」。複製執行完畢後,G1 會將失敗的區域從年輕代提升到老年代來修復,使它們為後續的複製做好準備。
G1 能夠在 major collection(即 full collection,完整收集)的操作期間將物件釘在它們的記憶體位置上,並且不會複製存放它們的區域。例如:G1 會釘選存放著大型物件的巨大區域,並且它還會在單一收集期間內釘選任何超過指定存活閾值的區域。因此區域釘選的技術其實早已存在於 G1 內並實際運用中。
不過,G1 無法在 minor collection 操作期間釘選任何區域,儘管它確實會將巨大的區域排除在操作範圍之外。
在年輕代收集操作時釘選區域
本功能的目的是想要通過擴展 G1 的能力,使其在 major 和 minor 收集操作期間內能夠釘選任意的區域。區域釘選技術的核心思想是允許 G1 垃圾回收器在 JNI 臨界區域期間內繼續進行垃圾回收,同時確保臨界物件不會被移動或回收。這是通過以下機制實現的:
- 每個記憶體區域中維護一個計數器,記錄該區域內臨界物件的數量
- 當有原生程式碼獲取臨界物件時,相應區域中的計數器遞增;釋放該物件時遞減
- 如果計數器為零時,則表示 GC 可以正常進行回收該區域的垃圾
- 如果計數器不為零時,則將該區域「釘選」並視為已固定,不會被移動或回收
在實際操作中,區域釘選技術的工作流程如下:
- 對於完整收集(major collection)
- 不複製任何已釘選的區域
- 正常處理未釘選的區域
- 對於年輕代收集(minor collection)
- 將年輕代中已釘選的區域視為「複製失敗」,從而將它們提升到老年代
- 不複製老年代中現有的已釘選區域
- 正常處理其他未釘選的區域
這種解決方法可以讓我們在 JNI 臨界區域內不需要禁用 GC,並允許垃圾回收器在大多數情況下正常運作。只有存放著臨界物件的區域會被暫時「釘選」,使得 GC 可以略過那些區域,並繼續收集其他未釘選區域中的垃圾。這大大減少了 JNI 臨界區域對整體垃圾回收效率的影響。
替代方案
JNI 規範建議了另外兩種實現臨界區域的方法:
- 在臨界區域開始時,將臨界物件複製到 C 堆積記憶體中存用,並在臨界區域結束時將其複製回來
- 如同前述所言,這個方式在時間和空間上都非常低效。在 G1 中,我們只能對無法釘選的區域中的臨界物件這樣做。然而,這些區域位於年輕代中,而年輕代通常是大多數物件大幅使用和修改發生的地方,因此其實這種方式對改善效能不會有太大幫助
- 釘選單一臨界物件
- G1 只能複製整個區域,因此單一區域中的單一釘選物件實際上會阻止該區域整個的垃圾收集。其最終的結果與「釘選整個區域」幾乎沒有不同,只是此方案的開銷會更高,因為追蹤單一釘選物件的成本比維護每個區域中臨界物件計數的成本還要高
風險與假設
此功能的前提假設是 JNI 臨界區域會被謹慎妥善地使用;意即我們會遵照上述的注意事項:縮小臨界區域、避免耗時或阻塞的操作、及時釋放臨界物件,以確保縮短其執行時間。
另外,當程式同時釘選多個區域時,可能會存在著堆積記憶體耗盡的風險。雖然目前尚無較好的解決方案,但是 Shenandoah GC 在 JNI 臨界區域期間內也會釘選記憶體區域,並且沒有遇過這個問題。這代表對 G1 來說也不會存在這個問題。
總結
JEP 423 提出的 G1 垃圾回收器區域釘選技術代表了 Java 虛擬機器效能優化的一個重要里程碑。通過巧妙地平衡 JNI 臨界區域的安全性需求和垃圾回收的效率,這項技術有望大幅提升依賴 JNI 程式的效能和可靠性。
儘管實現這項技術面臨了一些挑戰,但其潛在的效益遠遠超過了可能的風險。隨著這項技術的成熟和廣泛採用,我們可以期待看到更多高效能、低延遲的 Java 程式和函式庫,特別是在需要密集 JNI 相互操作的領域。對於 Java 開發人員來說,了解並善用這項新技術將成為提升程式效能的重要技能。
本篇文章的內容為老喬原創、二創或翻譯而來。雖已善盡校對、順稿與查核義務,但人非聖賢,多少仍會有疏漏之處難以避免。如果大家有任何問題、建議或指教,都歡迎在底下留言與老喬討論!