JDK 22 G1/Parallel/Serial GC changes

原文はこちら。
The original article was written by Thomas Schatzl (OpenJDK developer, Oracle).
https://tschatzl.github.io/2024/02/06/jdk22-g1-parallel-gc-changes.html

別のJDKリリースであり、この一連のエントリの別バージョンでもあるのですが、今回JDK 22のGAがまもなくなので、OpenJDKのstop-the-world GCの当該リリースにおける最新の変更でお楽しみ戴きたいと思います。

JDK 22
https://openjdk.org/projects/jdk/22

全体として、このリリースではJEP 423のように、stop-the-world GCの分野でかなり重要な変更がもたらされていると思います。

JEP 423: Region Pinning for G1
https://openjdk.org/jeps/423

実際の機能の変更に続いて、内部的に少なくとも技術的に興味深い変更が必要でした。また、Serial GCとParallelGCの両GCでのyoung genでのcollectionの性能が向上しています。

JDK 22のHotspot GCのサブコンポーネント全体の完全な変更リストはこちらにありますが、これを見ると合計で約230個の変更が執筆時点で解決もしくはクローズされていることがわかります。これはGCに関する変更を完全に反映しているわけではなく、重要な変更の一部は他のサブシステムに起因するものですが、GCポーズタイムに大きな影響を与えていたものです。

以下は、JDK 22におけるstop-the-world GCの興味深い変更点をいつも通りのまとめです。

Parallel GC

世代別GC中、old-to-young reference (oldオブジェクトからyoungオブジェクトへの参照)は重要なタスクです。Parallel GC(およびSerial GC)はこの目的のためにカード・テーブル (card table) を使用します。まず、ミューテーターが参照を含む可能性のあるカード・テーブル・エントリをdirtyとしてマークします。次に、ポーズ中にアルゴリズムがカード・テーブルをスキャンしてdirtyとマークされたものを探し、oldオブジェクトからyoungオブジェクトへの参照を持つ可能性がある場合、これらのマークで示されたオブジェクトを調べます。

Parallel GCは(その名の通り)、一般的に大きなカード・テーブルを調べる際に、複数の並列スレッドを使用します。カード・テーブルをスキャンするための作業分配メカニズムは、ヒープを64kBのチャンクに分割し、その領域で始まるマークされるカード・オブジェクトはすべて、処理のためにそのスレッドが所有します。

JDK-8310031では、より良い作業配分とパフォーマンス向上につながる2つの最適化を実装しています。

[JDK-8310031] Parallel: Implement better work distribution for large object arrays in old gen
https://bugs.openjdk.org/browse/JDK-8310031

ラージ・オブジェクト配列の内容がパーティションに分割され、今ではラージ・オブジェクト配列を所有するスレッドだけが、そのオブジェクトのold-to-young referenceまでを探すことはなくなりました。以前は1つのスレッドが1GBのラージ・オブジェクトを1個のスレッドで探索することになりかねませんでしたが、カードスキャンに続くキューからの作業分担に基づく追加的な作業分配メカニズムもあるが、この分担は、そもそも複数のスレッドがそのオブジェクトの一部を見ているだけの場合と比べ、相対的に高価です。

1個のオーナースレッドが常に配列の全要素をチェックしていましたが、ダーティ・カードは、その時点ですでに関心の場所を示していました。そのため、処理スレッドは、old-to-young referenceを含まないことがわかっている多くの参照に目を通すことが多かったのですが、これが、ラージ・オブジェクト配列のダーティとマークされた部分のみを処理するスレッドに変わりました。

以前の動作では、場合によってはスレッドのスケーリングが逆になり、例えばG1 GCと比較して非常に長いポーズタイムが発生していました。

Javaヒープ内の配列のラージ・オブジェクトに関連するParallel GCの別のパフォーマンス問題も修正されています。Parallel GCは、オブジェクトの開始点を見つけるために、他のGCと同じブロック・オフセット・テーブル (block offset table) の指数バックスキップを使用するようになり、結果このプロセスと全体的なポーズタイムを短縮しています。

ブロック・オフセット・テーブルは、カード・テーブルでカードの前にあるオブジェクトの開始点を見つける問題を解決します。1つの適用例は、前述のカードスキャン中の場合です。その際GCはJavaオブジェクトの開始点を素早く見つけるか、もしくはその特定のカードに達して、(参照を探している)当該オブジェクトのデコードを適切に開始する必要があります。

すべてのカード(通常、Javaヒープの512バイトを表す)に対して、ブロック・オフセット・テーブル(BOT)エントリがあります。このエントリは、このカードに到達するオブジェクトが、このカードに対応するヒープ・アドレスから何ワード戻って開始されるか、または、その前のカードにはオブジェクトの開始点がなく、アルゴリズムが前のBOTエントリを参照する必要があることを保存します。JDK-8321013 で導入された変更により、このバックスキップ値は、「前のカードを見る」だけから、遡るカード数(2の底の指数として使用)に変更されました。

