SJ blog
backend
A

信頼度ランク

S 公式ソース確認済み
A 成功実績多数・失敗例少数
B 賛否両論
C 動作未確認・セキュリティリスク高
Z 個人所感

JVM のメモリ構造と GC を 0 から理解する ── コードを動かしながら「実際に何が起きているか」を追う

スタックフレーム・ヒープ・Metaspace・GCルート・世代別GC・Minor/Full GC まで、簡単なJavaコードの実行に合わせてメモリの挙動を1ステップずつ追う初学者向け完全解説。

一言結論

Javaコードが動くとき、スタックとヒープで何が起きているか。GCはどうやって「不要なオブジェクト」を見つけるか。ここを理解すると OutOfMemoryError も StackOverflowError も怖くなくなる。

Javaを書いていると、こういう疑問が出てくる。

  • new したオブジェクトは、いつ消えるのか?
  • StackOverflowError はなぜ再帰で起きるのか?
  • GC(ガベージコレクション)は「どうやって」不要なオブジェクトを見つけるのか?
  • OutOfMemoryError が出るとき、メモリの中では何が起きているのか?

これらは全部、JVMのメモリ構造を知れば自然に説明できる。逆に言えば、メモリ構造を知らないまま Java を書き続けると、エラーの原因もチューニングの勘所も永遠に霧の中だ。

この記事では、実際の Java コードを動かしながら、メモリ上で何がどう変化するかを 1 ステップずつ追う。GC が何をしているのかも、「到達可能性」という概念から組み立てて理解する。

前提: Java 変数には「参照型」と「プリミティブ型」があり、参照型変数には「ヒープ上のオブジェクトへのアドレス」が入っているという基礎知識があると読みやすい。


1. JVM とは何をしている機械なのか

Java のソースコードは javac でコンパイルされ、バイトコード.class ファイル)になる。このバイトコードを実際に動かすのが **JVM(Java Virtual Machine)**だ。

┌─────────────┐    javac    ┌─────────────┐    JVM    ┌─────────────┐
│  Main.java  │ ──────────► │  Main.class │ ────────► │  実行結果   │
└─────────────┘             └─────────────┘           └─────────────┘
  人間が書く                  バイトコード               OS上で動く

JVM は「仮想的なコンピュータ」として動作する。本物の CPU やメモリを抽象化し、どの OS・アーキテクチャでも同じバイトコードを動かせるようにする。“Write once, run anywhere” の正体はここにある。

そして JVM は、プログラムを実行するために自分専用のメモリ空間を持つ。その構造を理解することが、この記事の目的だ。


2. JVM のメモリ領域 ── 全体図

JVM が管理するメモリは、大きく 5 つの領域に分かれる。

┌─────────────────────────────────────────────────────────┐
│                      JVM メモリ空間                      │
│                                                         │
│  ┌───────────────────────────────────────────────────┐  │
│  │                ヒープ(Heap)                      │  │
│  │   全スレッドで共有。オブジェクトが生きる場所。     │  │
│  │                                                   │  │
│  │  ┌─────────────┐  ┌─────────────────────────────┐ │  │
│  │  │ Young 世代  │  │       Old 世代              │ │  │
│  │  │ Eden|S0|S1  │  │(長生きオブジェクトの墓場) │ │  │
│  │  └─────────────┘  └─────────────────────────────┘ │  │
│  └───────────────────────────────────────────────────┘  │
│                                                         │
│  ┌──────────────────────┐  ┌──────────────────────────┐ │
│  │   Metaspace          │  │  スタック(各スレッド)  │ │
│  │ クラス定義・静的情報  │  │  メソッド呼び出しの記録  │ │
│  │ (ネイティブメモリ)  │  │  スレッドごとに独立      │ │
│  └──────────────────────┘  └──────────────────────────┘ │
│                                                         │
│  ┌──────────────────────┐  ┌──────────────────────────┐ │
│  │   PC レジスタ         │  │ ネイティブメソッドスタック│ │
│  │ 現在実行中の命令位置  │  │  JNI 用(通常意識しない)│ │
│  └──────────────────────┘  └──────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

最初は全部理解しなくてよい。まずスタックヒープMetaspace の 3 つを押さえれば、日常的な Java コードの挙動は説明できる。


3. スタック ── メソッド呼び出しの記録係

