Java 22中三種垃圾回收GC效能獲得了大提升

banq發表於2024-03-13


 JDK 22 GA 即將到來,本文介紹該版本中 OpenJDK 的垃圾收集器GC的最新更改,主要是提升了效率和效能。

 JDK 22 GA 這個版本在 stop-the-world 收集器領域提供了相當重大的變化,例如JEP 423:G1 的區域固定。除了功能上的實際變化之外,它還需要在幕後進行一些至少在技術上有趣的變化。序列和並行 GC 年輕集合的效能也得到了改進

以下是 JDK 22 中 Hotspot stop-the-world 收集器的有趣變化的通常概述:

Parallel並行GC
在代際垃圾回收過程中,查詢舊引用到新引用是一項重要任務。並行(和序列)GC 為此使用了卡片表。首先,突變者會將可能包含引用的卡片表項標記為 "髒"。然後,在暫停期間,演算法會掃描卡片表中的這些髒標記,並檢視這些標記所指示的物件,因為它們可能包含舊引用到新引用。

並行 GC(顧名思義)在檢視典型的大型卡片表時使用多個並行執行緒。卡片表掃描的工作分配機制是將堆劃分為 64kB 小塊。任何從該區域開始的卡片標記物件都歸該執行緒處理。

JDK-8310031 實現了兩項最佳化,從而改善了工作分配並提高了效能

  • 在大型物件陣列的內容被分割到不同分割槽後,現在不僅是擁有大型物件陣列的執行緒會查詢該物件中從老到新的引用。這以前可能會導致單個執行緒獨自走完一個 GB 的大型物件。雖然在卡片掃描後,還存在一些基於從佇列中竊取工作的額外工作分配機制,但與多個執行緒首先檢視該物件的部分內容相比,這種竊取工作的成本相對較高。
  • 雖然髒卡已經指出了感興趣的位置,但單個所有者執行緒總是會檢視陣列的所有元素。因此,處理執行緒經常會檢視許多已知不包含任何從舊到新引用的引用。這就變成了一個執行緒只處理大型物件陣列中被標記為髒的部分。

在某些情況下,舊的行為會導致反向執行緒縮放,與 G1 收集器等相比,暫停時間非常長。

並行 GC 的另一個效能問題與 Java 堆中的大型陣列物件有關,該問題也已得到修復。現在,並行 GC 在塊偏移表中使用與其他收集器相同的指數後跳來查詢物件的起始位置,從而加快了這一過程和整體暫停時間。

塊偏移表解決了在卡片表中查詢卡片之前的物件起始位置的問題。其中一個應用是在上述卡片掃描過程中,垃圾收集器需要快速找到 Java 物件的起始點,無論是從該特定卡片開始還是進入該特定卡片,以便開始對該物件進行正確解碼(查詢引用)。

每張卡(通常代表 512 位元組的 Java 堆)都有一個塊偏移表 (BOT) 條目。該條目儲存的資訊要麼是物件從與該卡對應的堆地址後退多少個字開始進入該卡,要麼是在前一個卡中沒有物件開始,演算法需要檢視前一個 BOT 條目以獲取更多資訊。JDK-8321013 引入的更改將後跳值從 "檢視前一張卡 "改為以 2 為底的指數計算後退的卡數。

使用新BOT編碼後,垃圾收集演算法只需要幾步。這大大減少了為大型物件尋找物件起點時的記憶體訪問量,從而提高了效能

序列 GC
JDK-8319373 基於 JDK-8310031 中新增的並行 GC 程式碼,最佳化了序列 GC 中的卡片掃描程式碼(查詢髒卡)。如果髒卡很少,這還會大大減少年輕卡的收集時間。

我們花費了大量精力清理序列 GC 程式碼,刪除了 JDK 14/JEP 363 中刪除的併發標記掃描收集器共享相同程式碼時的死程式碼和抽象。

G1 GC
以下是 JDK 22 面向 G1 使用者的更改:

1、G1 現在 (JDK-8140326) 會在下一次任何型別的垃圾回收中回收疏散失敗的區域。這提高了 G1 收集器的恢復能力,避免其舊版本被疏散失敗的區域淹沒。

主要用例是區域釘住:嘗試疏散釘住的區域會導致疏散失敗,從而將受影響的區域移到舊一代中。如果不採取任何措施,在區域解除釘扎後立即回收這些通常很快就能回收的區域,就會導致舊一代區域的大量堆積,從而產生更多的垃圾收集,在最糟糕的情況下還會產生不必要的全 GC。

顯然,這也有助於回收因記憶體不足而無法複製物件所導致的疏散失敗區域中的空間。

另外,這一改動還消除了 G1 中以前的(自我設定的)限制,即只有特定型別的年輕集合才能在舊一代區域中回收空間。現在,只要符合某些要求,任何年輕的集合都可以撤離舊版區域。

2、隨著 JDK-8318706 的整合和 JEP 423:G1 的區域銷釘的完成,在 G1 中取消使用 GCLocker 的漫長旅程已經結束。

簡而言之,以前如果應用程式在與 JNI 互動時透過 Get/ReleasePrimitiveArrayCritical 方法訪問陣列,則不會發生垃圾收集。這一修改修改了垃圾收集演算法,將這些物件保留在原處,"釘住 "它們並將相應的區域標記為 "釘住 "區域,但允許疏散釘住區域內的任何其他區域或非原始陣列。後一種最佳化之所以可行,是因為 Get/ReleasePrimitiveArrayCritical 只能鎖定非原始陣列物件。

現在,Java 執行緒絕不會因為使用 G1 的 JNI 程式碼而停滯

3、在 JDK-8314573 中,Remark 暫停期間的堆大小調整有一些小改動,以使調整大小更加一致。堆大小調整現在根據 -XX:Min/MaxHeapFreeRatio 計算堆大小變化,而不考慮 Eden 區域。由於 Remark 暫停可在突變器階段的任何時間發生,以前的行為使得堆大小變化非常依賴於當前的 Eden 佔有率(即 Remark 暫停發生時應用程式已進入突變器階段多長時間,用於計算的空閒區域數量可能相差很大,從而導致堆大小調整不同)。

這使得堆的大小更具有確定性,通常也不那麼激進。

4、新改變列表還包括一項實際、直接的效能改進:區域的程式碼根集,即來自編譯程式碼的根,以前在垃圾收集過程中由每個區域的單個執行緒處理。在程式碼根集非常不平衡的情況下(大量程式碼嵌入引用到一個或幾個區域中),這可能會導致垃圾回收工作停滯。JDK-8315503 使 G1 即使在區域內也能將程式碼根掃描工作分配給多個執行緒,從而消除了這一潛在瓶頸。

所有 GC暫停情況
在 JDK-8290025 中,Loom 需要移除程式碼快取清掃器。

就 STW 收集器而言,它的工作已被轉移到適當的暫停中。

不幸的是,掃碼器工作的一部分元件的執行時間為 O(n^2),其中 n 是解除安裝方法的數量。

只要掃碼器與應用程式同時工作,這個問題就不會太大,但在移除這個元件後,當解除安裝大量編譯程式碼時,暫停時間就會出現明顯的倒退。

有了 JDK-8317809、JDK-8317007、JDK-8317677 和其他一些軟體,現在在暫停時解除安裝類的速度實際上比移除程式碼快取清掃器(但仍在進行所有工作)之前還要快


 

相關文章