Javaのスタックは「メソッド呼び出しごとのローカル変数を置く、スレッド専用の領域」、ヒープは「newで作ったオブジェクトと配列を置く、全スレッド共有の領域」です。 さらに、クラスそのものの情報(クラス名・フィールド定義・メソッドのコードなど)を置く領域としてメタスペースがあります。変数に入っているのが「値そのもの」か「オブジェクトへの参照」かを区別できると、どこに何が保存されるかがすっきり整理できます。

この記事では、1つのサンプルコードの各行に「スタック/ヒープ/メタスペース」を注記し、変数の種類ごとの保存場所を一覧表にまとめます。あわせて、3つの領域があふれたときのエラー(StackOverflowError、Java heap space、Metaspace)を実際に起こし、エラー文と対応するJVMオプション、領域の実サイズを確かめるコマンドまで、すべて実行結果つきで紹介します。

確認環境:Ubuntu 24.04(Linux x86_64)/OpenJDK 21.0.10(HotSpot、既定のG1 GC)。数値(深さ・MB数)は環境と実行ごとに変わります。

ヒープ・スタック・メタスペースの早見表

まず3つの領域の違いです。

項目 スタック ヒープ メタスペース
置かれるもの メソッド呼び出しごとのフレーム(ローカル変数・引数・計算途中の値) newで作ったオブジェクト、配列 クラスのメタデータ(クラス名、フィールド・メソッドの定義、メソッドのコードなど)
持ち主 スレッドごとに1本 全スレッドで共有 全スレッドで共有
確保のタイミング メソッドを呼んだとき newしたとき、配列を作ったとき クラスを読み込んだとき
解放のタイミング メソッドが終わったとき(自動) 参照されなくなった後、GCが回収 クラスローダーごと不要になった後に回収
あふれたときのエラー java.lang.StackOverflowError java.lang.OutOfMemoryError: Java heap space java.lang.OutOfMemoryError: Metaspace
上限を決めるオプション -Xss -Xmx -XX:MaxMetaspaceSize
既定の上限(確認環境) 1024KB(1スレッドあたり) 約1.46GB(実行時に自動決定) 上限なし(空きメモリまで)

次に、変数の種類ごとの保存場所です。「変数そのもの(値や参照)が置かれる場所」と「参照の先にある実体が置かれる場所」を分けて見るのがポイントです。

変数の種類 例 変数そのものの場所 参照先の実体の場所
ローカル変数(プリミティブ) int local = 10; スタック なし(値そのもの)
ローカル変数(参照) MemoryMap task = new MemoryMap(...) スタック ヒープ
メソッドの引数 String[] args スタック ヒープ
インスタンス変数(プリミティブ) int id; ヒープ(オブジェクトの中) なし(値そのもの)
インスタンス変数(参照) String name; ヒープ(オブジェクトの中) ヒープ
static変数(プリミティブ) static int counter; ヒープ(クラスを表すオブジェクトの中) なし(値そのもの)
static変数(参照) static List<String> history ヒープ(クラスを表すオブジェクトの中) ヒープ
文字列リテラル "task" ― ヒープ(同じリテラルは1つを共有)
配列 int[] numbers = {1, 2, 3}; 変数の種類による ヒープ(要素もここに並ぶ)
クラスの定義情報 フィールド名、メソッドのコード ― メタスペース

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java ヒープとスタックの違い|変数の保存場所とメタスペースを図解」

3つの領域を図で見る

Java仮想マシン仕様(JVMS、Java SE 21)の「2.5 Run-Time Data Areas」では、実行時のデータ領域が定義されています。今回そのページを開いて確認した内容を、要点だけ表にします。

仕様上の領域 仕様の説明(要約) 共有か
Java仮想マシン・スタック スレッドと同時に作られ、フレームを格納する。ローカル変数と計算途中の結果を保持する スレッドごと
ヒープ すべてのクラスインスタンスと配列のメモリが割り当てられる領域。GCが回収する 全スレッド共有
メソッド領域 実行時コンスタントプール、フィールドとメソッドのデータ、メソッドやコンストラクタのコードなど、クラスごとの構造を格納する 全スレッド共有
pcレジスタ 実行中の命令のアドレスを持つ スレッドごと
ネイティブメソッド・スタック nativeメソッドのためのスタック スレッドごと