[JDK-8321013] Parallel: Refactor ObjectStartArray
https://bugs.openjdk.org/browse/JDK-8321013

Block Offset Table Old/New

上図はこの変更点を図示しようとしたものです。上部のJavaヒープ・オブジェクトは、JavaオブジェクトがJavaヒープにどのように配置されるかの例を示しています。中央の図は、エントリを持つBOTと、それらがそれぞれ何を参照しているかを示しています。一部はヒープ内のオブジェクトの開始点を参照していますが、オブジェクトの開始を含まないものは、前のBOTエントリ(バックスキップ値)を参照しています。下図は、新しいBOTエンコーディング(バックスキップ参照のみを示す)による バックスキップ値を示しています。ここでバックスキップ値は必ずしも前のBOTエントリを参照しているわけではなく、オブジェクト内の現在のエントリよりもずっと前のエントリを参照しています。このことは、右端のラージ・オブジェクトを見れば一目瞭然です。このオブジェクトの終端から始端までのアドレスを歩くには多くのステップを必要としますが、新しいエンコーディングでは、GCアルゴリズムは数ステップで済みます。

これによって、ラージオブジェクトのオブジェクト開始点を見つけようとするときのメモリ・アクセスの量が劇的に減り、パフォーマンスが向上します。

Serial GC

JDK-8319373では、JDK-8310031で追加された新しいParallel GCコードに基づいて、Serial GCのカードスキャンコード(ダーティカードの検出)を最適化しています。これにより、ダーティカードがほとんど存在しない場合、youngオブジェクトのGC時間も大幅に短縮されます。

[JDK-8319373] Serial: Refactor dirty cards scanning during Young GC
https://bugs.openjdk.org/browse/JDK-8319373
[JDK-8310031] Parallel: Implement better work distribution for large object arrays in old gen
https://bugs.openjdk.org/browse/JDK-8310031

JDK 14/JEP 363で削除されたConcurrent Mark Sweep (CMS) GCによって同じコードが共有された場合のための使われなくなったコードや抽象化を削除し、Serial GCコードをクリーンアップするために多くの努力が費やされました。

JEP 363: Remove the Concurrent Mark Sweep (CMS) Garbage Collector
https://openjdk.org/jeps/363

G1 GC

JDK 22のユーザーに見えるG1に対する変更は以下の通りです。

G1 は今回(JDK-8140326)、任意のタイプの次回のGCで退避に失敗した領域を回収します。これにより、old genが退避に失敗したリージョンで身動きできなくなる状態からのG1 GCの回復力が向上します。

[JDK-8140326] G1: Consider putting regions where evacuation failed into next collection set
https://bugs.openjdk.org/browse/JDK-8140326

主なユースケースは、リージョンの固定 (region pinning) です。固定されたリージョンを退避させようとすると、影響を受けるリージョンをold genに移動させるという、退避の失敗を引き起こします。リージョンがピン止めされなくなるや否や、このような通常非常に早く回収可能なリージョンを回収する手段がないと、old genのリージョンが大量に蓄積され、より多くのGCや、最悪の場合不要なfull GCを引き起こす可能性があります。

明らかに、これは、メモリ不足のためにオブジェクトをコピーできないことによって引き起こされる、退避失敗リージョンのスペースを回収するのにも役立ちます。

もう一つ、この変更により、G1では、特定のタイプのyoung genのGCだけがold genリージョンでリージョンを回収できるという、以前の(自らに課した)制限がなくなりました。

Garbage Collection Cycle
https://docs.oracle.com/en/java/javase/21/gctuning/garbage-first-g1-garbage-collector1.html#GUID-F1BE86FA-3EDC-4D4F-BDB4-4B044AD83180
https://docs.oracle.com/javase/jp/21/gctuning/garbage-first-g1-garbage-collector1.html#GUID-F1BE86FA-3EDC-4D4F-BDB4-4B044AD83180

現在では、いくつかの要件を満たせば、どのようなyoung genでのGCでもold genリージョンを退避させることができます。

JDK-8318706が統合され、JEP 423が完了したため、G1におけるGCLockerの使用を削除するための長い旅は終わりました。

Evacuation Failure and Object Pinning
https://tschatzl.github.io/2021/06/28/evacuation-failure.html
https://logico-jp.io/2021/09/26/evacuation-failure-and-object-pinning/
[JDK-8318706] Implement JEP 423: Region Pinning for G1
https://bugs.openjdk.org/browse/JDK-8318706
JEP 423: Region Pinning for G1
https://openjdk.org/jeps/423

