OpenJDK 16のShenandoahガベージコレクション:並行参照処理

Red Hat で Solution Architect として OpenJDK を担当している伊藤ちひろ(@chiroito)です。

この記事は、Red Hat Developerのブログ記事、Shenandoah garbage collection in OpenJDK 16: Concurrent reference processing | Red Hat Developer の翻訳記事です。


https://developers.redhat.com/sites/default/files/styles/article_feature/public/ST-java1_1x.png?itok=4G5hPyg8

OpenJDKにおけるShenandoahガベージコレクション (GC) プロジェクトの主の動機は、ガベージコレクションの停止時間を短縮することでした。参照処理は、伝統的にGCの停止の主な原因の1つでした。この関係はほぼ一次式です。つまり、アプリケーションがより多くの参照を処理すればするほど、ガベージコレクションの停止と遅延への影響は大きくなります。ここで重要なのは「処理」、つまりGCサイクルごとにどれだけの参照を処理する必要があるかということです。決して死なない参照を持つ参照や、参照自体と一緒に死滅する参照は問題ではありません。

私自身、過去に「遅延を気にするなら、ソフト、弱、ファントムの参照やファイナライズを量産しない方がいい」と勧めたことがあります。今回の記事では、過去に参照処理がJavaのガベージコレクションの停止の原因となった理由と、JDK16で参照処理を並行にすることでどのようにその問題を解決したかを紹介したいと思います。

TL;DR: もしあなたのアプリケーションがソフト、弱、ファントムの参照やファイナライズを処理しているなら、Shenandoah GCの並行参照処理を備えたJDK 16は、あなたのアプリケーションの遅延を大幅に改善するかもしれません。

参照の要約

ここでいう参照とは、Javaのオブジェクトである java.lang.ref.Reference 型と、そのサブタイプである SoftReference, WeakReference, PhantomReference, FinalReference を指します(最後の型については後述します)。オブジェクト間の通常の参照は強参照とも呼ばれます。各参照は1つの参照を指します。さまざまな種類の参照の目的は、オブジェクトを参照できるようにすることですが、そのオブジェクトが参照によって再利用されないようにすることはできません。再利用は到達可能性のルールに従っており、大まかに以下のように規定されています(到達可能性が低い順)。

  • SoftReference: 強参照によって到達できなくなった時点で、その参照を回収されます。回収はさらに他の発見的手法に従います。例えば、通常、ソフト参照は、メモリ圧が高くない限り、回収されないと考えられています。このため、ソフト参照は、メモリに敏感な(キャッシュ)実装に適しています。どちらかというと悪い選択ですが、とはいえ選択です。GCは、メモリが逼迫しているときにソフト参照をクリアします。
  • WeakReference: この参照は、強参照やソフト参照によって到達できなくなった時点で、回収されます。回収されていない限り、その参照はまだアクセスでき、その時点で再び強く到達可能になります。
  • FinalReference: これはJDK内部の参照型なので、知らない人も多いでしょう。これは、Object.finalize()を実装するために使用されます。finalize()を実装したオブジェクトはすべてFinalReferenceの参照先となり、対応するReferenceQueueに登録されます。そのオブジェクトが強、ソフト、または弱参照によって到達できなくなるとすぐに、そのオブジェクトは処理され、その finalize() メソッドが呼び出されます。
  • PhantomReference: 参照は、強参照、ソフト参照、弱参照、ファイナル参照のいずれによっても到達できなくなった時点で、言い換えれば、適切に到達できなくなった時点で、回収できます。参照元には決してアクセスできません。これは、到達不可能と判断された後に、参照元が誤って復活しないようにするためです。参照元の到達可能性が決定されるとすぐに、到達不可能な参照元はクリア(nullに設定)され、対応する参照オブジェクトは、アプリケーションによる更なる処理のために、それぞれのReferenceQueueにエンキューされます。ファントム参照は通常、ネイティブメモリやオペレーティングシステムのファイルハンドルなど、GCで処理できないようなリソースを管理するために使用されます。

