Javaにおけるメモリ管理はGC(ガベージコレクション)がよろしくやってくれているため、普段はあまり気になりません。
だ・け・ど
メモリ管理の概要くらいは知っておかないと、OutOfMemoryなんかが出た日にはアワアワしてしまいます。
実際に少し前に仕事のトラブルでそんなことがあって……Javaヒープにおけるメモリ管理(ヒープとGC)について、復習がてらまとめてみます。
Javaヒープの構成
Javaにおけるヒープとは、JVM上で「ユーザが作ったプログラムが利用するメモリ領域」のことです。
New/Old/Permanent
ヒープ領域は大きく「New」と「Old」に分けられます。
またヒープ領域ではありませんがヒープと一蓮托生の「Permanent」が存在します。
領域 | 用途 |
---|---|
New | 新規に作成された、まは短命なオブジェクトが格納される。 |
Old | 寿命が長いオブジェクトが格納される。 |
Permanent | クラスやメソッドの情報が格納される。そのため、動的なサイズが変化は少ない。 |
※「短命」「寿命が長い」は後ほど説明します。
Newの内訳
New領域はさらに「Eden」と「Survivor」に分けられます。
領域 | 用途 |
---|---|
Eden | 新規に作成されたインスタンス等が、まずはここに格納される |
Survivor | Edenに格納されてから時間が経ったオブジェクトを格納する。Survivorは「S0」と「S1 」(または「From」と「To」)を行ったり来たりする。 |
ヒープサイズの指定
ヒープ(およびPermanent)のサイズはJavaコマンド実行時にオプションで明示的に指定が可能です。
オプション | 意味 |
---|---|
-Xms | ヒープ領域(New+Old)の初期値 |
-Xmx | ヒープ領域(New+Old)の最大値 |
-XX:NewRatio | ヒープにおけるNewの割合。残りがOldに割り当たる。 |
-XX:SurvivorRatio | NewにおけるSurvivorの割合。S0とS1はこの値を半分した割合となり、残りがEdenに割当たる。 |
-XX:PermSize | Permanentの初期値 |
-XX:MaxPermSize | Permanentの最大値 |
初期値と最大値については同一の値を指定し、確保するメモリサイズを一定に保つのがベターなようです。
で、冒頭の説明だけではピンときませんね。
次はGCの動きと合わせてヒープ領域の役割の詳細を説明します。
JavaヒープにおけるGCの概要
前述のヒープ領域に対して、JVMは自動でメモリの管理を行います。具体的にはヒープに格納されているオブジェクトのうち、不要となったものを削除する=ガベージコレクション(GC)を勝手にやってくれるのです。
ScanvengeGCとFullGC
ヒープにおけるGCは「ScanvengeGC」と「FullGC」*1の2種類あります。
GC | 概要 |
---|---|
ScanvebgeGC | New領域に対するGC。負荷が低く速い。頻繁に行われる。 |
FullGC | OldおよびPermanent領域に対するGC。負荷が高く遅い。FullGCが行われている間はアプリケーションが停止状態となるため、頻発させないことが望ましい。 |
GCの動き
それではGCの動きに合わせてヒープ内の変化を追ってみます。
1.オブジェクトの発生
オブジェクトが新規に発生すると(例えば「String a = "aaaaa"」みたいな)、まずはEdenに格納されます。
2.Survivorへの移動(ScanvengeGCその1)
Edenに次々とオブジェクトが生成され、Edenが一杯になるとScanvengeGCが発生します。
ScanvengeGCは、Edenに格納されていてかつまだ使われているオブジェクトをSurvivorのどちらかへ移し(ここではS0)、不要なオブジェクトは削除します。
3.Survivor間の移動(ScanvengeGCその2)
ScanvengeGCが行われるとEdenには空きが出るため、新規オブジェクトはまたEdenの中に作成されます。
そうすると当然再びEdenがいっぱいになり、ScanvengeGCが発生します。
このときEden内のオブジェクトの移動先はS1になります。
さらに先程S0に移動して、かつまだ利用されているオブジェクトもS1へ移動します。
このようにScanvengeGCはS0とS1を交互に使い、Edenの空きを作り出します。
4.SurvivorからOldへの移動(ScanvengeGCその3)
さてEdenの中身をS0とS1に移し続けていると、利用され続けているオブジェクトが多ければ多いほどSurvivor(S0またはS1)の容量は無くなっていきます。
そうしてSurvivorの容量がいっぱいになった時点で、SurvivorからOldへオブジェクトの移動が行われます。
ここでOldへ移されたオブジェクトが”寿命が長いオブジェクト”ということになります。一度Old領域に入ったオブジェクトはその後利用されなくなったとしても、FullGCが行われるまでOld領域に残り続けます。
SurviorからOldへオブジェクトの移動は、以下のいずれかの条件で発生します。
- Survivor領域(S0またはS1)の容量がいっぱいになった時
- 一定回数以上のScanvengeGC(S0とS1の移動)が行われたオブジェクトが存在する時
一つ目は前述のとおり。二つ目ですが、オブジェクトごとにScanvengeGCで移動された回数を記憶しており、一定回数以上の移動が行われたオブジェクトを”寿命の長いオブジェクト”とみなしてOld領域へ移動させます。
「一定回数」は-XX:InitialTenuringThreshold(デフォルトは7)および-XX:MaxTenuringThreshold(デフォルト32)により指定されます。*2
5.FullGCの発生
1〜4のScanvengeGCが繰り返されるうちに、Old領域に長寿命オブジェクトが溜まっていきます。
しかし長寿命と言えども、全てのオブジェクトがいつまでも使われているわけではありません。
そのため、Old領域がいっぱいになった時点でOld内の不要オブジェクトを削除するFullGCが発生します。
FullGCの結果、Oldの使用率がほとんど減らない場合は近いうちに恐怖のOutOfMemoryExceptionが発生します。
おそらくは単純に-Xmxで指定するサイズが小さいか、メモリリークが発生しているかのどちらかです。
頑張って調査しましょう!!
GCの様子を実際に確認してみる
せっかくなのでGCが行われる様子を単純なプログラムで確認します。
ScanvengeGCの確認
サンプルプログラムはこんな感じ。whileが一度回ると用済みとなる、短命オブジェクトを無限に作り続けます。
import java.io.*; public class HeapTest { public static void main(String[] args) throws InterruptedException { while(true) { Thread.sleep(100); /* 3Mの短命オブジェクトを作り続ける */ StringBuffer tempStr = new StringBuffer(3000000); } } }
実行します。
java -Xms100m -Xmx100m HeapTest1
jstatコマンドにより現在のGCの様子を確認できます。
「S0」「S1」「E」「O」「P」はそれぞれの領域の”使用率”を表します。「YGC」「FGC」がそれぞれScanvengeGCとFullGCが実施された回数です。
詳しくはこちらを参照!!
jstat - Java 仮想マシン統計データ監視ツール
bash-3.2$ jstat -gcutil -h5 11929 1000 S0 S1 E O P YGC YGCT FGC FGCT GCT 0.00 0.00 6.00 0.00 12.12 0 0.000 0 0.000 0.000 0.00 0.00 28.44 0.00 12.12 0 0.000 0 0.000 0.000 0.00 0.00 50.88 0.00 12.12 0 0.000 0 0.000 0.000 0.00 0.00 73.32 0.00 12.12 0 0.000 0 0.000 0.000 0.00 0.00 95.76 0.00 12.12 0 0.000 0 0.000 0.000 ①0.00 10.16 25.46 0.00 12.13 1 0.005 0 0.000 0.005 … 0.00 10.16 48.94 0.00 12.13 1 0.005 0 0.000 0.005 0.00 10.16 71.38 0.00 12.13 1 0.005 0 0.000 0.005 0.00 10.16 93.82 0.00 12.13 1 0.005 0 0.000 0.005 ② 9.38 0.00 23.11 0.00 12.13 2 0.009 0 0.000 0.009 9.38 0.00 46.24 0.00 12.13 2 0.009 0 0.000 0.009 … 75.00 0.00 70.55 0.00 12.13 6 0.068 0 0.000 0.068 75.00 0.00 88.16 0.00 12.13 6 0.068 0 0.000 0.068 ③0.00 6.25 17.61 0.42 12.13 7 0.072 0 0.000 0.072
Eden上昇
開始直後からEdenの使用率が上がり続けています。最初に説明したとおり、whileの中のStringBufferインスタンスがガンガン作られている状態です。
そして①でYGCが1となり、Eの使用率が下がると共にS1の使用率が上昇します。ScanvengeGCが発生したということです。
Survivorへ移動
再度Edenの使用率は上昇し、②のタイミングでもう一度ScanvengeGCが発生(YGCが2)。するとS1のオブジェクトもS0に移動されたため、S1の使用率が0となっています。
Oldへ移動
さらに③でYGCが7、つまりScanvengeGCが-XX:InitialTenuringThresholdのデフォルト値7に達したため、S0中にあり現在も利用されているオブジェクトがOld領域へ移されたことが分かります。
これは恐らくstatic void mainの引数String[] argsなどです。(while中のインスタンスはすぐに破棄されるためほとんど含まれないはずです。)
FullGCの確認
続いてFullGC発生用のサンプルプログラムです。
import java.io.*; public class HeapTest2 { public static void main(String[] args) throws InterruptedExceptio { /* すぐにOld領域を圧迫する */ sayHello(); /* 常に参照され続けるインスタンスを生成 * ScanvengeGC後にOld領域へ移動される*/ StringBuffer oldStr = new StringBuffer(5000000); int i = 0; while(true) { /* 繰り返しScanvengeGCが実行されoldStrがOldへ移動し、FullGCが発生する。 */ StringBuffer newStr = new StringBuffer(100000); Thread.sleep(100); } } public static void sayHello() { System.out.print("Hello"); StringBuffer tempStr = new StringBuffer(30000000); } }
実行します。
java -Xms100m -Xmx100m HeapTest2
GCの遷移はこのとおり。
bash-3.2$ jstat -gcutil -h5 10298 1000 S0 S1 E O P YGC YGCT FGC FGCT GCT 0.00 0.00 6.00 0.00 12.12 0 0.000 0 0.000 0.000 ① 0.00 0.00 95.76 85.40 12.12 0 0.000 0 0.000 0.000 ② 0.00 0.00 71.38 14.65 12.13 1 0.016 1 0.020 0.036 49.25 0.00 53.04 14.65 12.13 2 0.018 1 0.020 0.038 0.00 49.25 30.37 14.65 12.13 3 0.022 1 0.020 0.042
Oldの上昇
まずは①の時点でOld領域がいっぱいです。
これは冒頭のプログラム中にあるメソッドsayHello内のtempStrが30Mバイト、続くmain内のoldStrが5MバイトのStringBufferインスタンスとなり、Newにはそもそも入り切らずにOld領域に直接格納されたためだと思われます。
FullGCの発生
その後while内で大量のインスタンスの生成が行われたことでScanvengeGCによりOld領域に更にオブジェクトが追加され、②でFullGCが発生しています。
このFullGCにより、sayHello内のtempStrは既に利用されていない(メソッド呼び出しが終わっている)ため、その分のオブジェクトがOldより削除され使用率が低下しています。
Javaのメモリ管理は
難しいね、これ。
普段あまり意識しないけれども、いざという時に必要な知識なんです。
「どんな言葉で調べたら良いか」ぐらいを頭の片隅に置いておくと良いかもしれません。
- 作者: 柴田望洋
- 出版社/メーカー: SBクリエイティブ
- 発売日: 2016/06/25
- メディア: 単行本
- この商品を含むブログを見る