スタックは「メソッドを呼ぶたびに積まれ、メソッドが終わると消える」領域だ。

各メソッド呼び出しは スタックフレーム という単位で積まれる。フレームの中には:

  • ローカル変数(int i = 5; など)
  • 演算途中の値(オペランドスタック)
  • 呼び出し元への戻りアドレス

が入っている。

コードを 1 ステップずつ追う

public class Main {
    public static void main(String[] args) {
        int x = 10;
        int result = add(x, 5);
        System.out.println(result);
    }

    static int add(int a, int b) {
        int sum = a + b;
        return sum;
    }
}

Step 1: main が呼ばれる

【スタック】
┌──────────────────────────┐
│  main フレーム           │  ← 最初に積まれる
│  x = (未初期化)          │
│  result = (未初期化)     │
│  args = 0xABC (参照)     │
└──────────────────────────┘

Step 2: int x = 10; が実行される

【スタック】
┌──────────────────────────┐
│  main フレーム           │
│  x = 10                  │  ← スタックフレーム内に直接 10 が入る
│  result = (未初期化)     │
└──────────────────────────┘

プリミティブ型(intdoubleboolean など)は値そのものがスタックに入る。ヒープは使わない。

Step 3: add(x, 5) が呼ばれる

【スタック】
┌──────────────────────────┐
│  add フレーム            │  ← 新しく積まれる
│  a = 10                  │
│  b = 5                   │
│  sum = (未初期化)        │
├──────────────────────────┤
│  main フレーム           │
│  x = 10                  │
│  result = (未初期化)     │
└──────────────────────────┘

Step 4: int sum = a + b; が実行される

【スタック】
┌──────────────────────────┐
│  add フレーム            │
│  a = 10                  │
│  b = 5                   │
│  sum = 15                │
└──────────────────────────┘

Step 5: addreturn sum で終わる

【スタック】
┌──────────────────────────┐
│  main フレーム           │  ← add フレームが消え、戻り値 15 が渡される
│  x = 10                  │
│  result = 15             │
└──────────────────────────┘

add フレームはメソッドが終わると即座に消える。このフレーム内にあったローカル変数も消える。GC を待たずに、だ。

StackOverflowError の正体

static void infinite() {
    infinite(); // 再帰で自分を呼び続ける
}
【スタック】
┌──────────────┐
│ infinite     │
├──────────────┤
│ infinite     │
├──────────────┤
│ infinite     │
├──────────────┤
│ infinite     │  ← スタックには上限サイズ(デフォルト数百KB〜数MB)がある
├──────────────┤
│ ...          │
├──────────────┤  ← ここで溢れる
│              │  java.lang.StackOverflowError !

スタックには固定サイズの上限がある。再帰が深くなりすぎてフレームが積みきれなくなると StackOverflowError が発生する。「メモリが足りない」のではなく「スタックが溢れた」というエラーだ。

ポイント: StackOverflowError は「バグ(無限再帰)」か「再帰が深すぎる設計」のサインであり、JVM 全体のメモリとは別の問題。ヒープをいくら増やしても解決しない。


4. ヒープ ── オブジェクトが生きる場所

ヒープは new したオブジェクトが住む場所だ。全スレッドで共有される。

public static void main(String[] args) {
    String name = new String("Alice");
    int[] scores = new int[]{90, 85, 78};
    Person p = new Person("Bob", 30);
}
【スタック(main フレーム)】     【ヒープ】
┌────────────────────────┐      ┌──────────────────────────────┐
│ name:   0x1001         │ ───► │ String オブジェクト          │
│ scores: 0x2005         │ ───► │ int[] {90, 85, 78}           │
│ p:      0x3A10         │ ───► │ Person { name:"Bob", age:30 }│
└────────────────────────┘      └──────────────────────────────┘

スタックには「アドレス(参照)」しか入らない。オブジェクト本体はヒープにある。

メソッドが終わったとき

static void createObject() {
    Person temp = new Person("Temp", 0); // ヒープに生成
    // メソッド終了
}

メソッドが終わると createObject フレームが消える。temp という参照も消える。しかしヒープ上の Person オブジェクト本体はすぐには消えない

メソッド終了直後:

【スタック】                 【ヒープ】
(createObject フレームは消えた)
                            ┌────────────────────┐
                            │ Person: "Temp"     │  ← 誰も参照していない
                            │ (孤立状態)        │     でもまだ存在する
                            └────────────────────┘

