Javaのメモリ管理(ヒープ・GC)を理解する

Javaにおけるメモリ管理はGCガベージコレクション)がよろしくやってくれているため、普段はあまり気になりません。

だ・け・ど

メモリ管理の概要くらいは知っておかないと、OutOfMemoryなんかが出た日にはアワアワしてしまいます。

実際に少し前に仕事のトラブルでそんなことがあって……Javaヒープにおけるメモリ管理(ヒープとGC)について、復習がてらまとめてみます。

Javaヒープの構成

Javaにおけるヒープとは、JVM上で「ユーザが作ったプログラムが利用するメモリ領域」のことです。

New/Old/Permanent

ヒープ領域は大きく「New」と「Old」に分けられます。
またヒープ領域ではありませんがヒープと一蓮托生の「Permanent」が存在します。

f:id:WorldWorldWorld:20161029203333p:plain

領域 用途
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に格納されます。
f:id:WorldWorldWorld:20161029211050p:plain

2.Survivorへの移動(ScanvengeGCその1)

Edenに次々とオブジェクトが生成され、Edenが一杯になるとScanvengeGCが発生します。

ScanvengeGCは、Edenに格納されていてかつまだ使われているオブジェクトをSurvivorのどちらかへ移し(ここではS0)、不要なオブジェクトは削除します。
f:id:WorldWorldWorld:20161029211537p:plain

3.Survivor間の移動(ScanvengeGCその2)

ScanvengeGCが行われるとEdenには空きが出るため、新規オブジェクトはまたEdenの中に作成されます。

そうすると当然再びEdenがいっぱいになり、ScanvengeGCが発生します。

このときEden内のオブジェクトの移動先はS1になります。
さらに先程S0に移動して、かつまだ利用されているオブジェクトもS1へ移動します。

f:id:WorldWorldWorld:20161029212038p:plain

このようにScanvengeGCはS0とS1を交互に使い、Edenの空きを作り出します。

4.SurvivorからOldへの移動(ScanvengeGCその3)

さてEdenの中身をS0とS1に移し続けていると、利用され続けているオブジェクトが多ければ多いほどSurvivor(S0またはS1)の容量は無くなっていきます。

そうしてSurvivorの容量がいっぱいになった時点で、SurvivorからOldへオブジェクトの移動が行われます。
f:id:WorldWorldWorld:20161029214154p:plain

ここで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が発生します。
f:id:WorldWorldWorld:20161029214138p:plain

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のメモリ管理は

難しいね、これ。

普段あまり意識しないけれども、いざという時に必要な知識なんです。

「どんな言葉で調べたら良いか」ぐらいを頭の片隅に置いておくと良いかもしれません。

*1:ScanvengeGCは「YoungGC」「マイナーGC」、FullGCは「OldGC」「メジャーGC」など様々な呼び方があるようです。

*2:この2つのオプションの使い分けがよく分かりません!自分の環境ではInitialTenuringThresholdの回数以上になるとOldへの移動が発生しているようで、MaxTenuringThresholdが何を意味するのか不明です。