それが僕には楽しかったんです。

僕と MySQL と時々 MariaDB

JVM における G1GC とヒープの雑な話

はじめに

どうも、けんつです。GW は特に何もせず映画とチェスに勤しんでいたら気がついた時には連休は既に過去のものとなっていました。
やるやる詐欺をしてきた JVM の G1GC について基本的なことなどをまとめていこうと思い立ったので書きます。ヒープではない領域の話については以前書いたので割愛。

ヒープと GC の基本

ヒープの構造

よく言われるやつだけど、ヒープは以下のような構造になっている。*1*2
f:id:RabbitFoot141:20210508233655p:plain

雑な GC の基本

この辺を雑に書くと、オブジェクトが生成された場合はまず Eden 領域に配置されて、そこから Minor GC によって Survivor 領域に移されたり、Survivor 領域が足りなくなったら Tenured 領域に配置され、そこでも領域が足りなくなると Major GC で解放される。

もう少し細かく書くと、Eden 領域に配置されたオブジェクトは Minor GC が走った時に利用されていないオブジェクトを破棄して、利用されているオブジェクトを Survivor 領域に移動させる。Survivor 領域にも移動させられない場合 or 一定回数 Minor GC を生き残ったオブジェクトは Tenured 領域に移動させる。

Tenured 領域にオブジェクトを移動させることができない場合は Major GC が走って不要なオブジェクトを解放する。一般的にはこの時どちらの GC でも停止時間が発生する。特に Full GC の方が時間がかかりがち。

G1GC

概要

G1GC はそれなりに大きいメモリを持つマルチプロセッサマシンで高パフォーマンスを発揮する、正確には高いスループットを安定した一時停止時間目標内で実現することを目標としている。

さらに細かい要件では、6 GB 以上のヒープサイズを持つ JVM に対して 0.5 秒未満の安定した予測可能な一時停止時間を実現することを目的としている。*3

G1GC も世代別 GC となっているがヒープをリージョンという単位で分割し、管理している。その上でそれぞれのリージョンは Young 領域または Old 領域に属することとなる。この時、それぞれの領域に属するリージョンは必ずしも連続するとは限らない。また Tenured 領域に対する処理はバックグラウンドスレッドが行うため大抵の場合、アプリケーションスレッドは停止しない。その代わり複雑な処理を内部で行っているため CPU 使用率が高くなりやすい。

仕組み

G1GC は特に Old 領域に対する GC が他の GC アルゴリズムと異なる。Old 領域に対する GC では対象リージョン内にどれだけ不要となったオブジェクトが存在するかを基準にして領域を確保する。つまり不要オブジェクトが多いリージョンの操作に注力する。そのため短時間で GC を実行することが可能となる。この仕組みは Young 領域には適用されない。

その上で G1GC では次の 4 つの処理が中心となる。これらは別々に動作するわけではなく後述のコンカレントサイクル内で実行される場合がある。

  • Young 領域に対する GC
  • バックグラウンドで行われるコンカレントサイクル
  • 混合 GC
  • Major GC (必要に応じて)
Young 領域に対する GC

Young 領域に対する GC は Eden 空間*4を使い果たすと実行される。この時、Eden 空間は解放され最低でも一つの Survivor 空間が確保された上で必要に応じて Old 領域に移動される。

コンカレントサイクル

コンカレントサイクルは大まかに以下のフェーズから構成される。

  • 初期マーク付け

Young 領域に対する GC に乗じて行われるためアプリケーションスレッドは停止する

  • ルートリージョンスキャン

初期マーク付けでマークされた Survivor 領域を走査して、 Old 領域のオブジェクトに対する参照をマークする。次の停止を伴う Young 領域に対する GC が実行される前に完了している必要がある。このフェーズはアプリケーションスレッドと並列実行されるため停止を伴わない。ただし G1GC が利用するスレッドに対する CPU リソースが十分でない時は停止時間が増大する可能性がある。またこのフェーズは中断できないことに注意が必要。

  • 並列マーク付け

このフェーズではヒープ全体が対象となり、オブジェクトグラフから到達可能なオブジェクトを探索する。これも並列実行されアプリケーションスレッドを停止させない。Young 領域に対する GC によって中断されることがある。

  • 再マーク付け

未探索のオブジェクトグラフから到達可能なオブジェクトを再度探索する。アプリケーションスレッドの停止を伴う。

  • クリーンアップ

領域を解放して、完全に利用していないリージョンと混合 GC で解放するオブジェクトを識別する。アプリケーションスレッドの停止を伴う。

ここで行っているのはあくまでも Old 領域に対するマーク付けで Young 領域に対する GC によって多少領域が解放されることはあるが、この一連のフェーズに色々期待してはいけないらしい。

混合 GC

バックグランドで実行されるコンカレントサイクルによって、リージョンに対するマーク付けが行われたらいよいよ混合 GC によって領域が解放される。混合 GC とは Young 領域に対する GC とマークがついたリージョンの解放が両方行われるため、そう呼ばれている。
Young 領域に対する GC では、Eden 空間が完全に空になり Survivor 空間に対して調整が入る。Old 空間に存在するマーク付けされたリージョンに対しては一度に全てを解放することはせずに、繰り返し実行される。その中でマーク付きリージョン内で到達可能なオブジェクトがマーク付きでない Old 領域のリージョンに退避される。マーク付きのリージョンがおおよそ解放された段階で Young 領域に対する GC が再開され、コンカレントサイクルによってマークされるを繰り返す。

Major GC が実行される可能性のある場合
  • concurrent mode failure

マーク付けフェーズを開始し、完了するまでに Old 領域がいっぱいになった場合。この時該当するフェーズを中断して Major GC が走る。

  • promotion failure

混合 GC が開始されたが、Old 領域に移動させる必要のあるものが解放されるものより多く、かつ Old 領域がいっぱいになってしまった場合。

  • evacuation failure

Minor GC 時に Survivor 空間に十分な空きがなく対象となる全てオブジェクトを Old 領域に移動させる必要のある場合。リージョンが確保されているが、各リージョンでデータが断片化してしまった場合に起きやすい。必ず Major GC を伴うわけではないが、Major GC が走る可能性がある。またログ上では Minor GC として記録されているらしい。

おわりに

次は G1GC のチューニングにどういったものがあるのか調べようかね。疲れた。

参考文献

*1:図中で各領域が占める割合は適当。

*2:Java 8 以降の話をしたいので、Permanent は除外。それに相当するものは Metaspace で非ヒープ領域。

*3:Oracle のドキュメントでは 6 GB 以上, 0.5 秒未満とあるが、Java パフォーマンスでは 4 GB 以上, 一時停止時間の明確な数値は言及されていない。

*4:ここでの空間とは各領域のリージョンを指す。