原文はこちら。
The original article was written by Thomas Schatzl (OpenJDK developer, Oracle).
https://tschatzl.github.io/2023/03/14/jdk20-g1-parallel-gc-changes.html
予定通りにまもなくJDK 20が一般提供されます。
JDK 20
https://openjdk.java.net/projects/jdk/20/
JDK 20リリースでのHotspotのstop-the-world GCの変更と改良をまとめる機会です。
このリリースにはGC関連のJEPは含まれていませんが、Generational ZGC (世代別ZGC) のJEPはつい最近Candidateステータスに達したので、もしかしたらJDK 21には間に合うかもしれません。
New candidate JEP: 439: Generational ZGC
https://mail.openjdk.org/pipermail/jdk-dev/2023-March/007457.html
JEP 439: Generational ZGC
https://openjdk.org/jeps/439
その他、JDK 20のHotspot GCサブコンポーネント全体の変更点一覧はこちらからどうぞ。合計で約220の変更点が解決またはクローズされたことがわかります。
Parallel GC
Parallel GCの唯一の顕著な改良点は、Full GC中にコンパクション領域を横断するオブジェクトの処理の並列化です (JDK-8292296)。
[JDK-8292296] Use multiple threads to process ParallelGC deferred updates
https://bugs.openjdk.org/browse/JDK-8292296
これらのオブジェクトを反復して修正するシングルスレッドのフェーズを最後に持つ代わりに、ワーカースレッドのローカルコンパクション領域を横切るオブジェクトをワーカースレッドが自身で収集、処理します。コントリビュータのM. Gassonは、特定のケースでフルGCの休止時間が20%短縮されたことを報告しています。
8292296: Use multiple threads to process ParallelGC deferred updates #10313
https://github.com/openjdk/jdk/pull/10313
Serial GC
Serial GCには顕著な変更点はありませんでした。Serial GCのコードをクリーンアップするかなり多くの変更が入っています。
G1 GC
JDK 20には、大まかに言って前回のレポートで紹介した長い「What’s next」リストのすべての項目、およびそれ以上のものが入っています。
JDK-8210708
Javaヒープ全体に及ぶマークビットマップの1つを削除することにより、G1ネイティブメモリーフットプリントをJavaヒープサイズの1.5%程度削減しました。
[JDK-8210708] Use single mark bitmap in G1
https://bugs.openjdk.java.net/browse/JDK-8210708
G1のConcurrent Markingのブログ記事に、この変更に関する徹底的な議論が記載されています。
Concurrent Marking in G1
https://tschatzl.github.io/2022/08/04/concurrent-marking.html
https://logico-jp.io/2022/09/17/concurrent-marking-in-g1/
また、この投稿では、オリジナルのG1論文にある同時実行マークに関する情報を削除しています。この論文には、現在のG1について正確な情報があまり残っていません。
Garbage-First Garbage Collection
https://cs.williams.edu/~dbarowy/cs334s18/assets/p37-detlefs.pdf
JDK-8295118
実はこのブログの記事も、ある意味すでに時代遅れなのです。JDK-8295118では、Clear Claimed Marks
と呼ばれるコンカレントマーキングの準備にかかる時間が長い部分を、コンカレントスタートのガベージコレクション休止期間から外しています。
[JDK-8295118] G1: Clear CLD claim marks concurrently
https://bugs.openjdk.org/browse/JDK-8295118
Concurrent Clear Claimed Marks
という新しいフェーズは、gc+marking=debug
としてログに表示されるようになります。
JDK-8256265
G1での将来のregion pinningサポートに備え、JDK-8256265は、ユーザーによって固定された(またはJavaヒープにスペースが残っていないため退避できなかった)リージョンを処理する際の並列化粒度を低下させました。
[JDK-8256265] G1: Improve parallelism in regions that failed evacuation
https://bugs.openjdk.org/browse/JDK-8256265
JEP 423: Region Pinning for G1
https://openjdk.org/jeps/423
スレッド単位でリージョン全体を渡す代わりに、タスクの粒度はリージョンの一部となりました。
これにより、スレッドがうまく仕事を共有できるようになり、非生産的な完了待ちが大幅に減少しました。
JDK-8137022
JDK-8137022では、refinementスレッドの制御がより適応的になりました。
[JDK-8137022] Concurrent refinement thread adjustment and (de-)activation suboptimal
https://bugs.openjdk.org/browse/JDK-8137022
以前は、GCポーズ中に、G1がミューテーター時間に特定のrefinementスレッドが活性化(および非活性化)される閾値を離散的に計算し、refinementを支援していました。この計算は、GCポーズ中にユーザーがカードのrefineに費やしたい許容時間(オプション -XX:G1RSetUpdatingPauseTimePercent
で設定)、次回ポーズまでの直近の間隔と多くの魔法に基づいています。
アプリケーションを直接観察していないため、直近の着信スレッドとrefinementスレッドの処理速度や、次回のGCまでの予想残り時間を考慮すると、refinementスレッドの制御は、バースト的に必要以上に起きて仕事をするように強く偏っていましたが、これはGCポーズのために残された仕事が多すぎないようにしていたことが原因でした。この動作は、refinementスレッドのCPUサイクルを浪費するだけでなく、refineされないままJavaヒープに残るカードがほとんどないという別の欠点がありました。これは一見良さそうに思いますが、あるレベル以下では、これはやや逆効果になることがあります。カードが後の処理のためにキューに残っている場合に比べ、writeバリアは、ここ(日本語はこちら)で説明しているように、新しいunvisitedカードがあるたびに、多くの作業を行う必要があるのです。
refinementスレッドによる追加の活動によってCPUリソースが奪われるだけでなく、G1はポーズに残された作業をあまり減らさずに、同じカードを繰り返しrefineするのです。
ミューテーターの活動を考慮した変更により、G1はrefinementスレッドの活動をポーズ中にうまく分配し、より多くのカードをrefinementタスクのキューに長く残すことで、新たなカードの生成率を下げ、ユーザーの意図(つまり、-XX:G1RsetUpdatingPauseTimePercent
)をより確定的に達成できるようになります。最終的には、アプリケーションから奪うCPUサイクルが減り、より良いスループットを提供できるようになります。
G1 GCのオプションのうち、古いrefinement制御に関連するオプションが廃止されています。使用するとVMが起動時に終了するようになりました。リリースノートに詳細の記載があります。
[JDK-8295819] Release Note: Improved Control of G1 Concurrent Refinement Threads
https://bugs.openjdk.org/browse/JDK-8295819
JDK-8288966
G1は、スレッドローカルアロケーションバッファ(PLAB)を使用して、GCポーズ中の同期オーバーヘッドを削減します。PLABは、最近の割り当てパターンに基づいてサイズが変更され、GCポーズ終了時にこれらのバッファの未使用領域を削減します。割り当ての必要性が低い場合は縮小され、そうでない場合は拡大されます。この収集休止ごとの適応は、GC間にかなりコンスタントに割り当てがあるアプリケーションではうまく機能しますが、割り当てが非常にバースト的である場合、うまく機能しません。GC中にほとんど割り当てがない場合、数回のGCでPLABが大きすぎてスペースを浪費したり、割り当てが急増したときに小さすぎてPLABを再ロードするCPUサイクルを浪費することになります。
[JDK-8288966] Better handle very spiky promotion in G1
https://bugs.openjdk.org/browse/JDK-8288966
いくつかのプラットフォームでは、そのために数秒の範囲で非常に長い休止スパイクが発生することがわかりました。このような状況に対処するため、JDK-8288966の変更で、GC時にPLABを適度かつ積極的にサイズ変更するようにしました。
GC G1 prediction
JDK 20では、かなりの量の努力を費やして、GC所要時間の最終的な原因となるyoung世代のサイズ決定に使用される予測を改善しています(JBSのクエリはこちら)。
より良い予測により、G1がポーズ時間の目標(-XX:MaxGCPauseMillis
で指定)をより一層順守します。これは、ポーズ時間のオーバーシュートを減らし、GCごとにより多くのyoung世代のリージョンを使用することで利用可能なポーズ時間の目標の使用量を増やすことにつながります。これにより、確かに許容される目標内でポーズ時間が増加しますが、GC回数が大幅に減少する可能性があります。これらの変更により、アプリケーションによっては、GCポーズ時間が10~15%短くなりました。
JDK-8293861
JDK 20では、JDK-8293861で予防的GC (preventive garbage collections) をデフォルトで無効にしています。
[JDK-8293861] G1: Disable preventive GCs by default
https://bugs.openjdk.org/browse/JDK-8293861
予防的GCは、GC中にG1がオブジェクトを退避させるのに十分なJavaヒープメモリを持たない(退避失敗、evacuation failureとも呼ばれます)状況を避けるためにJDK 19で導入されました。退避失敗が発生したリージョンの処理は従来からかなり時間がかかっていました。そのため、こうした退避失敗を完全に回避するために十分なメモリを解放することを期待して、先手を打って退避失敗をしないGCを行う方がよいというのがこの機能の論拠でした。
問題は、この状況をどのように正しく予測するかという点です。G1が予防的GCを開始するかどうかを判断するために用いた予測は最適でないことが判明し、不必要に、かつ早すぎる時期に予防的GCを開始することが多数ありました。これでは時間を無駄にしてしまいますし、また、予防的GCが開始されず、アプリケーションに待避失敗が発生するケースも多く見受けられました。最後に、このようなGC (=予防的GC) ではGCがより不規則になり、発生した場合には一般に予測が難しくなっていました。
JDK-8297247
G1 Concurrent GC
という新しいGarbageCollectorMXBean
がJDK-8297247で導入されました。
[JDK-8297247] Add GarbageCollectorMXBean for Remark and Cleanup pause time in G1
https://bugs.openjdk.org/browse/JDK-8297247
これは、G1 RemarkおよびCleanupポーズの発生と継続時間を報告します。これらのポーズでは、G1 Old Gen
のMemoryManagerMXBean
のメモリプール情報も更新されます。
まとめると、JDK 21で後からでもアップグレードする価値のあるガベージコレクションが大幅に追加されていると思います。
What’s next
すでにJDK 21に向けた作業は本格的に始まっています。ここでは、すでに統合されている、あるいは現在開発中の興味深い変更点を簡単に紹介します。例によって、それらが次のリリースに反映される保証はありませんが、すでに統合されたものは残る可能性が高いです。
JDK-8225409
refinementスレッド制御を改善後、JDK-8225409でHot Card Cacheを削除しています。
[JDK-8225409] G1: Remove the Hot Card Cache
https://bugs.openjdk.org/browse/JDK-8225409
このデータ構造は、refinementが強すぎるため、カードを長くrefineしないでおくことが有利になるという、前述の問題を回避するためのものでした。このメカニズムでは、G1はすべてのカードについて、このミューテーターサイクルで何回refineされたかというカウンターを保持します。そのカウントが閾値を超えた場合、カードはrefineされず、オーバーフローのためにそこから待避するか、GC発生まで、小さな固定サイズのリングバッファに保管されます。
上記のJDK20のrefinementスレッドの制御変更後は、Hot Card Cacheの影響は検出されませんでした。現在のrefinement制御は、Hot Card Cacheの機能を包含するために、絞り込みに対して十分すぎるほど怠慢であるように思えます。
これにより、ネイティブメモリのJavaヒープサイズの0.2%が他の用途に解放されます。
8191565: Last-ditch Full GC should also move humongous objects #12830
リージョンレベルの断片化が大きい場合のG1の動作を改善するための作業が進行中です。現在、G1がFull GCしても、巨大なオブジェクトの割り当てに必要な連続したメモリ範囲を見つけられない場合、全体としては十分なメモリが利用可能になっていても、VMはOutOfMemoryException
で終了します。このPRでは、「最後の手段」であるG1 Full GCの動作を変更し、より連続したメモリを作成するために巨大なオブジェクトも移動させるようにします。これにより、多くの場合、Full GCが長くなるのと引き換えに、OutOfMemoryException
を回避できるはずです。
8191565: Last-ditch Full GC should also move humongous objects #12830
https://github.com/openjdk/jdk/pull/12830
最後の手段 (last-ditch)のG1 Full GCは、通常のG1 Full GCが行われた直後に発生するため、このGCの長さは、VMの障害を回避するためのトレードオフとして受け入れられると思われます。
JDK-8140326
待避に失敗した(または将来的に固定される)リージョンがOld世代を押し流すことに対するG1の回復力を向上させるために、G1が生成後できるだけ早く、任意のGCでこれらの古いOldリージョンを避難させることを可能にする作業が進行中です。
[JDK-8140326] G1: Consider putting regions where evacuation failed into next collection set
https://bugs.openjdk.org/browse/JDK-8140326
待避失敗したリージョンに対する現在のポリシーは、それらをOldリージョンにするというもので、これはG1は以後Oldリージョンに割り当てできないことを意味します。これらはまた、記憶集合 (remembered sets) を持たないので、それらからスペースを取り戻すには、次の同時マーキングが完了するのを待つしかありません。多くのリージョンが待避に失敗し、複数のGCにまたがる可能性がある場合、Javaヒープはこのような非常に占有率の低いリージョンですぐにいっぱいになってしまうのです。これは、full GCを簡単に引き起こす可能性があります。
この変更により、young世代だけのGCはold世代リージョンを決してGCしないという以前の仮定を完全に排除します。しかし技術的には、G1はすでにJDK 8u65からOld世代リージョンであるいくつかの巨大なオブジェクトを収集しようとしていたので、その仮定は長い間厳密には保持されていなかったんですが…。
JDK-8297639
予防的GCの予測を改善する試みがいくつか失敗し、待避失敗したリージョンの処理にはほとんどオーバーヘッドがなく、上記の作業によってさらに少なくなることから、JDK-8297639でこの機能を完全に削除することを決定しました。GCを増やしても、メリットはなく、多くの問題を引き起こすしますからね。
[JDK-8297639] Remove preventive GCs in G1
https://bugs.openjdk.org/browse/JDK-8297639
今後数ヶ月の間に追加があるでしょう。
Thanks go to…
今回の素晴らしいJDKリリースに貢献してくれた皆さん、ありがとうございます。次の(LTS)リリースでお会いしましょう。