このオブジェクトを回収するのが GC の仕事だ。


5. Metaspace ── クラス定義が住む場所

class Dog { ... } と書いたとき、クラスの定義情報(メソッドのバイトコード、フィールド名、static 変数など)はどこに入るのか。

Java 8 以降は Metaspace というネイティブメモリ領域に入る(Java 7 以前は PermGen と呼ばれるヒープの一部だった)。

【Metaspace(ネイティブメモリ)】
┌────────────────────────────────────────────┐
│ class Dog                                  │
│   - bark(): void   (バイトコード)          │
│   - static count: 3 (static 変数)          │
│   - フィールド定義情報                     │
├────────────────────────────────────────────┤
│ class String                               │
│ class Integer                              │
│ class ArrayList                            │
│ ...(ロードされた全クラス)                │
└────────────────────────────────────────────┘

static 変数がここに置かれる。そのため new でインスタンスを何個作っても、static 変数はクラス単位で 1 つだけ存在する(前の記事で触れた「共有」の正体)。

Metaspace は Java 8 以降デフォルトで上限なし(OS のネイティブメモリが許す限り伸びる)。大量にクラスをロードし続けると OutOfMemoryError: Metaspace が出ることがある。主に動的クラス生成(リフレクション・Groovy・大規模な AOP など)が原因になる。


6. GC の仕組み ── 「到達可能性」で不要を判定する

GC は「不要なオブジェクトを自動で回収する」仕組みだが、「不要」をどうやって判定するのかが重要だ。

答えは**到達可能性(Reachability)**だ。

GC ルートから辿れるか?

GC は「GC ルート」と呼ばれる出発点から、参照を辿っていく。辿り着けたオブジェクトは「生きている」、辿り着けなかったオブジェクトは「不要」として回収する。

GC ルートの主な種類:

  • 各スレッドのスタック上にある参照変数
  • Metaspace 内の static 変数
  • JNI(Java Native Interface)の参照
【GC ルートから参照を辿るイメージ】

GC ルート

  ├── main スタックフレームの変数
  │     ├── list ──► ArrayList オブジェクト
  │     │               └── [0] ──► "hello" (String)
  │     │               └── [1] ──► "world" (String)
  │     └── user ──► User オブジェクト
  │                     └── name ──► "Alice" (String)

  └── static 変数
        └── Logger.instance ──► Logger オブジェクト

【到達できないオブジェクト(孤立)】
  ┌────────────────┐
  │ 孤立した Person │  ← 誰も参照していない → GC 対象
  └────────────────┘

到達可能 = 生存。到達不可 = 回収対象。 シンプルだが強力なルールだ。

参照が切れる瞬間

void process() {
    Person p = new Person("Alice");  // p が参照を持つ
    p = null;                        // 参照を手放す
    // ここで Person オブジェクトへの参照はゼロになる
    // GC の次のタイミングで回収される
}
void process() {
    Person p = new Person("Alice");
    // メソッド終了 → スタックフレームごと p が消える
    // → Person オブジェクトへの参照がゼロになる
}

null を代入する方法と、メソッド終了でフレームが消える方法、どちらも「参照ゼロ」になる点で同じだ。


7. 世代別 GC ── ヒープを世代に分けて効率化する

GC が毎回ヒープ全体をスキャンしたら時間がかかりすぎる。そこで JVM は世代仮説に基づいてヒープを分割している。

世代仮説: ほとんどのオブジェクトは生まれてすぐに死ぬ。長く生き残ったオブジェクトはさらに長く生き残る傾向がある。

【ヒープの世代構造】

┌────────────────────────────────────────────────────────────┐
│                       Young 世代                           │
│                                                            │
│  ┌────────────────┐   ┌────────────┐   ┌────────────┐    │
│  │   Eden 領域    │   │ Survivor 0 │   │ Survivor 1 │    │
│  │(新生児病棟)  │   │ (S0)       │   │ (S1)       │    │
│  │ new したものが │   │            │   │            │    │
│  │ まず来る       │   │            │   │            │    │
│  └────────────────┘   └────────────┘   └────────────┘    │
│     ↑ 大半のオブジェクトはここで死ぬ(Minor GC で回収)    │
├────────────────────────────────────────────────────────────┤
│                       Old 世代(Tenured)                  │
│                                                            │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 長生きしたオブジェクトが昇格してくる(Promotion)   │  │
│  └──────────────────────────────────────────────────────┘  │
│     ↑ ここが溢れると Major GC / Full GC が走る             │
└────────────────────────────────────────────────────────────┘

