JavaTM HotSpot / パフォーマンス

 

JAVATM 仮想マシン 1.3.1 による
ガベージコレクションのチューニング

原文: Tuning Garbage Collection

はじめに

JavaTM 2 プラットフォームは、Web サービスといった大規模なサーバーアプリケーションに続々と採用されています。これらのアプリケーションにはスケーラビリティが必要で、スレッド数、プロセッサ数、ソケット数、メモリー容量が多いことが直接のメリットになります。ただし、このような「大型コンピュータ」のパフォーマンスは、特殊な専門知識が必要で、より小規模なシステムの知識では太刀打ちできません。幸いなことに、JavaTM 仮想マシン (JVM)* と SolarisTM オペレーティング環境には、スレッド、入出力、メモリーの効率的な管理機能が実装されています。ここでは、スケーラブルなハイパフォーマンスの前に立ちふさがっている一般的な障害、すなわちチューニングが不完全なガベージコレクション (GC) について取り上げます。

Amdahl は、ワークロードのほとんどは完全には並列化できないことに気付きました。常に逐次処理される部分があり、その部分は並列処理の恩恵を受けないからです。これは、Java 2 プラットフォームでも同様です。特に、バージョン 1.3.1 までの JVM には並列ガベージコレクションの機能がないため、マルチプロセッサシステムでの GC の影響が、並列アプリケーションに比べて大きくなります。

次のグラフは、GC を除けば完全にスケーラブルな、理想的なシステムをモデル化したものです。1 番上の線 (赤) は、単一プロセッサでは GC にわずか 1% の時間しかかからないアプリケーションを示しています。この場合、32 プロセッサでのスループットは 20% 以上低下します。10% では (単一プロセッサアプリケーションで GC に費やされる時間としてあり得ない値ではありません)、規模を拡大したときのスループットは 75% 以上も低下してしまいます。

GC vs. Amdahl's law

このことから、小規模なシステムの開発では表面化しない問題が、規模を拡大すると重要なボトルネックになる可能性があることが分かります。ただし、希望の光もあります。このようなボトルネックを少し改善するだけで、パフォーマンスを大きく向上できるからです。したがって、十分に大規模なシステムでは、ガベージコレクションをチューニングする価値が大いにあります。

この文書は、SolarisTM (SPARCTM Platform Edition) オペレーティング環境上の JVM 1.3.1 の視点から記述されています。このプラットフォームが、現時点で最もスケーラブルな Java 2 ハードウェア / ソフトウェアプラットフォームだからです。ただし、この文書の内容は、Linux、Microsoft Windows、Solaris (Intel アーキテクチャ) オペレーティング環境など、サポート対象の他のプラットフォームにも当てはまります (ただし、スケーラブルなハードウェアを使用できることが条件になります)。コマンド行オプションはどのプラットフォームでも同じですが、プラットフォームによっては、この文書で示しているものとは異なるデフォルト値が使われる場合があります。

世代

Java 2 プラットフォームの最大の強みの 1 つは、メモリー割り当てとガベージコレクションの複雑さが開発者から隠蔽されることです。ただし、GC が重要なボトルネックになった場合、この目に見えない実装面を理解することが重要になります。ガベージコレクタは、アプリケーションがどのようにオブジェクトを使用するかについて、一定の前提に立っていて、この前提がチューニング可能なパラメータに反映されています。これらのパラメータを調節すると、抽象化のパワーを犠牲にしないでパフォーマンスを改善できます。

オブジェクトが不要になるのは、実行中のプログラムのどのポインタからも、そのオブジェクトに到達できなくなったときです。最も原始的なガベージコレクションアルゴリズムでは、到達可能な個々のオブジェクトを反復処理し、残ったオブジェクトを不要なものであると認識します。生存しているオブジェクトの数に比例した時間がかかるので、大量の生存データが保持される大規模アプリケーションには適用できません。