仕様の「メソッド領域」を、HotSpot(OpenJDKのJVM)ではメタスペースというネイティブメモリ上の領域で実現しています。JEP 122(Remove the Permanent Generation)には、クラスのメタデータをネイティブメモリに置き、インターンされた文字列とクラスのstatic変数はJavaヒープへ移す、と書かれています。早見表で「static変数はヒープ」「文字列リテラルはヒープ」としたのは、この内容に基づきます。

flowchart LR
    subgraph T1["スレッドmain のスタック"]
        F3["フレーム: square()"]
        F2["フレーム: sumOfSquares()"]
        F1["フレーム: main()"]
    end
    subgraph T2["スレッドworker のスタック"]
        G1["フレーム: run()"]
    end
    subgraph H["ヒープ(全スレッド共有)"]
        O1["オブジェクト"]
        O2["配列"]
        O3["文字列"]
        O4["クラスを表すオブジェクト<br/>(static変数の値)"]
    end
    subgraph M["メタスペース(全スレッド共有)"]
        C1["クラスのメタデータ<br/>フィールド定義・メソッドのコード"]
    end
    F1 -- 参照 --> O1
    G1 -- 参照 --> O1
    O1 -- 参照 --> O2
    O1 -. どのクラスか .-> C1

スタックはスレッドの数だけあり、ヒープとメタスペースは1つを全員で使う、という形です。複数のスレッドから同じオブジェクトを参照できるのは、オブジェクトが共有のヒープに置かれているからです。

1つのコードで変数の保存場所を確かめる

各行のコメントに「変数そのもの → 実体」の順で保存場所を書きました。そのままコピーして動かせます。

import java.util.ArrayList;
import java.util.List;

public class MemoryMap {                     // クラスの定義情報 → メタスペース
    static int counter = 0;                  // static変数(値) → ヒープ(クラスを表すオブジェクト内)
    static final String LABEL = "task";      // static変数(参照) → ヒープ / "task" の実体 → ヒープ
    static List<String> history = new ArrayList<>(); // 参照 → ヒープ / ArrayList本体 → ヒープ

    int id;                                  // インスタンス変数(値) → ヒープ(オブジェクト内)
    String name;                             // インスタンス変数(参照) → ヒープ(オブジェクト内)
    int[] scores;                            // インスタンス変数(参照) → ヒープ(オブジェクト内)

    MemoryMap(int id, String name) {         // 引数 id, name → スタック
        this.id = id;
        this.name = name;
        this.scores = new int[] {70, 80, 90}; // 配列の実体 → ヒープ
    }

    public static void main(String[] args) { // 引数 args → スタック / 配列の実体 → ヒープ
        int local = 10;                             // 値 → スタック
        MemoryMap task = new MemoryMap(1, "task");  // 参照 → スタック / オブジェクト → ヒープ
        String lit1 = "task";                       // 参照 → スタック / リテラルの実体 → ヒープ(共有)
        String made = new String("task");           // 参照 → スタック / 新しい文字列 → ヒープ(別物)
        int[] numbers = {1, 2, 3};                  // 参照 → スタック / 配列 → ヒープ
        counter++;
        history.add(task.name);

        System.out.println("lit1 == LABEL      : " + (lit1 == LABEL));
        System.out.println("lit1 == task.name  : " + (lit1 == task.name));
        System.out.println("lit1 == made       : " + (lit1 == made));
        System.out.println("lit1 == made.intern(): " + (lit1 == made.intern()));
        System.out.println("local=" + local + " counter=" + counter
                + " numbers.length=" + numbers.length + " history=" + history);
        System.out.println("クラスを読み込んだローダー: " + MemoryMap.class.getClassLoader());
    }
}
$ javac MemoryMap.java
$ java MemoryMap
lit1 == LABEL      : true
lit1 == task.name  : true
lit1 == made       : false
lit1 == made.intern(): true
local=10 counter=1 numbers.length=3 history=[task]
クラスを読み込んだローダー: jdk.internal.loader.ClassLoaders$AppClassLoader@639fee48

実行結果から読み取れること

結果 意味
lit1 == LABEL が true 同じ文字列リテラル "task" は、ヒープ上の1つの文字列を共有している
lit1 == task.name が true コンストラクタに渡した "task" も同じリテラル。インスタンス変数には参照だけが入る
lit1 == made が false new String("task") はヒープに別のオブジェクトを作る
lit1 == made.intern() が true intern() は共有されている側の文字列を返す

== は「同じオブジェクトを指しているか」を比べるので、保存場所の確認に使えます。文字列の内容を比べたいときは equals を使います。