Minor GC の流れ

  1. new したオブジェクトはまず Eden に入る
  2. Eden が満杯になると Minor GC が走る
  3. Eden 内で到達可能なオブジェクトを S0 (または S1)にコピーする
  4. 到達不可なオブジェクトは Eden ごと一括破棄(非常に速い)
  5. これを繰り返すたびに、生き残ったオブジェクトの年齢カウンタが増える
  6. 年齢が閾値(デフォルト 15)を超えると Old 世代に昇格(Promotion)
【Minor GC のビフォー/アフター】

Before:
Eden:  [A] [B] [C(孤立)] [D] [E(孤立)] [F]
S0:    [G(年齢2)] [H(年齢1)]
S1:    (空)

Minor GC 実行:
Eden の到達可能: A, B, D, F → S1 へコピー(年齢+1)
Eden の孤立: C, E → 即破棄
S0 の到達可能: G(年齢3), H(年齢2) → S1 へコピー(年齢+1)

After:
Eden:  (空)
S0:    (空)
S1:    [A(年齢1)] [B(年齢1)] [D(年齢1)] [F(年齢1)]
       [G(年齢4)] [H(年齢3)]

Minor GC は Young 世代だけを対象にするため非常に速い(ミリ秒単位)。大半のオブジェクトは Eden で死ぬため、Old 世代に昇格するオブジェクトはごく一部だ。

Major GC / Full GC

Old 世代が満杯になると Major GC(Old 世代のみ)または Full GC(ヒープ全体 + Metaspace)が走る。

Full GC は範囲が広いため時間がかかる(秒単位になることもある)。この間、アプリケーションの処理が止まる(Stop-the-World)。

Full GC のコスト比較:

Minor GC:   [ ■ ]  数ms(Eden だけ見る)
Major GC:   [ ■■■■■■■ ]  数百ms(Old 世代を見る)
Full GC:    [ ■■■■■■■■■■■■■■■ ]  数秒(全体を見る)

               この間アプリが「固まる」(Stop-the-World)

8. GC アルゴリズムの種類

JVM には複数の GC 実装があり、用途によって使い分ける。

Serial GC

シングルスレッドで動作する最も単純な GC。小規模アプリや開発用途向け。Stop-the-World が長い。

-XX:+UseSerialGC

Parallel GC(スループット重視)

GC をマルチスレッドで並列実行する。スループット(単位時間あたりの処理量)重視。Java 8 のデフォルト。バッチ処理などに向く。

-XX:+UseParallelGC

G1 GC(Java 9 以降のデフォルト)

ヒープを小さなリージョンに分割し、「最も回収効果の高いリージョン」から優先的に回収する(Garbage First)。停止時間を一定以下に収める目標時間を指定できる。

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200  # 目標停止時間 200ms
【G1 GC のリージョン構造】
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ E  │ E  │ S  │ O  │ O  │ E  │ O  │ S  │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ O  │ O  │ E  │ E  │ S  │ O  │ E  │ O  │
└────┴────┴────┴────┴────┴────┴────┴────┘
E = Eden, S = Survivor, O = Old
リージョンは役割が固定されず動的に変わる

ZGC / Shenandoah(超低停止時間)

停止時間を 1ms 以下に抑えることを目標とした最新世代の GC。Java 15 以降で本番利用可能。レイテンシが極めて重要な用途(リアルタイム処理など)向け。

-XX:+UseZGC
GC停止時間スループット用途
Serial長い開発・小規模
Parallelバッチ処理
G1短い中〜高Webアプリ全般
ZGC極短(<1ms)やや低超低レイテンシ

9. OutOfMemoryError が出るとき

OOM(OutOfMemoryError)にはいくつかパターンがある。メッセージで判断する。

java.lang.OutOfMemoryError: Java heap space

ヒープが満杯。Old 世代まで GC で回収しても空きが出ない状態。

主な原因:

  • オブジェクトをひたすら作り続け、どこかが参照を手放さない(メモリリーク)
  • 処理に対してヒープサイズが小さすぎる