JVM 1.3 には、さまざまなガベージコレクションアルゴリズムがあり、それらが「世代別コレクション」を使用して組み合わされます。原始的なガベージコレクションでは、ヒープ内のすべての生存オブジェクトをチェックしますが、世代別コレクションでは、ほとんどのアプリケーションに当てはまる、経験から得られたいくつかの特徴を利用して、無駄な処理を省きます。

これらの特徴の中で最も重要なのは「幼年期の死亡率」です。下の図を参照してください。青で示した領域が、オブジェクトの典型的な寿命分布です。左端の鋭いピークは、割り当て後にすぐに回収できるオブジェクトを表しています。たとえば、Iterator オブジェクトは、1 つのループの間だけしか生存していないのが一般的です。

histogram with collections

より寿命が長いオブジェクトもあるので、分布は右側に伸びています。たとえば、初期化時に割り当てられ、プロセスが終了するまで生き続けるオブジェクトがあります。この 2 種類の両極端なオブジェクトの中間に、何らかの中間演算の間だけ生存するオブジェクトがあり、上の図では、これらのオブジェクトが幼年期のピークの右側のこぶとして現れています。中には、これとはまったく違う分布になるアプリケーションもありますが、驚くほど多くのアプリケーションで、この一般的な分布が当てはまります。オブジェクトの大半が短命であるという事実に着目すると、コレクションの効率化が可能になります。

この効率化を実現するのが、メモリーを「世代」(誕生してからの年代別にオブジェクトを保持するメモリープール) に分けて管理する手法です。ある世代が一杯になったときに、その世代でガベージコレクションを実行します。上の図では、これらのコレクションを縦線で示しています。生まれたばかりのオブジェクトは Eden (後述) に割り当てられ、幼年期の死亡率が示すように、そのほとんどはそこで寿命を終えます。Eden が一杯になると、「マイナーコレクション (Minor collection)」が発生し、生き残っているオブジェクトが旧世代に移されます。旧世代のコレクションが必要になった場合、「メジャーコレクション (Major collection)」が発生します。メジャーコレクションは、生存しているオブジェクトをすべて処理する必要があるため、一般にマイナーコレクションよりはるかに長い時間がかかります。

上の図で示しているシステムは、よくチューニングされているため、最初のガベージコレクションまで生き延びるオブジェクトはほとんどありません。オブジェクトがより長期間生き延びるほど、そのオブジェクトが経験するコレクションの回数が増加し、GC が低速になります。ほとんどのオブジェクトが、コレクションを 1 回も経験しないうちに寿命を終えるように調節すると、ガベージコレクションを非常に効率的にすることができます。アプリケーションでの寿命分布が通常とは違っていたり、世代のサイズが小さすぎて、コレクションが頻繁に発生したりすると、この幸福な状況が覆される可能性があります。

ガベージコレクションのデフォルトのパラメータは、大半の小規模アプリケーションで効率的になるように設定されているため、サーバーアプリケーションの多くにとっては最適とはいえません。ここでは、この問題を中心に取り扱います。

GC がボトルネックになった場合、世代のサイズをカスタマイズします。GC の詳細出力をチェックし、GC のパラメータがパフォーマンスにどのように影響しているか確認します。

コレクションの種類

  それぞれの世代には、特定の種類のガベージコレクションが対応付けられています。これらのガベージコレクションを設定することで、アルゴリズムのスループット、フットプリント、停止時間のトレードオフを調節できます。JVM 1.3 には、次の 3 種類の非常に異なるガベージコレクタが実装されています。
  1. コピー (廃品回収型 (scavenge)とも言います): このコレクタは、複数の世代間でのオブジェクトの移動を非常に効率的に実行します。コピー元の世代は空の状態で残されるので、残っている寿命を終えたオブジェクトをすばやく回収できます。ただし、このコレクタが動作するには空の領域が必要なので、フットプリントが大きくなります。1.3.1 では、すべてのマイナーコレクションでコピー方式が使用されます。
  2. マークコンパクト: このコレクタは、余分なメモリーを確保しないで、世代をその場でコレクトできます。ただし、処理速度はコピーよりかなり遅くなります。1.3.1 では、メジャーコレクションでマークコンパクト方式が使われます。
  3. インクリメンタル (トレインとも言います): このコレクタは、コマンド行で -Xincgc を指定したときだけ使用されます。綿密な記録をとることで、一度に旧世代の一部だけをコレクトし、メジャーコレクションのために発生する長い停止時間を、多数のマイナーコレクションに分散できることが特長です。ただし、全体的なスループットは、マークコンパクト方式よりさらに遅くなります。
