Java | Java 詳細・モダン文法:JVM・パフォーマンス – JVM メモリ構造

Java Java
スポンサーリンク

JVM メモリ構造をざっくり一枚の絵にする

まずイメージからいきます。
JVM のメモリは、大きく言うと次のような「部屋」に分かれています。

  • ヒープ(Heap):new したオブジェクトが住む場所
  • スタック(Stack):メソッド呼び出しの一時的な作業台
  • メソッド領域/メタスペース:クラスやメソッドの情報が置かれる場所
  • そのほか、スレッドごとの小さな領域やネイティブコード用の領域

この「部屋の役割」が分かると、
「なぜ GC が必要なのか」「なぜスタックオーバーフローが起きるのか」
といった話が一気につながります。


ヒープ(Heap):new したものが全部集まる「オブジェクトの街」

何が置かれる場所か

ヒープは、new したオブジェクトや配列が置かれる場所です。
new String("hello") も、new int[10] も、new ArrayList<>() も、全部ヒープに置かれます。

Java の「参照型」は、基本的にここに住みます。
変数が持っているのは「ヒープ上のオブジェクトへの参照(住所)」です。

String s = new String("hello");
Java

このとき、
s という変数はスタックに置かれ、
実体の "hello" オブジェクトはヒープに置かれます。

ヒープはなぜ GC とセットなのか

ヒープに置かれたオブジェクトは、
「もうどこからも参照されなくなったら、GC が回収してくれる」
というルールで管理されています。

だから Java では、C のように freedelete を書かなくていい。
代わりに、ヒープは「GC が掃除する前提でどんどん散らかる場所」だと思ってください。

ヒープの中の「世代」:Young / Old

実際の JVM では、ヒープはさらに「世代」に分かれます。

  • Young(若い世代):生まれたばかりのオブジェクト
  • Old(年老いた世代):長生きしているオブジェクト

多くのオブジェクトは「すぐ死ぬ(すぐ使われなくなる)」という経験則があるので、
Young 領域は「頻繁に、素早く GC する」ように設計されています。

Young で何回か GC を生き延びたオブジェクトは、Old に昇格します。
Old は「長生き組」なので、GC の頻度は低いけれど、一回の GC は重めです。

この「世代別 GC」の仕組みが、Java のパフォーマンスチューニングの土台になります。


スタック(Stack):メソッド呼び出しの「作業台」

スレッドごとに一つずつある

スタックは「スレッドごと」に用意されます。
スレッドが一つ増えると、そのスレッド専用のスタックが一つ増えます。

スタックには、メソッド呼び出しごとに「スタックフレーム」と呼ばれる枠が積み上がります。

void a() {
    int x = 10;
    b(x);
}

void b(int y) {
    int z = y + 1;
}
Java

a() を呼ぶとき、a 用のフレームがスタックに積まれ、
その中にローカル変数 x や引数などが置かれます。

a の中から b を呼ぶと、b 用のフレームがその上に積まれ、
yz がそこに置かれます。

メソッドが終わると、そのフレームはスタックからポンと外されます。
だから、スタック上のデータは「メソッドの寿命と一緒」です。

スタックに置かれるもの・置かれないもの

スタックに置かれるのは、主に次のようなものです。

  • プリミティブ型のローカル変数(int, long, double など)
  • 参照型の「参照(ポインタ)」
  • 戻り先アドレスや一時的な計算用の値

new したオブジェクトの「中身」はスタックには置かれません。
スタックにあるのは「そのオブジェクトへの参照」だけです。

スタックオーバーフローはなぜ起きるのか

スタックはサイズに上限があります。
再帰呼び出しを深くしすぎると、フレームが積み上がりすぎて、
スタックの上限を超えます。

void recurse() {
    recurse();
}
Java

これを呼ぶと、延々とフレームが積まれていき、
StackOverflowError が発生します。

「スタックはメソッド呼び出しのための有限の積み重ねスペース」
というイメージを持っておくと、このエラーの意味が腑に落ちます。


メソッド領域/メタスペース:クラスの「設計図置き場」

何が置かれる場所か

JVM は、クラスをロードするときに、
そのクラスの「メタ情報(設計図)」を専用の領域に置きます。

そこには、例えばこんな情報が入っています。

  • クラス名、パッケージ名
  • フィールドやメソッドの一覧
  • メソッドのバイトコード
  • 定数プール(リテラル文字列など)

昔の JVM ではこれを「メソッド領域(Method Area)」や「PermGen」と呼んでいましたが、
今の HotSpot JVM では「メタスペース(Metaspace)」という形でネイティブメモリ側に置かれます。

なぜここを意識する必要があるのか