「値」と「参照」を分けて考える

混乱しやすいのは、参照型の変数には「オブジェクトそのもの」ではなく「オブジェクトの場所を指す参照」が入っている点です。

  • int local = 10; … スタックのフレームに、10という値がそのまま入る。
  • MemoryMap task = new MemoryMap(...) … スタックのフレームに入るのは参照だけ。id・name・scores を持つオブジェクト本体はヒープにある。
  • task.scores … ヒープのオブジェクトの中にある参照が、同じくヒープにある配列を指している。

そのため「インスタンス変数のintはスタックかヒープか」と聞かれたら、答えはヒープです。プリミティブ型かどうかではなく、その変数がどこに属しているか(メソッドか、オブジェクトか、クラスか)で場所が決まります。

変数が属する先 場所 寿命
メソッド(ローカル変数・引数) スタックのフレーム メソッドが終わるまで
オブジェクト(インスタンス変数) ヒープのオブジェクト内 そのオブジェクトがGCで回収されるまで
クラス(static変数) ヒープのクラスを表すオブジェクト内 クラスがアンロードされるまで(通常はプログラム終了まで)

メソッド呼び出しでスタックが積み上がる様子

JVMSの「2.6 Frames」によると、フレームはメソッドを呼ぶたびに新しく作られ、メソッドが終わると破棄されます。各フレームは、ローカル変数の配列・オペランドスタック(計算途中の値を置く場所)・そのクラスの実行時コンスタントプールへの参照を持ちます。

StackWalker を使うと、今どのフレームが積まれているかを表示できます。

public class Frames {
    static int square(int x) {
        int result = x * x;
        StackWalker.getInstance().forEach(f -> System.out.println("  " + f.getMethodName()));
        return result;
    }

    static int sumOfSquares(int a, int b) {
        return square(a) + square(b);
    }

    public static void main(String[] args) {
        System.out.println("square の中から見たスタック(上が最新):");
        int answer = sumOfSquares(3, 4);
        System.out.println("answer = " + answer);
    }
}
square の中から見たスタック(上が最新):
  square
  sumOfSquares
  main
  square
  sumOfSquares
  main
answer = 25

square は2回呼ばれますが、どちらの時点でも積まれているフレームは3つです。1回目の square のフレームは戻った時点で片付けられ、2回目の呼び出しで新しいフレームが積まれます。スタックは「呼んだら積む、戻ったら降ろす」だけなので、GCを待たずに解放されるのが特徴です。

時点 スタックの中身(下から上) フレームにある変数
mainの開始 main args
sumOfSquares(3, 4) を呼ぶ main → sumOfSquares a=3, b=4
square(3) を呼ぶ main → sumOfSquares → square x=3, result=9
square(3) から戻る main → sumOfSquares a=3, b=4(9は計算途中の値として保持)
square(4) を呼ぶ main → sumOfSquares → square x=4, result=16
mainに戻る main args, answer=25

スタックがあふれる例:StackOverflowError を再現する

終了条件のない再帰を書くと、フレームが積み上がり続けてスタックの上限に達します。

public class StackDemo {
    static int depth = 0;

    static void dive() {
        depth++;
        dive();   // 終了条件がないので呼び出しが積み上がり続ける
    }

    public static void main(String[] args) {
        try {
            dive();
        } catch (StackOverflowError e) {
            System.out.println("捕まえたエラー: " + e);
            System.out.println("到達した深さ: " + depth);
        }
    }
}

-Xss でスタックのサイズを変えながら実行した結果です。

$ java StackDemo
捕まえたエラー: java.lang.StackOverflowError
到達した深さ: 23306

$ java -Xss256k StackDemo
捕まえたエラー: java.lang.StackOverflowError
到達した深さ: 3644

$ java -Xss4m StackDemo
捕まえたエラー: java.lang.StackOverflowError
到達した深さ: 101948

$ java -Xss16m StackDemo
捕まえたエラー: java.lang.StackOverflowError
到達した深さ: 1005921
指定 到達した深さ
指定なし(1024KB) 23,306(もう一度実行すると22,072)
-Xss256k 3,644
-Xss4m 101,948
-Xss16m 1,005,921

スタックを大きくすると深く呼び出せるようになりますが、深さはきれいな比例にはなりません。 同じ指定でも実行ごとに数値が変わります。1フレームが使う大きさは、メソッドの内容や実行時の状況によって変わるためです。

try-catchを書かずに実行したときの表示も確認しておきます。