コピー方式は非常に高速です。したがって、できるだけ多くのオブジェクトが、マークコンパクト方式やインクリメンタル方式によってではなく、コピー方式によってコレクトされるようにすることが、チューニングの目標になります。

世代のデフォルトの配置は次のようになります。 
space usage by generations

初期化時には、最大のアドレス空間が仮想的に確保されますが、必要になるまでは物理メモリーは割り当てられません。オブジェクトメモリー用に確保されるアドレス空間全体は、若い世代 (young) と旧世代 (old) に分けることができます。

若い世代は Eden と 2 つの Survivor 領域 から構成されています。オブジェクトは最初は Eden に割り当てられます。Survivor 領域の一方は常に空で、Eden ともう一方の Survivor 領域内の生存オブジェクトに対する次回のコピー方式のコピー先として使用されます。このようにして、Survivor 領域間でオブジェクトがコピーされ、そのオブジェクトの存命期間がある程度に達すると、そのオブジェクトの「殿堂入り」 (旧世代へのコピー) が実行されます。

(Solaris オペレーティング環境用の JVM Production バージョン 1.2 など、他の仮想マシンでは、1 つの大きな Eden と 2 つの小さな領域ではなく、2 つの同一サイズの領域を使用してコピーが行われます。そのため、若い世代のサイズを設定するオプションは、これらの仮想マシンには直接当てはまりません。パフォーマンスについての FAQ に記載されている例を参照してください。)

旧世代は、マークコンパクト方式でその場でコレクトされます。旧世代には、Permanent 世代 (Perm) という特殊な領域が含まれています。この領域には、クラスやメソッドのオブジェクトなど、JVM 自身のリフレクトデータが保持されます。

パフォーマンスについての考慮事項

ガベージコレクションのパフォーマンスの指標には、基本的に次の 2 種類があります。スループットは、長期間で見たときの、ガベージコレクション以外の処理に使用される時間の割合です。その中には割り当てにかかる時間も含まれています (ただし、通常は割り当ての速度をチューニングする必要はありません)。停止時間は、ガベージコレクションが実行中のために、アプリケーションが応答しないように見える時間の長さです。

ガベージコレクションに求められる条件は、ユーザーによってさまざまです。たとえば、Web サーバーで重要なのはスループットだと考えるユーザーもいるでしょう。ガベージコレクションによる停止時間は許容できるか、あるいはネットワーク遅延の前では目立たないからです。これに対して、対話型のグラフィックプログラムでは、ほんの短い停止時間でも、ユーザーの体感性能を損なう可能性があります。

これ以外のことを重視するユーザーもいます。フットプリントは、プロセスの作業セットの大きさをページ数やキャッシュライン数で表したものです。物理メモリーが限られていたり、プロセス数が多いシステムでは、フットプリントがスケーラビリティを左右する可能性があります。機敏性は、オブジェクトが寿命を終えてから、そのメモリーが使用可能になるまでの時間です。RMI などの分散システムでは、この機敏性が重要になります。

一般に、特定の世代のサイズを設定することは、これらの考慮事項のトレードオフを選択することです。たとえば、若い世代を非常に大きくすると、スループットが最大になりますが、フットプリントと機敏性が犠牲になります。若い世代を小さくし、インクリメンタルコレクションを使用すると、停止時間が最小になりますが、スループットが犠牲になります。