伝統的な参照の処理方法

JDK16以前のバージョンでは、Shenandoahは前節の到達可能性ルールに忠実な方法で参照を発見し、処理します。

  • 最初に、並行マーキング中のすべての強く到達可能なオブジェクトの到達可能性を決定します。マーキングの波面が、まだマークされた参照先を持たない参照オブジェクトに到達すると(つまり、どこか他の場所から強く到達できないオブジェクト)、すぐにそこで停止し、後の処理のために4つの発見キューの1つに参照をエンキューします。参照タイプごとに1つの発見キューがあります。特殊なケースとして、ソフト参照があります。GCは、発見的手法(例えば、メモリ圧やオブジェクトの年齢)に基づいて、ソフト参照を強参照のように扱うかどうかを決定できます。

  • 次に、並行(強い)マーキングが完了すると同時に、JVMはアプリケーションを停止し、発見キューの処理を開始します:

    1. すべての SoftReference オブジェクトが検査されます。参照先が強く到達可能でなければ(つまり、並行マーキングでマークされていれば)、それはクリアされ、そのSoftReferenceは処理キューに入れられます。
    2. すべての WeakReference オブジェクトが検査されます。参照元が強く到達可能でなければ,それはクリアされ,そのWeakReferenceは処理キューに入れられます.
    3. すべての FinalReference オブジェクトが検査されます。参照元が強く到達可能でない場合,FinalReference は処理キューに入れられますが,GC が finalize() メソッドを呼び出すことができるように,後でまだ必要とされるので,参照元はまだクリアされません.また,ここで何か特別なことが起こります:他の方法では到達できない参照元から始まって,マーキングが再開され,参照元から見つかったオブジェクトの部分的なグラフがマークされます.これは次のステップで重要なことで、ファイナライズから到達可能な PhantomReference オブジェクトの回収を避けるためです。この追加のマーキングパスは問題となります。なぜなら、JVMが停止している間に行われ、理論的には参照のあるデータセットのサイズによってのみ制限されるためです。言い換えれば、部分的なグラフをファイナライズからマーキングするのに多くの時間を費やす可能性があります。
    4. すべての PhantomReference オブジェクトが検査されます。参照先に到達できない場合(強くもなくファイナライズ対象からも到達できない場合)、参照先はクリアされ、PhantomReferenceは処理キューに入れられます。
  • 最後に、処理キューはJavaのLinked Listに追加され、さらに ReferenceQueue の処理が行われます。

これらのステップから、GCの停止時間に対する参照処理の貢献度は、基本的に「処理された参照の数」+「新しくマークされた部分的なグラフのサイズ」に比例することがわかります。

参照処理の停止時間の問題に対処するためには、GCがそれを並行して行う必要があります。参照処理のタスクは,2つのサブタスクに分けられます。すなわち,それぞれの部分的なグラフを含む参照の並行マーキングと、参照とクリアの並行処理です。従来の実装ではこの2つのサブタスクが絡み合っているので、どのようにして解きほぐすかを考えてみましょう。

並行参照マーキング

従来の実装をよく見てみると、到達可能性モデルを単純化できることがわかります。到達可能性のレベルは実際には5つ(強、 ソフト、弱、ファイナル、ファントム)ではなく、2つだけです。参照を分類する基準を別の角度から見てみましょう。

  • SoftReference オブジェクトは、参照先に強く到達できず、ある発見的手法な条件を満たすと、クリアされたり、エンキューされます。要するに、並行マーキングの前または途中で、SoftReference を強参照と弱参照のどちらとして扱うかを決めるべきです。
  • WeakReference オブジェクトは、参照先に強く到達できない場合にクリアされ、エンキューされます。
  • FinalReference オブジェクトは、参照先に強く到達できない場合にエンキューされます。
  • PhantomReference は、参照元に強く到達できず、どの FinalReference からも到達できない場合にクリアされ、エンキューされます。

言い換えれば、我々の2つの関連する到達可能性レベルは、強い到達可能性とファイナルな到達可能性です。

