原文はこちら。
The original article was written by Thomas Schatzl (OpenJDK developer, Oracle).
https://tschatzl.github.io/2021/06/28/evacuation-failure.html
JDK 16によるHotspot VMの改良についての投稿の最後に、オブジェクト・ピンニング(object pinning)のための準備が追加されたことを述べました。
JDK 16 G1/Parallel GC changes
https://tschatzl.github.io/2021/03/12/jdk16-g1-parallel-gc-changes.html
https://logico-jp.io/2021/04/01/jdk-16-g1-parallel-gc-changes/
この件について質問を受けたので、もう少し詳しく説明したいと思います。特に、問題点、解決策の可能性、どこから作業を始めればよいのかについて話します。
この記事では、オブジェクト・ピンニングとは何か、G1でのオブジェクト・ピンニングの状況、現在提案されている実装のベースとなっている退避失敗のメカニズムを改善するための提案を簡単に説明します。これにより、将来的にはG1のアップストリームでオブジェクト・ピンニングが可能になるかもしれません。
JNI Critical Sections and Why the GCLocker Is Bad
このセクションで紹介されている情報の多くは、こちらのA.Shipilev氏の文章を要約したものです。より詳しい背景を知りたい方はそちらをお読みください。
JVM Anatomy Quark #9: JNI Critical and GC Locker
https://shipilev.net/jvm/anatomy-quarks/9-jni-critical-gclocker/
要するに、JNI API関数Get<PrimitiveType>Array*
のファミリーがあり、これを使うとCのコードでJavaヒープ上のオブジェクトへのポインターを取得できます。Cコードがアクセスしている間、ガベージコレクターは当然これらのオブジェクトを動かしてはいけません。これらのオブジェクトを処理する方法にはいくつかのオプションがあります。現在、Shenandoahを除く、オブジェクトを移動するすべてのホットスポット・ガベージコレクターは、その種のオブジェクトがある間、GCを無効にすることで、この状況に対処しています。
記事を引用してみます。
If GC was attempted, JVM should see if anybody holds that lock. If anybody does, then at least for Parallel, CMS, and G1, we cannot continue with GC. When the last critical JNI operation ends with “release”, then VM checks if there are pending GC blocked by GCLocker, and if there are, then it triggers GC. This yields “GCLocker Initiated GC” collection.
https://shipilev.net/jvm/anatomy-quarks/9-jni-critical-gclocker/
(GCを試みた場合、JVMは誰かがそのロックを保持しているかどうかを確認する必要があります。もし誰かが持っていれば、少なくともParallel、CMS、G1については、GCを続行できません。最後の重要なJNI操作が “release “で終わると、VMはGCLockerがブロックしていた保留中のGCがあるかどうかをチェックし、もしあればGCを開始します。これにより、”GCLocker Initiated GC “コレクションが生成されます。)
これは問題でしょうか?Noでもあり、Yesでもあります。
No | – JNI仕様ではネイティブコードが Release<PrimitiveType>Array* を呼び出す前にネイティブコードを長時間実行させないことを提案しており、JNI実装ではこれを前提としている。そのため、この状況はそれほど発生しないはず。– JNI実装ではロックオブジェクトの個数が小さいことも前提としている。 |
Yes | それでもGCLockerを待つアプリケーション全体のストールが発生する可能性がある。 |
(現在のGCLockerメカニズムの実装では、JDK-8192647で議論されているように、VMの失敗を引き起こす問題があります)。
[JDK-8192647] GClocker induced GCs can starve threads requiring memory leading to OOME
https://bugs.openjdk.java.net/browse/JDK-8192647
仕様では、(コピーやガベージコレクションを防止しない場合)このオブジェクトを所定の位置に保持するためのメモリ管理しか要求していないため、選択肢としては、所定の位置に留まる必要のあるJavaオブジェクトを所定の位置に保持し、その周囲の空間を後続の異なる粒度での割り当てのために解放することが挙げられます。
理想的には、ガベージコレクターはこれらのオブジェクトだけを所定の位置に保持し、それらを囲む他のすべてのものを退避させることです。問題は、このせいで後続の割り当てが著しく複雑になってしまう点です。
リージョンを使うGC(G1、Shenandoah、ZGC)は、すでにヒープの一部(リージョン)の退避をサポートしています。そのため、これらのGCでの簡単な解決策は、そのようなオブジェクトを含む領域を、他のすべてのライブオブジェクトと一緒に退避させないことです。これにより、問題はオブジェクト・ピンニングからリージョン・ピンニングに変わり、Javaヒープに割り当てられないメモリが増えるという犠牲を払いつつも、はるかに簡単になります。
シリアルGCやパラレルGCのようなリージョンを使わないGCのための良い解決策はありません。少なくとも私は存じ上げておりませんので、これらのGCではおそらく永遠にGCLockerを使い続けるでしょう。
その上、このテクニックはリスクなしというわけではありません。ほとんどの場合、アプリケーションを停止させるよりはましですが、ヒープ全体をピン止めしてVMの障害を引き起こすリスクがあります。このリスクはどの程度のものでしょうか?データが少なすぎるのですが、私の知る限り、ShenandoahはGCLockerのメカニズムを追加していませんでした。
G1 and Object Pinning
G1はすでにリージョンピンニングを原理的に完全にサポートしています。実際、G1は巨大なオブジェクトのために、当初からそれをサポートしています。巨大な物体は決して動きません。このメカニズムをold generationのどんなリージョンにも拡張できます。実際、リージョンが「巨大」であることと「ピン留め」されているという概念は、長い間別のものでした。G1では現在、Class Data Sharing(クラスデータ共有)アーカイブのオブジェクト・グラフを含むリージョンに対しても、リージョン・ピンニングを使用しています。
ここまでは、G1のold generationに位置するリージョンについてでしたが、young generationのピン止めされたリージョンについてはどうでしょうか?
G1におけるEvacuation Failureの処理では、コピーできなかったためにその場に留まる必要のあるオブジェクトを処理するメカニズムがすでに用意されています。これらの(生存している)オブジェクトは、GC時に特別にマークされ、GCポーズの終わりに、これらのリージョンを修正するための特別なフェーズがあります。
では、以下のパッチで提案されているようにスイッチを入れれば、仕事が終わり、と言えるのでしょうか?そうはいきません。
Full pin Support
https://github.com/openjdk/jdk/compare/master…tschatzl:full-pin-support
Evacuation Failure Handling Analysis
退避失敗時の処理は、「単なる」退避に比べてかなり時間がかかります。ガベージコレクション中に追加のフェーズが必要なことからも、そのことがわかるかもしれません。避難失敗は滅多に起こらないと仮定しているため、実装は他の部分ほど最適化されていません。そのため、ポーズ時間への直接的な影響や、アプリケーションの動作への間接的な影響については、ほとんど考慮されていません。
退避失敗時の処理は、「単なる」退避に比べてかなり時間がかかります。ガベージコレクション中に追加のフェーズが必要なことからも、そのことがわかるかもしれません。避難失敗は滅多に起こらないと仮定しているため、実装は他の部分ほど最適化されていません。そのため、ポーズ時間への直接的な影響や、アプリケーションの動作への間接的な影響については、ほとんど考慮されていません。
直接的な影響はポーズ時間で、間接的な影響はこれらのリージョンに発生することです。現在、ヒューリスティックは、退避に失敗したオブジェクトを1つ持つすべてのyoung generationのリージョンを、文字通りold generationのリージョンに変換します。これらのリージョンは記憶集合を持っていないので、次回のマーキング・サイクルでのみ収集されます。これにより、Javaのヒープはあっという間にいっぱいになります。
ともかく、ポーズ中の避難失敗処理のための追加作業は以下の通りです。
During evacuation
G1 は、コピーできるスペースが見つからないためにオブジェクトを退避させることができなかったことに気づいた場合、そのオブジェクトとそれを含むリージョンを退避に失敗したものとしてマークします。G1は、そのオブジェクトが退避に失敗したことを示すオブジェクトのマークワードを特別な値で上書きし、必要に応じて古い値を保存します(G1ParScanThreadState::handle_evacuation_failure_par()
)。
After evacuation
G1はこれらの保存したマークに再び戻します。このタスクは通常は問題になりません。なぜなら、ロックされたオブジェクトやハッシュ値を持つオブジェクトのみを保存する必要があり、これらは通常、(RestorePreservedMarksTask
work gang タスクを使用して)退避したすべてのオブジェクトのセットと比較して少ないからです。
G1は、これらのyoungリージョンをoldリージョンに昇格させます。これはかなりの量の作業を必要とします(RemoveSelfForwardPtrHRClosure::remove_self_forward_ptr_by_walking_hr()
)。
- これらの生き残ったオブジェクトの間にある死んだオブジェクトを上書きし、これらの領域を直線的に歩く人が死んだ退避したオブジェクトを見つけないようにする必要がある。そのためには、Block Offset Tableがないので、1つのスレッドが、死んでいるか生きているかわからないオブジェクトをすべて直線的に歩き、生きているオブジェクトの間にダミーのオブジェクトを埋めていく。
- オブジェクトの開始位置を高速に特定するために使用される、Javaヒープ上にある非常に重要なサイドテーブルのBlock Offset Tableを作成する必要がある。
- 記憶集合の並行改良をセットアップして、記憶集合を必要とするリージョンへの参照のためにこれらのオブジェクトをスキャンする。
つまり、基本的には、G1のような世代型GCがyoung generationのために行う最適化を元に戻す必要があるのです。世代並行型のGCは、おそらくこのような作業をすべて並行して行うので、ポーズにはならず、コードが複雑になるだけだと思います。
結局のところ、これは問題ではないのかもしれませんが、それが問題である(ない)ことを示すには、すでに行われた以上の測定が必要です。
Where Can I Help?
一般的に、初期のテストでは、JNIのためにピン留めされたリージョンの量はそれほど多くありませんが(ほんの数個のリージョン)、処理される必要のあるオブジェクトの量は、ほんの数個のオブジェクトから多くのオブジェクトまでと、大きく変動する可能性があります。これは多くの無関係なオブジェクトが所定の位置に留まる必要のあるリージョンに存在する、単一のオブジェクトによるためです。
Oracleのガベージコレクションチームでは、既存の退避失敗処理を改善する方法についてブレーンストーミングを行いました。
以下は提案内容のリスト(注釈付き)です。
[JDK-8254738] G1: Improve generation placement heuristics for regions that could not be evacuated https://bugs.openjdk.java.net/browse/JDK-8254738 | リージョンのoldへの昇格を避ける : これにより、上述の修正作業のほとんどを行う必要がなくなる。特に、ヒープ内のすべてのオブジェクトを辿らなくても、生存オブジェクトの位置にアクセスできる場合はなおさらである。長期間固定されているリージョンをいつまでもyoung generationに残しておかないようにするには、何らかのエイジングのヒューリスティックを考える必要がある(例えばリージョンの年齢とか)。これを変更するには、現在、リージョンの粒度でyoung generationのサイズを決定しているyoung generationサイズ決定ポリシーの考慮が必要(JDK-8254738)。 |
[JDK-8140326] G1: Consider putting regions where evacuation failed into next collection set https://bugs.openjdk.java.net/browse/JDK-8140326 | 上記の提案の拡張として、退避に失敗したリージョンを、次のGCで退避させるためのコレクションセットに入れることを検討している。結局のところ、実際にピン留めされた数個のオブジェクトのためだけにリージョン全体を存続させたにすぎない(JDK-8140326)。これは確かにold generationリージョンにしか適用されない。もしそのリージョンがまだyoung generationにあれば、次のGCの時点で自動的に退避される。 |
[JDK-8254896] G1: Rebuild remembered sets for some regions during full gc https://bugs.openjdk.java.net/browse/JDK-8254896 | フルGCの間に、いくつかのリージョン(ほぼ空のピン留めされたリージョンなど)の記憶集合を再構築し、通常のGCですぐに収集できるようにする(JDK-8254896)。 |
[JDK-8254739] G1: Optimize evacuation failure for regions with few failed objects https://bugs.openjdk.java.net/browse/JDK-8254739 | 少数の生存オブジェクトを持つリージョンの退避失敗処理を最適化:1つのオプションとして、少なくとも最初はリージョン内(の一部)のすべての生存オブジェクトを追跡する。その後、このリストを使って生存オブジェクトを素早く発見する。まだどこかにこのためのプロトタイプがあるかもしれない(JDK-8254739)。 |
[JDK-8256265] G1: Improve parallelism in regions that failed evacuation https://bugs.openjdk.java.net/browse/JDK-8256265 | 失敗したリージョンでの並列性の向上:現在、1つのスレッドが1つのリージョンの退避失敗処理を引き継ぐが、これにより事実上そのフェーズが直列化されている。1つのオプションは、リージョン内の潜在的なエントリーポイント/生存オブジェクトの開始を記録し、複数のスレッドがリージョンの異なる部分で作業できるようにすることである。なお、Block Offset Tableは存在しないため、使用すべきではない(JDK-8256265)。 |
[JDK-8254167] G1: Record regions where evacuation failed to provide targeted iteration https://bugs.openjdk.java.net/browse/JDK-8254167 | 上記の強化は、どのリージョンが退避失敗の影響を受けているかを実際に保存するための変更である。現在、G1ではすべてのスレッドが失敗したリージョンをリージョンテーブル全体から検索しているが、リージョン数が多いとそれだけで時間がかかってしまう(JDK-8254167)。 |
これらの提案はすべて退避失敗の処理を改善するもので、退避失敗を誘発すればテストできます。そして最後に、JDK-8236594のコードを追加し、必要なリファクタリングを行うことで、実際にオブジェクト・ピンニングを有効にするという問題があります。
[JDK-8236594] G1: Provide object pinning
https://bugs.openjdk.java.net/browse/JDK-8236594
この分野でのより大規模な概念的変更は、これらのリージョンの作業をポーズの外に移すことになるかもしれません。例えば退避でポインタの転送にオブジェクト・ヘッダーが使われなければ、これらの領域は同時に処理され、上述のすべての作業も同時に行われます。
Conclusion
G1ではすでにオブジェクトのピン留めをサポートしていますが、性能上の問題から、まだ準備ができていません。何から始めればいいかと聞かれたら、これらの提案のうち、最後の3つが手始めにやってみるには最も簡単かと思います。
何か質問があれば、直接おたずねください。できればhotspot-gc-devメーリングリストにお願いします。
それではごきげんよう。