// 古典的なメモリリーク例
static List<byte[]> leak = new ArrayList<>();

void addData() {
    leak.add(new byte[1024 * 1024]); // 1MB を static リストに追加し続ける
    // static なので GC に回収されない
}

-Xmx でヒープ上限を増やすか、参照を手放す設計にする。

java.lang.OutOfMemoryError: Metaspace

Metaspace が満杯。大量のクラスが動的にロードされ続けるとなる。

-XX:MaxMetaspaceSize=256m  で上限を設定して検知しやすくする

java.lang.OutOfMemoryError: GC overhead limit exceeded

GC が実行されても全体の 98% 以上の時間が GC に費やされ、解放できるメモリが 2% 未満という状態が続いたときに発生。「GC が一生懸命動いているが何も解放できていない」=「詰んでいる」という状態だ。


10. 実際に手を動かして確認する

ヒープ使用量をコードから見る

public class MemoryCheck {
    public static void main(String[] args) {
        Runtime rt = Runtime.getRuntime();

        long maxMemory  = rt.maxMemory();    // JVM に設定された最大ヒープ
        long totalMemory = rt.totalMemory(); // 現在確保しているヒープ
        long freeMemory = rt.freeMemory();   // 現在の空きヒープ
        long usedMemory = totalMemory - freeMemory;

        System.out.printf("最大ヒープ  : %,d bytes%n", maxMemory);
        System.out.printf("使用中      : %,d bytes%n", usedMemory);
        System.out.printf("空き        : %,d bytes%n", freeMemory);
    }
}

GC ログを出力して動きを確認

# G1 GC のログを出力しながら起動
java -Xms256m -Xmx512m -XX:+UseG1GC \
     -Xlog:gc*:file=gc.log:time,uptime,level,tags \
     Main

ログには [GC pause (G1 Evacuation Pause) (young)] のような行が出る。Minor GC のたびに何MB回収されたかが確認できる。

JVM Flags でよく使うもの

-Xms512m          # ヒープ初期サイズ
-Xmx2g            # ヒープ最大サイズ
-XX:+UseG1GC      # GC アルゴリズム指定
-XX:MaxGCPauseMillis=200  # G1 GC の目標停止時間
-XX:+HeapDumpOnOutOfMemoryError  # OOM 時にヒープダンプを自動出力
-XX:HeapDumpPath=/tmp/dump.hprof  # ダンプ先

-XX:+HeapDumpOnOutOfMemoryError は本番でも設定しておくのが鉄板だ。OOM が起きた瞬間のヒープの状態を保存してくれるため、原因調査が格段に楽になる。


まとめ ── 全体を繋げる

領域何が入るか管理寿命
スタックローカル変数・メソッドフレームJVM(自動)メソッド終了で即消える
ヒープ Youngnew したての短命オブジェクトGC(Minor GC)Eden満杯のたびに回収
ヒープ Old長生きしたオブジェクトGC(Major/Full GC)参照がなくなるまで
Metaspaceクラス定義・static変数GC(クラスアンロード)クラスが使われ続ける限り

new したオブジェクトはいつ消えるか?」に対する完全な答えはこうなる。

  1. new → Eden に生成
  2. Minor GC を生き残るたびに年齢が増える
  3. 年齢が閾値を超えると Old 世代に昇格
  4. 参照がゼロになり、Old 世代 GC の際に回収される
  5. ただし GC のタイミングはJVMが決める(System.gc() はヒント程度で保証はない)

これを理解すると、「GC チューニング」「メモリリーク調査」「ヒープダンプ解析」といった実務スキルへの道が開ける。

次に学ぶと面白いこと

  • VisualVM / JConsole: GC の動きをリアルタイムで GUI 観察できる(JDK に同梱)
  • JOL(Java Object Layout): ヒープ上でオブジェクトが実際に何バイト占めているか確認できるライブラリ
  • Java Memory Model(JMM): マルチスレッド時の可視性・volatilesynchronized の正体。スタックとヒープの構造が前提知識になる
  • GC チューニング入門: G1 GC の Region サイズ・Eden/Old 比率・Promotion 閾値の調整
  • ヒープダンプ解析: Eclipse MAT(Memory Analyzer Tool)を使ったメモリリーク原因の特定