O'Reilly Japan - Javaパフォーマンス
こちらの本の6章まとめ
1章 イントロダクション - Qiita
2章 パフォーマンステストのアプローチ - Qiita
3章 Javaパフォーマンスのツールボックス - Qiita
4章 JIT コンパイラのしくみ - Qiita
5章 ガベージコレクションの基礎 - Qiita ←前回記事
6 章 ガベージコレクションのアルゴリズム←今回記事
6.1 スループット型ガベージコレクターを理解する
適応的あるいは静的なヒープサイズのチューニング
スループット型ガベージコレクターのチューニングは、停止時間と各領域間のサイズのバランスがすべて。
ヒープサイズを大きくすれば、フルガベージコレクションの発生を抑えられるが、1回のガベージコレクションにかかる時間は長くなる。
young領域を増やすと、フルガベージコレクションでの停止時間を短縮できるが、頻度が上昇する。
ヒープが足りないとスループットが落ちる。
ヒープサイズを増やしすぎると逆にスループットが若干減ることがある。
スループット型ガベージコレクターでは、停止時間の目標値に適合するようにヒープと各領域のサイズが変更される。
目標値は-XX:MaxGCPauseMillis=N
と-XX:GCTimeRatio=N
で指定する。
-XX:MaxGCPauseMillis=N
マイナーガベージコレクションとフルガベージコレクションに適用される許容可能な停止時間。
この値が小さいと、old領域が小さくなり、フルガベージコレクションの頻度が高くなる。
デフォルトは値設定無し。
-XX:GCTimeRatio=N
ガベージ・コレクションに費やす割合がこれ以下だと望ましいという仮想マシンへのヒント。
「ガベージ・コレクション外で使用される時間/ガベージ・コレクションで使用する時間」を指定する。デフォルトは99
アプリケーションスレッドの割合は以下計算式で求められる。
アプリケーションスレッドの割合 = 1 − 1 / (1 + GCTimeRatio)
デフォルト値は99なので、0.99となる。
GCTimeRatio = アプリケーションスレッドの割合 / ( 1 −アプリケーションスレッドの割合)
アプリケーションスレッドの割合が0.95の場合は、19となる。
停止時間の目標値を満たすまでサイズが拡張された後、停止時間の目標値を満たすまでサイズを縮小する。
デフォルトでは、停止時間の目標は設定されていないので、GCTimeRatioの目標を満たすまで、ヒープと各領域のサイズが増加する。
GCTimeRatioのデフォルト値は99なのでGCに費やす時間が1%ぐらいだろうという観測で、オブジェクトを大量に生成する場合は足りないケースもあるので、そういう場合は19(95%)とかに設定する。
メモリの制約が厳しい場合は、ガベージコレクションに10から15%もの時間が割かれているアプリケーションもある。
オブジェクトの生成が少ないようなアプリケーションでは、ヒープサイズを増やして、ガベージコレクションの負荷を下げるようなチューニングを行ってもあまり恩恵はない。
MaxGCPauseMillis=50ms
のような非現実的な少なすぎる停止時間を設定すると、目標を達成するためにヒープサイズを小さくしてしまい、頻繁にガベージコレクションが走るようになり、パフォーマンスが悪くなる。
どの程度のヒープサイズが適切か分からない場合は、Xmx(最大ヒープサイズ)を大きくし、MaxGCRatio(望ましいアプリケーションスレッドの割合)を小さめにして、動的なチューニングに任せるのが良い。
CMS ガベージコレクターを理解する
CMSは以下の処理で成り立っている
- young 領域の解放(ここはスループット型と同様に、アプリケーションスレッドがすべて停止される)
- コンカレントサイクル(アプリケーションの処理と並行して、old領域の開放)
- フルガベージコレクション(必要に応じて)
コンカレントサイクルが終了するとEDENが少なくなり、oldが増える。
コンカレントサイクルは複数のフェーズがあり、以下のフェーズが順番で行われる。
順 | フェーズ名 | アプリケーションスレッド | |
---|---|---|---|
1 | initial mark | 停止 | old領域の占有率がしきい値を超えたときなどを条件に開始される。アプリケーションスレッドから直接到達可能なオブジェクト(ルートオブジェクトと呼ぶ)にマークする |
2 | mark | 並行 | ルートオブジェクトから、到達可能なオブジェクトにマークをする |
3 | preclean | 並行 | remarkの作業を軽減するために、前準備を並行して行う |
3 | abortable preclean | 並行 | eden領域の占有率が多くまるまで、待機しながら行われる |
4 | remark(再マーク) | 停止 | mark phase中に発生した変更を取り込むために、アプリケーションスレッドを停止して、再度、mark処理を行う。 |
5 | sweep | 並行 | 参照されていないold領域のオブジェクトを開放 |
6 | concurrent reset | 並行 | 次回のコンカレントサイクルのために備えてCMSのアルゴリズムで使用する様々なデータ構造をリセットする |
CMSは複数の種類のガベージコレクションがあり、通常は、マイナーガベージコレクションとコンカレントサイクルが行われます。
concurrent mode failure や promotion failed が発生すると、アプリケーションスレッドは長時間にわたって停止さるため、可能なかぎりこれらを避けるようなチューニングが求められる。
デフォルトでは、CMSでは permanent領域のガベージコレクションは行われません。
concurrent mode failureを回避するために
concurrent mode failureは、old領域の解放がアプリケーションに対して追いつかない場合に発生する。
※old領域の使用率が(デフォルトでは)70%を超えると、コンカレントサイクルが開始される。
回避策は以下のいずれかがある
- old領域を大きくする(young領域との比を大きくしても、ヒープ全体を大きくしてもOK)
- バックグラウンドのスレッドの実行頻度を上げる
- バックグラウンドのスレッドの数を増やす
adaptive sizingについて
CMSは、MaxGCPauseMllis=N
とGCTimeRatio=N
でヒープと各領域のサイズを決定する。
CMSの大きな特徴は、以下2つ。適切にチューニングされたCMSは、young領域のサイズが変わらない。
- フルGCが走るまで、young領域のサイズが変わらない
- フルGCの頻度を減らすことを目標
プログラムの起動時には、CMSは、ヒープやpermanent領域(またはメタスペース)を適応的にサイズを拡大するので、concurrent mode failureが発生しやすい。
バックグラウンドのスレッドの実行頻度を上げるときには、以下フラグを使う。
-
-XX:CMSInitiatingOccupancyFraction=N
: old領域が何%まで埋まったら、コンカレントサイクルを開始するか?を指定する(デフォルトは70) -
-XX:+UseCMSInitiatingOccupancyOnly
: バックグラウンドの処理の開始するかの判定に、old領域の使用率のみを使用する(デフォルトはfalse)。falseの場合は、複雑な計算を用いて判定される(らしい)。早期に
6.2.2 permanent 領域のための CMS のチューニング
Java7でのCMSのスレッドは、デフォルトでは、permanent領域に対してGCを行わないが、
-XX:+CMSPermGenSweepingEnabled
フラグ(デフォルト値はfalse)を有効にすると、GCが行われる。
使用率が、-XX:CMSInitiatingPermOccupancyFraction=N
(デフォルト値は80%)で指定された値を超えると、GCが行われる。
参照されていないクラスの開放は、-XX:+CMSClassUnloadingEnabled
フラグ(Java7では、デフォルトfalse、Java8からはtrue)を有効にする。
iCMS(インクリメンタルCMS)というのがあるが、Java8では非推奨なので、詳しくは説明しない。
CPUリソースが限られているが、停止時間を避けたい場合に使う。
バックグラウンドのスレッドが定期的に停止するので、その間に、アプリケーションスレッドを実行できるというもの。
-XX:+CMSIncrementalMode
フラグで有効にできる
-XX:CMSIncrementalSafetyFactor=N
, -XX:CMSIncrementalDutyCycleMin=N
, -XX:- CMSIncrementalPacing
で頻度の調整ができる。
6.3 G1ガベージコレクター
コンカレントなGCで、ヒープをリージョン毎に分けて処理する。
デフォルトでは2048個に別れたリージョンを1つのリージョンはyong領域かold領域のどちらかに属する。
不要なオブジェクトの数は、リージョン毎に違うという性質を利用する。
リージョンを開放するときには、アプリケーションスレッドの停止を伴うが、リージョンで別れているため、短時間で終わる。
ゴミ(garbage)だらけのリージョンだけを処理するので、Garbage 1st(略してG1)という。
通常は、survivor空間のオブジェクトの一部が、old領域へ移動するが、
survivor空間の使用量が多すぎる場合、young領域からからold領域へと直接に昇格することもある。
以下のように複数のフェーズに別れている
- 初期マーク付け(initial mark)、young領域へのGCが必要という理由からアプリケーションスレッドが停止
- ルートリージョンスキャン(root region scan),このフェーズは停止できないので、CPUリソースに余裕を持たせる。バックグラウンドのスレッドだけが使われる。
- 並行マーク付け(concurrent mark),中断可能
- 再マーク(remark)
- クリーンアップ(cleanup)
- 並行のクリーンアップ(concurrent cleanup)
- 混合ガベージコレクション(mixed garbage collection)、マークが付けられたリージョンが無くなるまで、複数回行われる。バックグラウンドのスキャンでマーク付されたリージョンの開放と通常のyoung領域へのGCが共に行われるので、mixed。マークがついたリージョンが別のリージョンに移るので、コンパクト化(断片化したデータを1箇所にまとめることで、連続した空き領域を作ること)の効果があるので、CMSより断片化しにくい。
フルGCがされる場合、チューニングの余地がある場合がある。
concurrent mode failureの発生: マーク付け開始して完了までの間に、old領域がいっぱいになった場合。
昇格の失敗(promotion failed): 混合GCが開始されたが、昇格させるオブジェクトが多く、old領域がいっぱいになった場合(この場合、混合GCのあとにフルGCが発生する)。
移動の失敗(evacuation failure): young領域のGCの際に、survivor空間がいっぱいの場合に、直接old領域へ移動させる場合
巨大な割り当ての失敗(humongous allocation failure): G1では、極めて大きなオブジェクトの割当で失敗することがある。
6.3.1 G1 のチューニング
G1の目標は、フルガベージコレクションのトリガーであるconcurrent mode failureやevacuation failureを発生させないというものがある。
フルガベージコレクションを避けるためにはいろいろなの方法(old領域の拡大、バックグラウンドのスレッドを増大、バックグラウンドのG1の頻度を増大、1回の混合ガベージコレクションで行われる処理の量の増大)が取られるが、
G1の目標には、たくさんのチューニングは求めないということも含まれており、主に-XX:MaxGCPauseMillis=Nフラグだけでチューニングするらしい。スループット型と違って、デフォルト値200ミリ秒が設定されている。
それ以外のチューニングで使うフラグは以下がある。
ParallelGCThreads
-XX:InitiatingHeapOccupancyPercent=N
-XX:G1MixedGCLiveThresholdPercent=N
-XX:G1MixedGCCountTarget=N
MaxGCPauseMillis
6.4 高度なチューニング
6.4.1 昇格とsurvivor空間
young領域へのGCで、開放されずに残るものは、長く存続するものと短命かもしれないものがある。
old領域への移動は、survivor空間が小さく、survivor空間がいっぱいのときや、survivor0とsurvivor1の移動(実際は、移動というよりもsurvivor0とsurvivor1が指し示すものが切り替わるだけらしい)を一定回数以上繰り返したときに、発生する。
survivor空間の初期サイズは、以下フラグと計算式で求められる。
-XX:InitialSurvivorRatio=N
※デフォルト値は8
survivor空間の初期サイズ = \frac{young領域のサイズ}{InitialSurvivorRatio + 2}
survivor空間の最大サイズは、以下で求められる。
-XX:MinSurvivorRatio=N
※デフォルト値は3
survivor空間の最大サイズ = \frac{young領域のサイズ}{MinSurvivorRatio + 2}
survivor空間やold領域のサイズを一定にするには、UseAdaptiveSizePolicyを無効化する。そうすると、InitialSurvivorRatioの値で固定される。
GC後のsurvivorの使用率が50%になるように調整するように、動作しているが、-XX:TargetSurvivorRatio=N
で何%で調整するかを変更できる。
2つのsurvivor領域の往復を何回繰り返したら、old領域へ移動するかのしきい値は、常にJVMによって、1から-XX:MaxTenuringThreshold=N
(デフォルトは、スループット型とG1では15、CMSでは6)の間の値で変動されるが、初期値は、-XX:InitialTenuringThreshold=N
で変更可能。
デフォルト値は、スループット型とG1では7、CMSでは6らしい。
young領域へのGCで生き残ったオブジェクトが全て長命であるとわかっている場合は、-XX:+AlwaysTenure
(デフォルトはfalse)をtrueにする又はMaxTenuringThresholdを0にすることで、survivor空間を経由せずにold領域へいく。
※しかし、この設定が必要であることはまれ。
-XX:+NeverTenure
(デフォルトはfalse)をtrueにすると、survivor空間に余裕がなくならない限りは、old領域へ移動しないようになる。
-XX:+PrintTenuringDistribution
をtrueにすると、GCのログに、昇格関連のログが追加される。
6.4.2 大きなオブジェクトの割り当て
大きなオブジェクトの基準は、TLAB(thread-local allocation buffer)のサイズによって大きさの基準は変わる。
大きなオブジェクトのためにTLABを変えることはあまり無い。一方でG1で大きなオブジェクトのためにリージョンのサイズを変更することはよくあることらしい。
6.4.2.1 TLAB
TLABが高速なのは、オブジェクトを共有の空間に直接割り当てると、同期が必要になるが、スレッド固有の領域の場合はそのコストが不要なため。
- 通常TLABを意識することはないが、サイズが限られているので大きなオブジェクトは割り当てられない。
- TLABが満杯になると、JVMには2つの選択肢がある。TLABを作り直すか、スレッドが別のオブジェクトを割り当てるまでそのまま保持して、ヒープを直接割り当てるというもの。
- TLABはeden空間の一部なので、young領域へのGCの対象でもある。
- TLABのサイズ設定が重要。スレッド数とeden空間のサイズ、各スレッドによる割り当ての頻度という3つの条件を元にTLBABのサイズが算出される。
-
-XX:-UseTLAB
を指定すると無効化できる(が、おすすめしない)。 - 大きなオブジェクトをたくさん割り当てるアプリと、eden空間のサイズに見合わないぐらい多くのスレッドを利用しているアプリはTLABのチューニングによるメリットが得られる。
- TLABの適切なサイズを正確に予測はできないため、JFRなどで、TLABでの割り当ての監視をし、調整する必要がある。TLAB外のオブジェクトや割り当て時のコールスタックもJFRで見れるらしい。
-
-XX:+PrintTLAB
フラグを使って監視を行うこともできる。
6.4.2.2 TLABのサイズ変更
- TLAB外への割り当てに多くの時間を費やしている場合、TLAB内で割り当てが行われるようにチューニングする
- TLAB外で割り当てられるオブジェクトの型が限定されている場合、プログラムを改修するというのが適切な解決策で、より小さいオブジェクトを使うようにするというのがより良いアプローチ。
- young領域を大きくすると、自動的にTLABも大きくなる
-
-XX:TLABSize=N
フラグでサイズを指定できる -
-XX:-ResizeTLAB
で、GCのたびにサイズ変更されることを防げる - 新しいオブジェクトが現在のTLABの空き容量に収まらない場合、ヒープに割り当てるか、今のTLABを引退させて新しいTLABに割り当てるかの判断をする。その判断のしきい値の初期値は、TLABのサイズの1%または
-XX:TLABWasteTargetPercent=N
で指定された値。 - ヒープ外での割り当てが発生するたびに、
-XX:TLABWasteIncrement=N(デフォルト値は4)
の値だけしきい値が上昇する - 最小値は、
-XX:MinTLABSize=N(デフォルト値は2kb)
で設定できる
6.4.2.3 超巨大オブジェクト
- humongous objectと言うらしい
- TLAB外に割り当てる場合、可能な限りeden空間に割り当てるが、無理な場合、直接old領域に割り当てられる。
- G1では、humongous objectの扱い方が他と違い、1つのリージョンのサイズより大きい場合、old領域に割り当てられる
6.4.2.4 G1でのリージョンのサイズ
- G1のリージョンは固定サイズ。起動時のヒープの初期サイズ(Xms)を元に決定され、常に一定。1MB以上。
- リージョンのサイズは、
1 << log( ヒープの初期サイズ / 2048)
という計算式で計算される -
-XX:G1HeapRegionSize=N
でリージョンのサイズを指定もできる - G1のリージョンは2048個程度の状況を前提に設計されているので、XmsとXmxのようにヒープの変動幅が大きいなど、リージョンが多すぎることがないようにリージョンのサイズを決める
6.4.2.5 G1での超巨大オブジェクトの割り当て
- リージョンのサイズを超えるobjectの場合、連続したold領域が必要となる。
- humongous objectがフルGCを引き起こしているかは、PrintAdaptiveSizePolicyで知ることができる
- リージョンのサイズの半分のサイズを超えるとhumongous objectと判定されるので、この場合もリージョンのサイズを増やして、一つのリージョンにおさまるようにする。
6.4.3 AggressiveHeap
- AggressiveHeapフラグは、デフォルトはfalse。trueにするのはおすすめしない。昔からあるらしい。64bitのJVMでのみ利用可能。いろいろなチューニングを一括して指定できるが、チューニング内容を知ることができないのと、適切なデフォルト値を上書きしてしまい、パフォーマンス悪化するケースがあるため。
- 大きなマシンでJVMを一つだけ実行するケースに適している。
6.4.4 ヒープサイズの完全な制御
- ヒープの大サイズは
MaxRAM
の1/4。-XX:MaxRAM=N
を使って上限値を設定することもできる。 - デフォルトの最大ヒープサイズ = 上限値以内の物理メモリの量 /
-XX:MaxRAMFraction=N(デフォルト値は4)
- ヒープの最大サイズは、
-XX:ErgoHeapSizeLimit=N
(デフォルト値は0)の上限値が設定されている。 - 一方、物理メモリがとても少ない場合は、OSのためにメモリを残しておこうとする。デフォルトの最大ヒープサイズ = 物理メモリの量 /
-XX:MinRAMFraction=N(デフォルト値は2)
という計算式となる。ヒープの初期サイズもほぼ同様ので、デフォルトの初期ヒープサイズ = 上限値以内の物理メモリの量 / InitialRAMFraction(デフォルト値は64)となる。
6.5まとめ
- FullGCでの停止を許容できるのであれば、スループット型GC。許容できないならコンカレントGC。
- 負荷の高いアプリケーションで、GCにかかる時間が3%以下の場合、改善の余地は少ない。
- GCによる停止時間が短いが、スループットが悪化している場合、ヒープサイズ(少なくともyoung領域)を増やす必要がある。
- concurrent mode failuerに伴うフルGCが発生している場合、CPUに余裕あるなら、GCのスレッドの数を増やすか、InitiatingHeapOccupancyPercentの値を調整。G1ならG1MixedGCCountTargetの値を減らすなどが考えられる。
- promotion failedに伴うフルGCが発生している場合。CMSではヒープの断片化で、対策はあまりないらしいが、場合によっては対策があるらしい。G1の場合、evacuation failureによって同じような症状が発生し、バックグラウンドでの処理を早めて、混合GCを速くすれば断片化は解消できます(InitiatingHeapOccupancyPercentを増やし、G1MixedGCCountTargetを減らすらしい)。