原文はこちら。
The original article was written by Thomas Schatzl (OpenJDK developer, Oracle).
https://tschatzl.github.io/2024/07/22/jdk23-g1-serial-parallel-gc-changes.html
JDK 23 がrampdown phase 2に入ったことを受け、この記事ではOpenJDKのstop-the-world GCの変更点について、いつものように概要をご紹介します。
前リリースと比較してJDK 23のGCの領域での変更はかなり控えめなものですが、将来のよい兆しについて、この記事の最後に触れておきます。
JDK 23 の Hotspot GC サブコンポーネント全体に対する変更点の完全なリストはこちらです。執筆時点で解決済みまたはクローズされた変更点は合計で約 230 あります。特に変わった点はありません。
JDK 23
https://openjdk.org/projects/jdk/23/
JDK 22 G1/Parallel/Serial GC changes
https://tschatzl.github.io/2024/02/06/jdk22-g1-parallel-gc-changes.html
https://logico-jp.io/2024/02/13/jdk-22-g1-parallel-serial-gc-changes/
Parallel GC
Parallel GC に対するおそらく最も大きな変更は、既存のParallel GCのFull GCアルゴリズムが、より伝統的なパラレルMark-Sweep-Compactアルゴリズムに置き換わったことです。
元のアルゴリズムは、オブジェクトの圧縮、つまりオブジェクトの移動時にオブジェクトヘッダーを上書きしますが、移動したオブジェクトの最終的な位置を計算し、参照を更新するためにオブジェクトサイズを(再)計算する必要があります。上書きされたオブジェクトが存在する場合でもそれを可能にするため、Parallel GCのFull GC は、最初のフェーズでライブオブジェクトの開始を記録するビットマップと同じサイズのビットマップに、ライブオブジェクトの終了位置を保存した上で、特定のライブオブジェクトのオブジェクトサイズが必要な場合、アルゴリズムは終了ビットマップ内のオブジェクトの開始位置から順方向にスキャンして次のセットビットを探し、前者を後者から差し引きます。これは時間がかかる可能性があり、移動されたオブジェクトの最終位置を確認する作業の一部として毎回行う必要があります。これは、JDK-8320165で再発見されたように、実際に生きているオブジェクトの数に2乗で比例します。この問題に対処するために、JDK-8145318をはじめとする関連の濃淡にばらつきのあるいくつかの改善が行われていますが、JDK-8320165が示すように、この問題を完全に緩和することはできませんでした。
[JDK-8320165] Parallel: Full GC code is very slow due to quadratic calc_new_address
https://bugs.openjdk.org/browse/JDK-8320165
[JDK-8145318] Cache and reuse ParMarkBitMap::live_words_in_range() results in Parallel GC
https://bugs.openjdk.org/browse/JDK-8145318
JDK-8329203では、Parallel GC のやや独特なアルゴリズムを、(基本的に)G1 のパラレルFull GCに置き換えました。これにより、これらの不具合の影響を受けなくなりました。同時に、2つ目のエンドビットマップ(Java ヒープサイズの 1.5% を占める)も削除でき、全体的なパフォーマンスは変わらないことが当社の測定で明らかになりました(問題のあるケースでは大幅に改善しています)。
[JDK-8329203] Parallel: Investigate Mark-Compact for Full GC to decrease memory usage
https://bugs.openjdk.org/browse/JDK-8329203
Parallel Full GCのパフォーマンスをさらに向上させるには、各リージョン内の生存バイト数を記録するカウンタの競合を減らすことで実現できる可能性があります。すべてのスレッドがグローバルカウンタを即座に更新するのではなく、JDK-8325553で、Parallel GCでは、各スレッドが同一(少数の)リージョン内のライブオブジェクトにのみ遭遇する場合、各スレッドがリージョンごとのライブ状態をローカルに記録するようになりました。
[JDK-8325553] Parallel: Use per-marker cache for marking stats during Full GC
https://bugs.openjdk.org/browse/JDK-8325553
Serial GC
Serial GCのコードのクリーンアップとリファクタリングが継続中です。
G1 GC
JDK 23 で解決された長年の問題のひとつに、JDK-8280087 があります。これは参照処理中に G1 が一部の内部バッファを適切に拡張せず、役に立たないエラーメッセージを表示して早期終了していた、というものでした。
[JDK-8280087] G1: Handle out-of-mark stack situations during reference processing more gracefully
https://bugs.openjdk.org/browse/JDK-8280087
JDK-8327452 や JDK-8331048 のようなパフォーマンス改善により、ポーズ時間が短縮され、G1 GCのネイティブメモリオーバーヘッドが削減されています。
[JDK-8327452] G1: Improve scalability of Merge Log Buffers
https://bugs.openjdk.org/browse/JDK-8327452
[JDK-8331048] G1: Prune rebuild candidates based on G1HeapWastePercent early
https://bugs.openjdk.org/browse/JDK-8331048
All (STW) GCs
少し前に、専用の(内部)フィラーオブジェクトについてご紹介しました。
The Case of Ljdk.vm.internal.FillerArray;
https://tschatzl.github.io/2022/09/26/jdk-vm-internal-fillerarray.html
https://logico-jp.io/2022/12/12/the-case-of-ljdk-vm-internal-fillerarray/
jdk.vm.internal.FillerArrayは有効な配列クラス名ではないという指摘がコミュニティから寄せられ、一部のツールでは可変長の正規オブジェクトに問題が生じるという指摘もありました。
JDK-8319548により、フィラー配列のクラス名が[Ljdk/internal/vm/FillerElement;に変更されました。そしてJDK 21以後のリリースにバックポートされています。
[JDK-8319548] Unexpected internal name for Filler array klass causes error in VisualVM
https://bugs.openjdk.org/browse/JDK-8319548
What’s next
G1の記憶集合のストレージ要件の削減は、当社にとって依然として非常に重要な課題です。1つの古いアイデアに、複数のリージョンに対して1つの記憶集合を使用することで、同じ記憶集合を持つリージョンのグループ内に記憶集合エントリを保存する必要性をなくす、というものがあります。
[JDK-8058803] Allow one remembered set to be used for multiple regions
https://bugs.openjdk.org/browse/JDK-8058803
下図でその原理を示しています。現在のリージョン(上部の丸角の箱、YはYoungリージョン、OはOldリージョンを示す)には、リージョンに関連付けられた記憶集合(リージョンの下の縦列に配置された小さな箱)があります。各エントリは、それぞれの地域の外側のおおよその位置(エリア)を示しており、そのリージョンへの参照がある可能性があります。
Youngリージョンは常にそのリージョンに関連付けられた記憶集合を保持しています。これはそのリージョン内の生存オブジェクトを移動する際に、修正が必要な参照先を見つける必要があるためです。そして、GCのたびに、そのリージョンを退避/移動します。異なるリージョンの記憶集合のエントリが、Old genリージョンの同じ場所を指していることに気づくかもしれません。

前述の通り、特にYoungリージョンは常に同時にまとめて退避されるため、同じ場所を指すこれらのリメンバーセットエントリはすべて冗長になっています。 上記のアイデアを実装するための最初の変更(JDK-8336086)は、下図のように、すべてのYoung genリージョンに対して単一の記憶集合を使用することで、冗長な記憶集合エントリを自動的に削除するというものです。これにより、メモリを節約できるだけでなく、GC時にこれらのエントリをフィルタリングする時間も節約できます。
[JDK-8336086] G1: Use one G1CardSet instance for all young regions
https://bugs.openjdk.org/browse/JDK-8336086
(技術的には、記憶集合を結合することで、グループのリージョン間の位置を参照する記憶集合エントリを保存する必要もなくなりますが、Young genリージョンではそのような記憶集合エントリが生成されることはないので、表示されず、メモリ領域の節約にもなりません)。

このテクニックをYoung genリージョンの記憶集合にのみ適用した場合の利点は、以下のいくつかの大規模なアプリケーションの測定結果に示されているように、すでに相当なものです。下図は、変更を適用する前(青線)と適用後(ピンク線)の記憶集合のメモリ使用量を表したグラフです。Young genリージョンのみが記憶集合を持つ最初の40秒間におけるメモリ使用量が半減している点に特に注目してください。
Old genリージョンに記憶集合が割り当てられるにつれ、このプロトタイプにおいて改善が小さくなっていますが、これはOld genリージョンのマージをサポートしていないためです。しかし、Young genリージョンの記憶集合の節約による影響は依然として顕著です。

Young genリージョンの記憶集合を結合するためのこの最初の変更は、実際にレビューに出されていますが、Old genリージョンも1つの記憶集合を使用するグループにマージする、より一般的なバリエーションも準備中です。
8336086: G1: Use one G1CardSet instance for all young regions #20134
https://github.com/openjdk/jdk/pull/20134
今回のリリースサイクルでは、G1 GCの改善に重点的に取り組んだもう一つの大きな課題は、書き込みバリア(write barrier)の変更です。G1 GCのスループットを、場合によってはパラレル GC との差を埋める最善の方法について多くの労力を費やして調査しました。
[JDK-8253230] G1 20% slower than Parallel in JRuby rubykon benchmark
https://bugs.openjdk.org/browse/JDK-8253230
How fast is Java 21?
https://timefold.ai/blog/java-21-performance
そして、G1 GCのレイテンシの面を損なわない、非常に良い解決策を見つけたと考えています。
その方向性の一つがJEP 475です。G1 用の遅延バリア拡張は、JDK 24での組み込みに向けて順調に進んでいるようです。これにより、私たちが計画している最適化のいくつかが可能になります。
JEP 475: Late Barrier Expansion for G1
https://openjdk.org/jeps/475
8334060: Implementation of Late Barrier Expansion for G1 #19746
https://github.com/openjdk/jdk/pull/19746
まだ解決すべきいくつかの詳細や問題があり、解決には時間がかかります。また、すべてを準備するにはさらに時間がかかりますが、今後数か月にわたってさらに多くの情報をお伝えしていきます。
Thanks go to…
いつも通り、素晴らしい次回のJDKリリースに貢献してくれたみなさまに感謝いたします。また次のリリースでお会いしましょう。