世代のサイズの設定方法は一通りではありません。アプリケーションによるメモリーの使用法とユーザーのニーズから、最適な設定が決まります。JVM の GC のデフォルト設定が最適とは限らないのはそのためです。ユーザーは後述するコマンド行オプションの形式で、デフォルト設定を変更することができます。

測定

スループットとフットプリントを測定するのに最適なのは、アプリケーションの指標を使用することです。たとえば、Web サーバーのスループットはクライアント負荷生成プログラムを使用してテストでき、その Web サーバーのフットプリントは、Solaris オペレーティング環境の pmap コマンドで測定できます。これに対して、GC による停止時間は、JVM 自身の診断出力をチェックすることで簡単に推測できます。

コマンド行引数、-verbose:gcを使うと、コレクションごとの情報が出力されます。例として、以下に大規模なサーバーアプリケーションの出力を示します。

  [GC 325407K->83000K(776768K), 0.2300771 secs]
  [GC 325816K->83372K(776768K), 0.2454258 secs]

  [Full GC 267628K->83769K(776768K), 1.8479984 secs]

この出力から、2 回のマイナーコレクションと 1 回のメジャーコレクションが発生していることが分かります。矢印の前後の数値は、GC の前後の生存オブジェクトの合計サイズです (マイナーコレクション後の数値には、直接生存しているか、旧世代に含まれているか、旧世代から参照されているために、必ずしも生存していないにもかかわらず回収できないオブジェクトが含まれています)。括弧の中の数値は、合計空き容量、すなわち合計ヒープ容量から、一方の Survivor 領域の容量を引いたものです。

世代のサイズ調節

  世代のサイズに影響するパラメータはたくさんあります。次の図に、JVM 1.3.1 で最も重要なチューニングパラメータを示します。多くのパラメータは実際には比率 (x : y) で、これらは (x を示す) 黒のバーと (y を示す) グレーのバーで表現しています。options affecting sizing

合計ヒープ容量

コレクションは世代が一杯になったときに発生するので、スループットは使用可能なメモリーの量に反比例します。メモリーの合計容量が、GC のパフォーマンスに影響する最も重要な要因です。

デフォルトでは、コレクションが発生するたびに、生存オブジェクトに対する空き領域の割合が一定範囲内に保たれるように、ヒープが拡大・縮小されます。この割合の範囲は、 -XX:MinHeapFreeRatio=<最小値> パラメータと -XX:MaxHeapFreeRatio=<最大値> パラメータでパーセント値として設定します。また、ヒープの合計サイズは、下限値 -Xms と上限値 -Xmx によって制限されます。Solaris (SPARC Platform Edition) オペレーティング環境では、これらのパラメータのデフォルト値は次のようになります。

-XX:MinFreeHeapRatio=
40
-XX:MaxHeapFreeRatio=
70
-Xms
3584k
-Xmx
64m

大規模なサーバーアプリケーションでは、これらのデフォルト値のままでは 2 つの問題が頻繁に発生します。第 1 の問題は、初期ヒープサイズが小さく、多数のメジャーコレクションでサイズ変更が必要になるので、起動が遅くなることです。第 2 の、そしてより深刻な問題は、ほとんどのサーバーアプリケーションにとって、デフォルトの最大ヒープサイズは理不尽に小さすぎることです。サーバーアプリケーションでの設定の目安を次に示します。

停止時間が問題になっていなければ、JVM にできるだけ多くのメモリーを割り当てます。多くの場合、デフォルトのサイズ (64M バイト) は小さすぎます。

-Xms-Xmx を同じ値に設定すると、サイズについての最も重要な決断を JVM が行う必要がなくなり、予測性が高まります。ただし、JVM による調節の余地がなくなるので、設定が不適切だと逆効果になります。