$ java StackRaw
Exception in thread "main" java.lang.StackOverflowError
    at StackRaw.dive(StackRaw.java:2)
    at StackRaw.dive(StackRaw.java:2)
    at StackRaw.dive(StackRaw.java:2)
    (同じ行が続く。この環境では合計1,025行)

同じメソッドの行が延々と並んでいたら、終了条件のない再帰が原因です。 スタックトレースの繰り返し部分を見れば、どのメソッドが自分自身を呼び続けているかがわかります。

直す前と後:深い再帰をループに置き換える

終了条件があっても、回数が多いとスタックが足りなくなることがあります。1から100万までの合計を、再帰とループで比べます。

public class SumDemo {
    // 直す前: 1回の足し算ごとにフレームを1つ積む
    static long sumRecursive(int n) {
        if (n == 0) return 0;
        return n + sumRecursive(n - 1);
    }

    // 直した後: ループならフレームは1つのまま
    static long sumLoop(int n) {
        long total = 0;
        for (int i = 1; i <= n; i++) {
            total += i;
        }
        return total;
    }

    public static void main(String[] args) {
        int n = 1_000_000;
        System.out.println("ループ: " + sumLoop(n));
        try {
            System.out.println("再帰  : " + sumRecursive(n));
        } catch (StackOverflowError e) {
            System.out.println("再帰  : " + e);
        }
    }
}
$ java SumDemo
ループ: 500000500000
再帰  : java.lang.StackOverflowError

$ java -Xss512m SumDemo
ループ: 500000500000
再帰  : 500000500000

-Xss512m を付ければ再帰でも最後まで計算できますが、スタックはスレッドごとに確保されるので、スレッド数が多いプログラムでは全体のメモリ使用量が増えます。まず「ループに書き換えられないか」「終了条件は正しいか」を見直し、それでも深い再帰が必要なときに -Xss を調整する、という順番がおすすめです。

スレッドごとにスタックのサイズを変える

Thread のコンストラクタには、スタックサイズを指定できるものがあります。特定の処理だけ深い再帰が必要な場合は、JVM全体の -Xss を変えずに、そのスレッドだけ大きくする方法もあります。

public class Th {
    static int dive(int d) {
        try { return dive(d + 1); } catch (StackOverflowError e) { return d; }
    }
    public static void main(String[] args) throws Exception {
        int[] result = new int[2];
        Thread a = new Thread(() -> result[0] = dive(0), "worker-a");
        Thread b = new Thread(null, () -> result[1] = dive(0), "worker-b", 8L * 1024 * 1024);
        a.start(); b.start(); a.join(); b.join();
        System.out.println("worker-a(既定のスタック)   : 深さ " + result[0]);
        System.out.println("worker-b(8MBを指定)       : 深さ " + result[1]);
    }
}
worker-a(既定のスタック)   : 深さ 9078
worker-b(8MBを指定)       : 深さ 302938

2つのスレッドがそれぞれ別のスタックを持ち、片方があふれてももう片方は動き続けていることがわかります。

ヒープがあふれる例:Java heap space を再現する

ヒープの上限は -Xmx で決まります。約8MBの配列(long が100万個)を、参照を持ったまま作り続けます。

import java.util.ArrayList;
import java.util.List;

public class HeapDemo {
    public static void main(String[] args) {
        long max = Runtime.getRuntime().maxMemory();
        System.out.println("ヒープの上限: " + max / 1024 / 1024 + " MB");
        List<long[]> keep = new ArrayList<>();
        try {
            while (true) {
                keep.add(new long[1_000_000]);   // 約8MBの配列を参照したまま増やす
            }
        } catch (OutOfMemoryError e) {
            int n = keep.size();
            keep = null;
            System.out.println("確保できた配列: " + n + " 個(約 " + n * 8 + " MB)");
            System.out.println("捕まえたエラー: " + e);
        }
    }
}
$ java -Xmx64m HeapDemo
ヒープの上限: 64 MB
確保できた配列: 7 個(約 56 MB)
捕まえたエラー: java.lang.OutOfMemoryError: Java heap space

$ java -Xmx256m HeapDemo
ヒープの上限: 256 MB
確保できた配列: 31 個(約 248 MB)
捕まえたエラー: java.lang.OutOfMemoryError: Java heap space

-Xmx を4倍にすると、確保できる量もほぼ4倍になりました。 ここで大事なのは keep というリストが配列を参照し続けている点です。参照が残っているオブジェクトはGCの回収対象にならないため、ヒープが埋まっていきます。ループの中で new long[1_000_000] を作るだけで参照を残さなければ、GCが回収するのでエラーにはなりません。

一度に大きな配列を要求した場合と、配列の長さの上限を超えた場合のエラー文も確認しました。

public class HeapRaw {
    public static void main(String[] args) {
        long[] big = new long[100_000_000];   // 約800MBを一度に要求
        System.out.println(big.length);
    }
}
$ java -Xmx256m HeapRaw
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at HeapRaw.main(HeapRaw.java:3)
(new int[Integer.MAX_VALUE] を実行した場合)
Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit
    at HeapLimit.main(HeapLimit.java:3)

2つ目は「ヒープが足りない」のではなく「配列の長さがJVMの扱える上限を超えた」という別のメッセージです。-Xmx を増やしても変わらないので、長さの計算を見直します。

ヒープの中身を後から調べる:ヒープダンプ

-XX:+HeapDumpOnOutOfMemoryError を付けると、OutOfMemoryErrorが起きた時点のヒープをファイルに書き出せます。

$ java -Xmx256m -XX:+HeapDumpOnOutOfMemoryError HeapRaw
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid27709.hprof ...
Heap dump file created [3449916 bytes in 0.007 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at HeapRaw.main(HeapRaw.java:3)

カレントディレクトリに java_pid(プロセスID).hprof が作られます。出力先は -XX:HeapDumpPath で指定できます(javaコマンドの公式マニュアルで確認)。このファイルを解析ツールで開くと、どのクラスのオブジェクトがヒープを占めているかを調べられます。

メタスペースがあふれる例:Metaspace の OutOfMemoryError を再現する

メタスペースはクラスを読み込むたびに使われます。通常のプログラムではクラスの数は限られますが、実行中にクラスを作り続ける仕組み(動的なクラス生成や、クラスローダーを作り直す処理)があると増えていきます。

ここでは、小さなクラス Blank のクラスファイルを、毎回新しいクラスローダーで定義し直すことで「クラスの大量生成」を再現します。外部ライブラリは不要です。

// Blank.java
public class Blank {
    int a; long b; String c;
    void run() {}
}
// MetaDemo.java
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;

public class MetaDemo {
    // 同じクラスファイルを、毎回別のクラスローダーで定義する
    static class OneShotLoader extends ClassLoader {
        Class<?> define(byte[] bytes) {
            return defineClass("Blank", bytes, 0, bytes.length);
        }
    }

    public static void main(String[] args) throws Exception {
        byte[] bytes;
        try (InputStream in = MetaDemo.class.getResourceAsStream("/Blank.class")) {
            bytes = in.readAllBytes();
        }
        List<Class<?>> keep = new ArrayList<>();
        try {
            while (true) {
                keep.add(new OneShotLoader().define(bytes));
            }
        } catch (OutOfMemoryError e) {
            int n = keep.size();
            keep.clear();      // 参照を外してから
            System.gc();       // クラスをアンロードさせ、表示に使う分を空ける
            System.out.println("定義できたクラス: " + n + " 個");
            System.out.println("捕まえたエラー: " + e);
        }
    }
}
$ javac Blank.java MetaDemo.java

$ java -XX:MaxMetaspaceSize=32m MetaDemo
定義できたクラス: 5406 個
捕まえたエラー: java.lang.OutOfMemoryError: Metaspace

$ java -XX:MaxMetaspaceSize=64m MetaDemo
定義できたクラス: 10846 個
捕まえたエラー: java.lang.OutOfMemoryError: Metaspace

上限を2倍にすると、定義できるクラス数もほぼ2倍になりました。 クラスローダーが違えば、同じ名前・同じ中身でも別のクラスとして扱われ、そのぶんメタスペースを使います。

try-catchなしで実行すると、次のように defineClass の中でエラーになります。

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
    at java.base/java.lang.ClassLoader.defineClass1(Native Method)
    at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1027)
    at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:889)
    at MetaRaw$OneShotLoader.define(MetaRaw.java:7)
    at MetaRaw.main(MetaRaw.java:13)

catchの中でもう一度エラーになった例

最初は keep.clear() と System.gc() を入れずに実行したところ、catchブロックの中で2回目のOutOfMemoryErrorが出ました。

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
    at java.base/java.lang.invoke.MethodHandleNatives.resolve(Native Method)
    at java.base/java.lang.invoke.MemberName$Factory.resolve(MemberName.java:962)
    (以下略)

文字列を + でつなぐ処理は、初回に内部でクラスを用意します。メタスペースが満杯のままだと、その準備ができずに止まります。参照を外してGCを実行すると、不要になったクラスローダーごとクラスがアンロードされ、メタスペースが空きます。 メタスペースもGCと連動して回収される、という性質がこの結果から確かめられます。

エラー文とJVMオプションの対応表

3つのエラーをまとめます。エラー文はすべて今回の実行で得たものです。

エラー文 あふれた領域 よくある原因 最初に見直すこと 上限を変えるオプション
java.lang.StackOverflowError スタック 終了条件のない再帰、深すぎる再帰、互いに呼び合うメソッド スタックトレースで繰り返されているメソッドと終了条件 -Xss(例:-Xss4m)
java.lang.OutOfMemoryError: Java heap space ヒープ 大量のデータを一度に読み込む、コレクションに追加し続けて参照が残る 参照を持ち続けているコレクション、一度に扱うデータ量 -Xmx(例:-Xmx512m)
java.lang.OutOfMemoryError: Metaspace メタスペース 実行中のクラス生成、クラスローダーが残り続ける クラスを生成・再読み込みしている処理 -XX:MaxMetaspaceSize(例:-XX:MaxMetaspaceSize=256m)
java.lang.OutOfMemoryError: Requested array size exceeds VM limit (配列の長さの上限) 配列の長さの計算が想定より大きい 長さを決めている計算式 なし(コードを見直す)

オプションの書き方は次のとおりです。javaコマンドの公式マニュアル(JDK 21)で、-Xss と -Xmx の説明を確認しました。

オプション 意味 単位の書き方 公式マニュアルの記載
-Xss<サイズ> スレッドのスタックサイズ k・m・g(大文字も可) 既定値はプラットフォームによる。Linux/x64 は 1024KB、Linux/Aarch64 と macOS/Aarch64 は 2048KB、macOS/x64 は 1024KB
-Xmx<サイズ> ヒープの最大サイズ k・m・g(大文字も可) 1024の倍数で2MBより大きい値。既定値はシステム構成をもとに実行時に選ばれる
-Xms<サイズ> ヒープの最小・初期サイズ k・m・g(大文字も可) 1024の倍数で1MBより大きい値
-XX:MaxMetaspaceSize=<サイズ> メタスペースの最大サイズ k・m・g (今回は実行結果で動作を確認)

上限を増やすのは「必要な量が本当にそれだけある」ときの対処です。 参照が残り続けて増えていく場合は、上限を上げてもエラーになるまでの時間が延びるだけなので、先に原因の処理を見直します。

各領域の実サイズを確かめる手順

手順1:java -XX:+PrintFlagsFinal で設定値を見る

-XX:+PrintFlagsFinal は、JVMのすべてのフラグと最終的な値を表示します。数百行あるので、grep で絞り込みます。

java -XX:+PrintFlagsFinal -version | grep -E ' (MaxHeapSize|InitialHeapSize|ThreadStackSize|MetaspaceSize|MaxMetaspaceSize) '
   size_t InitialHeapSize                          = 98566144                                  {product} {ergonomic}
   size_t MaxHeapSize                              = 1568669696                                {product} {ergonomic}
   size_t MaxMetaspaceSize                         = 18446744073709551615                      {product} {default}
   size_t MetaspaceSize                            = 22020096                                  {product} {default}
     intx ThreadStackSize                          = 1024                                   {pd product} {default}
フラグ 値 読み方
MaxHeapSize 1568669696 ヒープの上限(約1.46GB)。{ergonomic} はJVMが環境から自動で決めた値
InitialHeapSize 98566144 ヒープの初期サイズ(94MB)
ThreadStackSize 1024 スタックのサイズ。単位はKB
MaxMetaspaceSize 18446744073709551615 64ビットの最大値。上限を設けていないという意味
MetaspaceSize 22020096 約21MB。上限ではない(上限は MaxMetaspaceSize)

オプションを付けて実行すると、右端が {command line} に変わり、指定が反映されたことを確かめられます。

$ java -Xss2m -Xmx512m -XX:MaxMetaspaceSize=128m -XX:+PrintFlagsFinal -version | grep -E ' (MaxHeapSize|ThreadStackSize|MaxMetaspaceSize) '
   size_t MaxHeapSize                              = 536870912                                 {product} {command line}
   size_t MaxMetaspaceSize                         = 134217728                                 {product} {command line}
     intx ThreadStackSize                          = 2048                                   {pd product} {command line}

手順2:jcmd VM.native_memory で領域ごとの使用量を見る

動いているJavaプロセスの内訳を見るには、Native Memory Tracking(NMT)を使います。公式マニュアルによると -XX:NativeMemoryTracking の既定は off で、summary を指定するとJavaヒープ・クラス・コード・スレッドといったJVMのサブシステム単位で集計されます。起動時にこのオプションを付けておく必要があります。

確認用に、60秒待つだけのプログラムを用意します。

public class Sleep {
    public static void main(String[] args) throws Exception {
        System.out.println("pid=" + ProcessHandle.current().pid());
        Thread.sleep(60_000);
    }
}
# 端末1:NMTを有効にして起動
java -XX:NativeMemoryTracking=summary -Xmx256m Sleep

# 端末2:表示された pid を指定(jcmd -l でも一覧できる)
jcmd 27731 VM.native_memory summary scale=MB
Total: reserved=1665MB, committed=159MB

-                 Java Heap (reserved=256MB, committed=96MB)
                            (mmap: reserved=256MB, committed=96MB, at peak)

-                     Class (reserved=1024MB, committed=0MB)
                            (classes #697)
                            (  instance classes #590, array classes #107)

-                    Thread (reserved=18MB, committed=1MB)
                            (threads #18)
                            (stack: reserved=18MB, committed=1MB, peak=1MB)

-                 Metaspace (reserved=64MB, committed=0MB)
                            (mmap: reserved=64MB, committed=0MB, at peak)

(主要な項目だけを抜粋しています。ほかに Code、GC、Symbol などの項目が表示されます。)

項目 表示 読み方
Java Heap reserved=256MB, committed=96MB -Xmx256m のとおり256MBを予約し、今は96MBを実際に確保している
Thread threads #18、stack: reserved=18MB 18本のスレッドそれぞれに1MB(1024KB)のスタックを予約。スタックはスレッドの数だけあることが数字で見える
Class classes #697、reserved=1024MB 読み込まれたクラスは697個。クラス用の領域として1GBのアドレス空間を予約
Metaspace reserved=64MB, committed=0MB クラス以外のメタデータ用。1MB未満なので0MBと表示

reserved は「使うかもしれない範囲として予約したアドレス空間」、committed は「実際に確保したメモリ」です。reservedが大きくても、その分のメモリをすぐに使っているわけではありません。

main メソッドしかないプログラムでもスレッドが18本あるのは、GCやコンパイルなどJVM内部のスレッドが含まれるためです。

NMTを付けずに起動したプロセスに同じコマンドを実行すると、次の1行が返ります。

Native memory tracking is not enabled

手順3:NMTなしでも使える jcmd コマンド

起動時のオプションなしで使えるコマンドもあります。

$ jcmd 19005 GC.heap_info
 garbage-first heap   total 98304K, used 1933K [0x00000000f0000000, 0x0000000100000000)
  region size 1024K, 1 young (1024K), 0 survivors (0K)
 Metaspace       used 348K, committed 512K, reserved 1114112K
  class space    used 25K, committed 128K, reserved 1048576K
$ jcmd 19005 VM.flags
(1行に並んだフラグのうち、関係するもの)
-XX:MaxHeapSize=268435456
-XX:ThreadStackSize=1024
知りたいこと コマンド 起動時のオプション
設定値(上限)の一覧 java -XX:+PrintFlagsFinal -version 不要
動作中プロセスの設定値 jcmd <pid> VM.flags 不要
ヒープとメタスペースの現在の使用量 jcmd <pid> GC.heap_info 不要
メタスペースの詳しい内訳 jcmd <pid> VM.metaspace 不要
スレッドのスタックを含む領域ごとの内訳 jcmd <pid> VM.native_memory summary -XX:NativeMemoryTracking=summary が必要
コードから見たヒープの上限 Runtime.getRuntime().maxMemory() 不要

つまずきやすいポイントと確認のしかた

思い込み 実際 この記事での確認箇所
プリミティブ型は必ずスタックに置かれる インスタンス変数やstatic変数のプリミティブ、配列の要素はヒープにある 早見表、MemoryMapの注記
参照型の変数にはオブジェクトが入っている 入っているのは参照。オブジェクト本体はヒープ lit1とmadeの比較結果
スタックはプログラムに1つ スレッドごとに1本。NMTでは18スレッドで18MB Threadの項目、Thの実行結果
-Xmx を増やせばどのメモリエラーも解消する スタックは -Xss、メタスペースは -XX:MaxMetaspaceSize。領域ごとに別のオプション 対応表
メタスペースはヒープの一部 HotSpotではネイティブメモリ上の別の領域。-Xmx の対象外 NMTでJava HeapとMetaspaceが別項目
static変数はメタスペースに置かれる クラスのメタデータはメタスペース、static変数の値はヒープ(JEP 122の記載) 3つの領域の図

どの領域の問題かを切り分けるチェックリスト

  • [ ] エラー文は StackOverflowError か、OutOfMemoryError か。後者なら : の後ろ(Java heap space/Metaspace)まで読む。
  • [ ] StackOverflowError なら、スタックトレースで繰り返されているメソッドを探し、終了条件を確認する。
  • [ ] Java heap space なら、参照を持ち続けているコレクションや、一度に読み込むデータ量を確認する。必要に応じてヒープダンプを取る。
  • [ ] Metaspace なら、実行中にクラスを生成・再読み込みしている処理がないか確認する。
  • [ ] java -XX:+PrintFlagsFinal -version で、今の上限値を数字で把握する。
  • [ ] 原因を見直したうえで量が足りない場合に、対応するオプションで上限を調整する。

よくある質問

Q. ローカル変数のStringはスタックとヒープのどちらに保存されますか? A. 変数そのもの(参照)はスタックのフレームに、文字列の実体はヒープにあります。String s = "task"; と書いた場合、s に入るのはヒープ上の文字列を指す参照です。同じ文字列リテラルは1つの実体を共有するため、今回の実行では lit1 == LABEL が true になりました。

Q. メタスペースには何が入りますか?ヒープとは別ですか? A. クラスのメタデータ(クラス名、フィールドやメソッドの定義、メソッドのコードなど)が入ります。HotSpotではネイティブメモリ上に確保され、ヒープとは別の領域です。そのため -Xmx では上限が決まらず、-XX:MaxMetaspaceSize で指定します。確認環境の既定値は上限なしでした。

Q. -Xss を大きくすれば StackOverflowError は解消しますか? A. 終了条件のある深い再帰なら、-Xss を増やすことで最後まで実行できます(1から100万までの再帰は -Xss512m で完走しました)。終了条件のない再帰は、どれだけ大きくしてもいずれ上限に達します。スタックはスレッドごとに確保されるので、まずループへの書き換えや終了条件の見直しを検討するのがおすすめです。

Q. 配列はプリミティブ型の配列でもヒープに置かれますか? A. はい。JVMSでは、すべてのクラスインスタンスと配列がヒープに割り当てられると定義されています。int[] numbers = {1, 2, 3}; の場合、numbers という参照はスタックに、3つのintの並びはヒープにあります。

まとめ

  • スタックはスレッドごと、ヒープとメタスペースは全スレッド共有。 スタックにはメソッド呼び出しごとのフレーム、ヒープにはオブジェクトと配列、メタスペースにはクラスのメタデータが置かれる。
  • 変数の保存場所は「どこに属するか」で決まる。 ローカル変数と引数はスタック、インスタンス変数はヒープのオブジェクト内、static変数はヒープのクラスを表すオブジェクト内。
  • 参照型の変数に入るのは参照だけ。 オブジェクト・配列・文字列の実体は常にヒープにある。
  • エラー文で領域を見分ける。 StackOverflowError はスタック、Java heap space はヒープ、Metaspace はメタスペース。
  • 領域ごとにオプションが違う。 スタックは -Xss、ヒープは -Xmx、メタスペースは -XX:MaxMetaspaceSize。
  • 実サイズは数字で確かめられる。 設定値は java -XX:+PrintFlagsFinal、動作中の内訳は jcmd <pid> VM.native_memory summary(起動時に -XX:NativeMemoryTracking=summary)と jcmd <pid> GC.heap_info。
  • 上限を増やす前に原因を見る。 再帰の終了条件、参照を持ち続けるコレクション、クラスを作り続ける処理を先に見直す。

参考・出典

  • The Java Virtual Machine Specification, Java SE 21 Edition「2.5 Run-Time Data Areas」「2.6 Frames」 https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-2.html
  • The java Command(JDK 21) https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html
  • JEP 122: Remove the Permanent Generation https://openjdk.org/jeps/122

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java ヒープとスタックの違い|変数の保存場所とメタスペースを図解」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?