従来の実装では、以下のような手順でマーキングにより到達可能性を決定していました。

  1. 並行マーキング時に、すべての強く到達可能なオブジェクトのセットを確立する。
  2. ソフト、弱、ファイナルの到達可能性レベルのみを必要とするすべての参照を処理する。
  3. ファイナルになったものからのマーキングを継続する。
  4. この新しい情報を必要とする残りのファントム参照を処理する。

強い到達可能性とファイナルな到達可能性を同時に判定できますか?もちろんできます。しかし、マーキングのビットマップを少し拡張する必要があります。オブジェクトごとに1つのビット(マークされているかどうか)を使う代わりに、2つのビットですべての可能な状態(強い到達可能性、ファイナルな到達可能性、不到達)を表します。この情報があれば、すべてのライブオブジェクトを並行にマークして、強い到達可能性とfinalizable 到達可能性の両方を決定できます。

  1. 通常の強い波面でルートからマーキングを開始する。
  2. 強い波面が「ソフト参照」に遭遇すると、それを強参照として扱うべきか、弱参照として扱うべきかを(発見的手法に基づいて)決定する。もしソフト参照が強参照として扱われるべきであれば、通常は参照を介してマークし(その部分グラフを強いものとしてマークする)、そうでなければそこで波面を止め、ソフト参照を発見キューにエンキューする。
  3. 強い波面が WeakReferencePhantomReference に遭遇した場合、そこで波面を停止し、発見キューに参照をエンキューする。
  4. 強い波面が FinalReference に遭遇するとすぐに、その FinalReference を強参照としてマークし、その参照をマークするためにファイナルな波面に切り替えます。そこから到達可能なすべてのオブジェクトは、ファイナルにマークされます。
  5. 強い波面が、すでにファイナルとマークされたオブジェクトに遭遇した場合、そのオブジェクトとその部分グラフを強参照にアップグレードします。

これで、すべての到達可能なオブジェクトを並行にマークし、それらが強い到達可能性かファイナルな到達可能性かを判断しました。また、すべての参照オブジェクトを発見キューに入れて、さらに処理を行えました。これで問題の前半は解決しました。私たちは到達できない参照先を消去し、参照オブジェクトをエンキューする必要がまだあります。

並行参照処理

マーキング中に並行に到達可能性を確立しました。これはファイナルマークの停止期間に終了し、その停止期間中に退避のための準備も行います。並行参照処理は、並行退避フェーズの最初に行われます。私たちは2つのことをする必要があります。

  1. 発見キューをスキャンし、到達できない参照をすべてクリアする。
  2. ”到達できない”参照オブジェクトを、Java側でのさらなる処理のために、処理キューにエンキューする。

タスクは、ほとんどが見た目通りの単純なものです。発見キューを並行にスキャンし、各参照オブジェクトを検査して、その参照先が到達可能かどうかを確認します。 前述の到達可能性のルールに従います。到達可能であるためには、ソフト参照、弱参照、ファイナル参照は強く到達可能でなければならず、ファントム参照は強く到達可能であるか、またはファイナルに到達可能でなければなりません。参照が到達不可能な場合は、参照元をクリアして、参照を処理キューに入れます。

でも、もしJavaプログラムが、私たちがクリアする前に参照元にアクセスしようとしたらどうでしょう?Javaプログラムは,他の方法では到達できないと判断された参照をまだ見ており,それを復活させてしまいます。これは仕様違反であり,JVMのクラッシュに至るまで様々な問題を引き起こすでしょう。なぜなら、GCはその後、そのオブジェクトを取り戻したり、上書きしたりするからです。そして、Javaプログラムは、開放済のメモリ領域を指しているポインタで終わってしまいます。

この問題を解決するには、Reference.get()に挿入する特別なバリアが必要です。参照の読み込みであるため、そこにはすでにLRBがあります。

// Reference.get()組込みの疑似コード
T Reference_get() {
  T ref = this.referent;
  return lrb(ref);
}
// 参照元に到達できない場合はnullを返しましょう。