普通のアプリではあまり意識しませんが、
大量のクラスを動的に生成・ロードするようなフレームワーク(JPA、Proxy、動的クラス生成など)を使うと、
この領域がパンパンになって OutOfMemoryError を起こすことがあります。

「オブジェクトが多すぎてヒープが足りない」のとは別に、
「クラスの設計図が多すぎてメタスペースが足りない」というパターンもある、
ということだけ頭の片隅に置いておくと、エラーの意味が読みやすくなります。


簡単なコードで「どこに何が置かれるか」を追ってみる

例コード

public class MemoryExample {
    public static void main(String[] args) {
        int a = 10;
        String s = new String("hello");
        int[] arr = new int[3];
        arr[0] = a;
        print(s, arr);
    }

    static void print(String msg, int[] values) {
        int len = values.length;
        System.out.println(msg + " : " + len);
    }
}
Java

これをメモリ構造の観点で眺めてみます。

main が動き始めたとき

JVM 起動時に、MemoryExample クラスの情報がメタスペースにロードされます。
main が呼ばれると、main 用のスタックフレームが積まれます。

そのフレームの中に、ローカル変数として a, s, arr が置かれます。

  • a:スタック上の int 値(10)
  • s:ヒープ上の String オブジェクトへの参照
  • arr:ヒープ上の int[] への参照

new String("hello")new int[3] の実体はヒープに置かれます。

print を呼んだとき

print(s, arr) を呼ぶと、print 用のスタックフレームがその上に積まれます。

そのフレームには、引数 msg, values、ローカル変数 len が置かれます。

  • msgs と同じ String への参照
  • valuesarr と同じ int[] への参照
  • lenvalues.length の結果(3)

ここでも、ヒープ上のオブジェクトは増えていません。
増えたのは「参照」と「プリミティブ値」だけです。

print が終わると、そのフレームはスタックから消えます。
main が終わると、main のフレームも消えます。

その後、sarr を指していた参照も消えるので、
ヒープ上の Stringint[] は「どこからも参照されない」状態になります。
次の GC で回収対象になります。


パフォーマンスの観点から見た JVM メモリ構造のポイント

「短命オブジェクトを大量に作る」と何が起きるか

短命なオブジェクト(すぐに使い捨てるオブジェクト)を大量に作ると、
Young 領域での GC が頻繁に起きます。

例えば、巨大なループの中で毎回 new StringBuilder() しているようなコードは、
GC の負荷を上げる典型例です。

ただし、JVM は短命オブジェクトの回収はかなり得意なので、
「短命オブジェクトが多い=即アウト」ではありません。

大事なのは、

  • 不必要なオブジェクトを作りすぎていないか
  • 長生きする大きなオブジェクトを無駄に残していないか

を意識することです。

「参照を持ち続ける」と GC できない

ヒープのオブジェクトは「どこからも参照されなくなったとき」に初めて GC 対象になります。

逆に言うと、
「もう使わないのに、どこかのコレクションに入れっぱなし」
「static フィールドでずっと参照を持ち続けている」
といった状態だと、GC はそのオブジェクトを回収できません。

これがいわゆる「メモリリーク」の正体です。
C のように「解放し忘れ」ではなく、
「参照を持ち続けてしまった結果、GC が回収できない」という形で起きます。

スタックは「軽いが有限」、ヒープは「重いが柔軟」

スタック上の変数は、メソッドが終われば自動的に消えます。
GC の対象にもなりません。
その意味で「軽い」領域です。

ヒープは、GC の対象になる分、管理コストがあります。
でも、サイズは大きく、柔軟にオブジェクトを置けます。

「何でもかんでもヒープに置く」のではなく、
「一時的な計算はローカル変数(スタック)で済ませる」
という意識を持つと、自然と無駄なオブジェクトが減っていきます。


まとめ:JVM メモリ構造を自分の言葉で説明するなら

あなたの言葉でまとめると、こうなります。

「JVM のメモリは、
new したオブジェクトが住むヒープ、
メソッド呼び出しごとの一時的な作業台であるスタック、
クラスの設計図を置くメタスペース(メソッド領域)などに分かれている。

スタックはスレッドごとにあり、メソッドが終わればフレームごと消える。
ヒープは GC によって『もう参照されていないオブジェクト』が回収される。
ヒープは Young / Old の世代に分かれ、短命オブジェクトと長命オブジェクトを分けて効率よく GC している。

パフォーマンスやメモリトラブルを考えるときは、
『この値はスタックかヒープか』『このオブジェクトはどれくらい生きるか』
『どこが参照を持ち続けているか』を意識してコードを見ることが大事になる。」

タイトルとURLをコピーしました