割り当ては並列化できますが、GC は並列処理ではないので、プロセッサを増やすにつれて、メモリーも増やす必要があります。

若い世代

2 番目に影響力の高い要因は、若い世代用のヒープの割合です。若い世代のサイズが大きいほど、マイナーコレクションが発生しにくくなります。ただし、ヒープのサイズには制限があるので、若い世代を大きくすると、旧世代が小さくなり、メジャーコレクションが発生しやすくなります。最適な設定は、アプリケーションの寿命分布に左右されます。

デフォルトでは、若い世代のサイズは NewRatio で制御します。たとえば、-XX:NewRatio=3 と設定すると、若い世代と旧世代の比率が 1:3 になります。言い換えれば、Eden と Survivor 領域の合計サイズが、ヒープ全体の 1/4 になります。

若い世代のサイズの下限値と上限値を設定するには、NewSizeMaxNewSize を使います。これらのパラメータを同じ値に設定すると、若い世代のサイズが固定されます (-Xms-Xmx を同じ値にすると、合計ヒープサイズが固定されるのと同じです)。これらのパラメータは、若い世代をきめ細かくチューニングするのに便利です。NewRatio では整数の倍率しか指定できないからです。

若い世代ではコピーコレクションが使用されるので、旧世代に十分なメモリーが確保されていないと、マイナーコレクションを完了できません。少なくとも、Eden のサイズと、空ではない方の Survivor 領域内のオブジェクトのサイズを足したものと同じだけのメモリーが必要です。旧世代で使用可能なメモリーがこれよりも少ないと、マイナーコレクションの代わりにメジャーコレクションが発生します。小規模なアプリケーションでは、旧世代で確保されたメモリーは通常仮想的にコミットされているだけで、実際には使用されていないので、この方式でも問題はありません。しかし、できるだけ多くのヒープが必要なアプリケーションでは、ヒープの仮想コミットサイズの 1/2 より大きい Eden は役に立ちません。メジャーコレクションしか行えないからです。

必要であれば、SurvivorRatio パラメータを使用して Survivor 領域のサイズをチューニングできます。ただし、多くの場合、このパラメータはパフォーマンスの点ではそれほど重要ではありません。たとえば、 -XX:SurvivorRatio=6 と設定すると、それぞれの Survivor 領域と Eden との比率が 1:6 になります。すなわち、各 Survivor 領域のサイズが若い世代全体の 1/8 になります (1/7 ではありません。Survivor 領域は 2 つあるからです)。

Survivor 領域が小さすぎると、コピーコレクションが実行されたときに、Survivor 領域に入りきらなかったオブジェクトが旧世代に直接移ってしまいます。Survivor 領域が大きすぎると、使われない空間が無駄になります。JVM は、ガベージコレクションのたびに、何回オブジェクトをコピーしたら、そのオブジェクトを殿堂入りさせるかを選択します。この回数は、Survivor 領域の半分が空くように選択されます。(さらに、1.3.1 のオプション、-XX:+PrintTenuringDistribution を使って、この回数と若い世代のオブジェクトの生存期間を表示できます。このオプションは、アプリケーションの寿命分布を確認するときにも役立ちます。)

Solaris (SPARC Platform Edition) オペレーティング環境では、これらのオプションのデフォルト値は次のようになります。

NewRatio
2   (クライアント JVM では 8)
NewSize
2172k
MaxNewSize
32m
SurvivorRatio
25

サーバーアプリケーションでの設定の目安を次に示します。

まず、JVM に与えることのできる合計メモリー容量を決定します。次に、ユーザー自身のパフォーマンス指標と若い世代のサイズとの関係をグラフ化し、最適な設定を見つけます。

メジャーコレクションが発生しすぎる、停止時間が長すぎるといった問題がなければ、十分な量のメモリーを若い世代に割り当てます。一般に、デフォルトの MaxNewSize (32M バイト) は小さすぎます。

ただし、若い世代のサイズを合計ヒープサイズの 1/2 より大きくすることは逆効果です。

割り当ては並列化できますが、GC は並列処理ではないので、プロセッサを増やすにつれて、若い世代も大きくする必要があります。

その他の考慮事項

ほとんどのアプリケーションでは、Permanent 世代は GC のパフォーマンスに影響しません。ただし、アプリケーションが多数のクラスを動的に生成し、ロードする場合があります。たとえば、JSP の一部の実装では、このような処理が行なれます。必要であれば、 MaxPermSize で Permanent 世代の最大サイズを増やすことができます。

ファイナライズや、弱参照 / ソフト参照 / ファントム参照を使用することで、ガベージコレクションに関与するアプリケーションもあります。これらの機能は、Java プログラミング言語のレベルでパフォーマンスに影響を与える可能性があります。たとえば、ファイナライズによってファイル記述子を閉じると、外部資源 (記述子) が GC の機敏性に依存するようになります。GC にメモリー以外の資源を管理させることは、一部の例外を除けば賢明なアイデアではありません。

アプリケーションからガベージコレクションに関与するもう 1 つの手段が、 System.gc() を呼び出すことなどによる、GC の明示的な実行です。このような呼び出しを行うと、メジャーコレクションが強制的に実行され、大規模システムのスケーラビリティが阻害されます。明示的 GC のパフォーマンスへの影響は、サポート対象外のフラグ、 -XX:+DisableExplicitGC を使用して測定できます。

明示的 GC が最もよく使用される例の 1 つが、RMI の分散ガベージコレクション (DGC) です。RMI を使用するアプリケーションは、他の JVM のオブジェクトを参照します。これらの分散アプリケーションでは、ローカルコレクションを時々実行しないとガベージをコレクトできないので、RMI がフルコレクションを強制的に実行します。これらのコレクションの実行間隔はプロパティで制御できます。たとえば、

  java -Dsun.rmi.dgc.client.gcInterval=3600000
       -Dsun.rmi.dgc.server.gcInterval=3600000 ...

と指定すると、明示的コレクションがデフォルトの実行間隔 (1 分 に 1 回) ではなく、1 時間に 1 回実行されます。ただし、このように指定すると、一部のオブジェクトがなかなか回収されない可能性があります。DGC のタイムリーな処理が必要なければ、これらのプロパティの値を Long.MAX_VALUE まで引き上げ、明示的コレクションの間隔を事実上無限大にすることができます。

Solaris 8 オペレーティング環境では、代替版の libthread を使用できます。この libthread を使用すると、スレッドが LWP に直接結合されるので、ファイナライズスレッドが飢餓状態になるのを防止できます。この libthread を使用するには、環境変数 LD_LIBRARY_PATH に、 /usr/lib/lwp というパスを組み込んでから JVM を起動します。

サーバー JVM では、ソフト参照がクライアント JVM ほどには積極的にクリアされません。クリア間隔を伸ばすには、-XX:SoftRefLRUPolicyMSPerMB=10000 のように設定します。デフォルト値は 1000 (1M バイトあたり 1 秒) です。

大規模な専用システムでは、その他の 特殊なオプション (英語版) を使ってパフォーマンスを改善できます。

まとめ

非常に並列化されたシステムでは、ガベージコレクションがボトルネックになる可能性があります。GC の動作を理解すると、各種のコマンド行オプションを使用して、このボトルネックの影響を最小限に抑えることができます。

大規模サーバーの需要を満たすために、ますます大規模なハードウェア構成が登場しています。JVM 1.4 は、より大規模な世代を実現する 64 ビットアドレス空間、メジャーコレクションに伴う停止時間を目立たないものにする並行コレクションといった、さらなるソリューションをこれらのシステムに提供します。

この Web サイトで使用している「Java 仮想マシン」と「JVM」という用語は、Java プラットフォーム用の仮想マシンを指しています。