// 並行参照処理のためのReference.get()の疑似コード
T Reference_get() {
  T ref = this.referent;
  if (isUnreachable(ref) {
    return null;
  }
  return lrb(ref);
}

ほらね。到達できない参照にアクセスしようとすると、常に null を返すようになりました。また、Javaアプリケーションが誤ってオブジェクトを復活させることもありません。

理屈はいいから数字を見せてくれ

並列ガベージコレクションは、実際にはどのくらい悪いのでしょうか?

JDK 11で動作する、参照とファイナライズを適度に使用する負荷を見てみましょう。-Xlog:gc+statsオプションで、いくつかの統計情報を得れます。

Pause Final Mark (N)           =    0,278 s (a =     3812 us) (n =    73) (lvls, us =      893,     2891,     3535,     4414,     7707)
  Finish Queues                =    0,020 s (a =      273 us) (n =    73) (lvls, us =       94,      123,      137,      227,     4426)
  Weak References              =    0,214 s (a =     2929 us) (n =    73) (lvls, us =      158,     2109,     2695,     3555,     6072)
    Process                    =    0,213 s (a =     2924 us) (n =    73) (lvls, us =      154,     2109,     2695,     3555,     6067)

これにより、平均的なファイナルマークの停止時間3.8msのうち、GCは弱参照処理に2.9msを費やし、最悪の場合は7.7msのうち4.4msを費やしていることがわかります。

同じコードをJDK16で見てみましょう。

Pause Final Mark (G)           =    0,208 s (a =     2044 us) (n =   102) (lvls, us =      688,      820,      914,     1562,    36975)
Pause Final Mark (N)           =    0,089 s (a =      870 us) (n =   102) (lvls, us =      500,      625,      705,      764,     4489)
  Finish Queues                =    0,034 s (a =      329 us) (n =   102) (lvls, us =      107,      125,      174,      246,     4072)
  Update Region States         =    0,004 s (a =       38 us) (n =   102) (lvls, us =       26,       34,       38,       41,       52)
  Manage GC/TLABs              =    0,001 s (a =       13 us) (n =   102) (lvls, us =        9,       13,       13,       14,       22)
  Choose Collection Set        =    0,019 s (a =      183 us) (n =   102) (lvls, us =       97,      156,      188,      203,      323)
  Rebuild Free Set             =    0,002 s (a =       23 us) (n =   102) (lvls, us =       13,       20,       23,       25,       29)
  Initial Evacuation           =    0,028 s (a =      274 us) (n =   102) (lvls, us =      133,      207,      227,      260,     3264)
    E:                  =    0,247 s (a =     2422 us) (n =   102) (lvls, us =      799,     1875,     2031,     2090,    39583)
    E: Thread Roots            =    0,247 s (a =     2422 us) (n =   102) (lvls, us =      799,     1875,     2031,     2090,    39583)
Concurrent Weak References     =    0,967 s (a =     9479 us) (n =   102) (lvls, us =      152,     4824,     9473,    11914,    39231)
  Process                      =    0,966 s (a =     9470 us) (n =   102) (lvls, us =      148,     4805,     9453,    11914,    39215)
[

ファイナルマークの停止期間から参照処理が完全になくなり、停止期間は0.9msに短縮されています。参照処理は現在、「Concurrent Weak References」で個別に表示され、平均で9.5msかかります。しかし、アプリケーションの実行を妨げるものではないので、それほど気にしていません。

この数字は、比較的小規模なワークロードでのものです。文字通り何百万もの弱参照やファイナライザの対象があるお客様の負荷を扱ったことがありますが、当然のことながら、そこでは効果がより劇的に現れています。

まとめ

リソースのクリーンアップに弱参照、ソフト参照キャッシュ、ファイナライズ、ファントム参照を使用していて、ガベージコレクションの一時停止や遅延を気にしている場合は、JDK16にアップグレードして、Shenandoah GCを試してみてはいかがでしょうか。

* 各記事は著者の見解によるものでありその所属組織を代表する公式なものではありません。その内容については非公式見解を含みます。