要するに、以前は、JNIとのインターフェイス時にアプリケーションがGet/ReleasePrimitiveArrayCriticalメソッドで配列にアクセスしても、GCは発生しませんでした。この変更により、GCアルゴリズムが変更され、これらのオブジェクトを「固定」し、対応するリージョンをそのようにマークすることで、これらのオブジェクトを所定の位置に保持しつつ、固定された領域内の他の領域や非プリミティブ配列の退避を許可します。後者の最適化が可能なのは、Get/ReleasePrimitiveArrayCriticalが、非プリミティブ配列オブジェクトのみをロックできるためです。

これにより、G1を使用したJNIコードが原因でJavaスレッドがストールすることはなくなりました。

JDK-8314573では、Remarkポーズ中のヒープ・リサイジングに若干の変更があり、リサイジングの一貫性が少し向上しました。

[JDK-8314573] G1: Heap resizing at Remark does not take existing eden regions into account
https://bugs.openjdk.org/browse/JDK-8314573

ヒープ・リサイジングでは、Eden領域を考慮せずに、-XX:Min/MaxHeapFreeRatioに基づいて計算されるようになりました。Remarkポーズはミューテーターフェーズ (mutator phase) 中にいつでも発生する可能性があるため、以前の動作ではヒープサイズの変更がその時点のEdenの占有率に大きく依存していました(つまり、Remarkポーズが発生したときにアプリケーションがミューテーターフェーズにどれだけ入っているかによって、計算に使用される空き領域の量が大きく異なり、ヒープのサイズ変更が異なる可能性があります)。

この変更により、より決定論的で、一般的にあまり積極的でないヒープ・サイジングになります。

このリストを、実際の直接的なパフォーマンス改善で締めくくりましょう。リージョンのコードルートセット (code root set) 、つまりコンパイル済コードのルートは、以前はGC中にリージョンごとに1つのスレッドで取り扱っていました。コードルートセットが非常にアンバランスな(単一または少数のリージョンに埋め込まれた参照を持つコードが多い)場合、この作業でGCがストールする可能性がありました。JDK-8315503では、G1がリージョン内でも複数のスレッドにコードルート・スキャン作業を分散するようになり、この潜在的なボトルネックが取り除かれました。

[JDK-8315503] G1: Code root scan causes long GC pauses due to imbalanced iteration
https://bugs.openjdk.org/browse/JDK-8315503

All STW GCs

Loomは、JDK-8290025でコード・キャッシュ・スイーパーを削除する必要がありました。STWコレクターの場合、その作業は適切なポーズへの異動でした。

JEP 444: Virtual Threads
https://openjdk.org/jeps/444
[JDK-8290025] Remove the Sweeper
https://bugs.openjdk.org/browse/JDK-8290025

残念なことに、コード・キャッシュ・スイーパーのジョブの中には、O(n^2)の実行時間を持つコンポーネントがありました。これは、コード・キャッシュ・スイーパーがアプリケーションと同時並行で仕事をする限り、それほど大きな問題ではありませんでしたが、削除後、多くのコンパイル済コードをアンロードするときに、顕著なポーズ時間の増加を引き起こしました。JDK-8317809、JDK-8317007、JDK-8317677、およびその他のいくつかのクラスでは、ポーズ中のクラス・アンロードが、コード・キャッシュ・スイーパーが削除される前よりも高速になりました。

[JDK-8317809] Insertion of free code blobs into code cache can be very slow during class unloading
https://bugs.openjdk.org/browse/JDK-8317809
[JDK-8317007] Add bulk removal of dead nmethods during class unloading
https://bugs.openjdk.org/browse/JDK-8317007
[JDK-8317677] Specialize Vtablestubs::entry_for() for VtableBlob
https://bugs.openjdk.org/browse/JDK-8317677

What’s next

JDK 23に向けた作業はすでに始まっています。おそらく最も興味深い今後の変更は、以下のJEPドラフトでしょう。

[JDK-8322295] Late G1 Barrier Expansion
https://bugs.openjdk.org/browse/JDK-8322295

GCの観点からは、これによりC2の専門家でない開発者がバリア生成をより利用しやすくなり、より簡単にいじれるようにするものです。

JDK23のタイムフレームで取り組む可能性があるもう1つの興味深いトピックは、クラスのアンロード時間のさらなる短縮です。これはコードの完全だけでなく、G1では、その一部をコンカレント・フェーズに移行させることによっても実現しようと考えています。

さらに詳しいことは、次の数ヶ月で発表する予定です。

Thanks go to…

素晴らしい次回のJDKリリースに貢献してくれたみなさま、また次のリリースでお会いしましょう。

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください