ヒープとスタックを「机」と「倉庫」でイメージする
まずはイメージからいきましょう。
スタックは「今やっている作業のための机」。
ヒープは「作ったモノを置いておく大きな倉庫」。
机(スタック)の上には、今呼び出しているメソッドのローカル変数や一時的な値が並びます。
倉庫(ヒープ)には、new したオブジェクトや配列がどんどん置かれていきます。
この二つの役割の違いが分かると、
「なぜ再帰しすぎると StackOverflowError になるのか」
「なぜ GC が必要なのか」
といった話が一気に腑に落ちます。
スタックとは何か:メソッド呼び出しの「作業机」
スレッドごとに一つずつある
スタックは「スレッドごと」に一つずつ用意されます。
スレッドが増えれば、その分スタックも増えます。
メソッドを呼び出すたびに、そのメソッド専用の「スタックフレーム」という小さな箱が、スタックの上に積み上がります。
メソッドが終わると、そのフレームはポンと取り除かれます。
つまりスタックは、「今動いているメソッドたちの“呼び出し履歴”と“ローカル変数”が積み重なっている場所」です。
何がスタックに置かれるのか
スタックフレームの中には、主に次のようなものが入ります(言葉でイメージしてください)。
メソッドの引数。
メソッド内のローカル変数。
計算途中の一時的な値。
戻り先の情報。
プリミティブ型(int, long, double など)のローカル変数は、その値がそのままスタックに置かれます。
参照型(String, List など)のローカル変数は、「ヒープ上のオブジェクトへの参照(住所)」だけがスタックに置かれます。
オブジェクトの“中身”はスタックには来ません。
スタックにあるのは、あくまで「どのオブジェクトを指しているか」という情報だけです。
スタックオーバーフローはなぜ起きるのか
スタックにはサイズの上限があります。
再帰呼び出しを深くしすぎると、フレームが積み上がりすぎて、スタックがいっぱいになります。
public class StackOverflowSample {
static void recurse(int n) {
System.out.println(n);
recurse(n + 1); // 終わらない再帰
}
public static void main(String[] args) {
recurse(0);
}
}
Javaこのコードでは、recurse が呼ばれるたびに新しいスタックフレームが積まれます。
戻ることなく呼び続けるので、いつかスタックの上限を超え、StackOverflowError が発生します。
「スタックはメソッド呼び出しのための有限の積み重ねスペース」
というイメージを持っておくと、このエラーの意味がよく分かります。
ヒープとは何か:new したものが住む「オブジェクト倉庫」
何がヒープに置かれるのか
ヒープには、new したオブジェクトや配列が置かれます。
String s = new String("hello");
int[] arr = new int[3];
List<String> list = new ArrayList<>();
Javaここで作られた String の実体、int[] の実体、ArrayList の実体は、すべてヒープに置かれます。
変数 s, arr, list はスタックにあり、それぞれ「ヒープ上のどのオブジェクトを指しているか」という参照を持っています。
つまり、ヒープは「参照型の実体が住む場所」です。
ヒープと GC(ガーベジコレクション)
ヒープに置かれたオブジェクトは、プログラマが手動で解放する必要はありません。
どこからも参照されなくなったタイミングで、GC が自動的に回収してくれます。
例えば、次のようなコードを考えます。
void sample() {
String s = new String("hello");
// ここで s を使う
}
// ここで sample が終わる
Javasample が終わると、スタック上の s は消えます。
それに伴って、ヒープ上の "hello" オブジェクトは「どこからも参照されていない」状態になります。
次に GC が走ったとき、このオブジェクトは回収対象になります。
この「参照が残っている限り GC されない」という性質が、メモリリークの原因にもなります。
もう使わないオブジェクトを、static フィールドや長寿命のコレクションで持ち続けていると、GC はそれを回収できません。
スタックとヒープを一つのコードで追いかけてみる
例コード
public class HeapStackExample {
public static void main(String[] args) {
int a = 10;
String s = new String("hello");
int[] arr = new int[2];
arr[0] = a;
process(s, arr);
}
static void process(String msg, int[] values) {
int len = values.length;
System.out.println(msg + " : " + len);
}
}
Javaこのコードが動くとき、スタックとヒープで何が起きているかを言葉で追ってみます。
main が動き始めたとき
main が呼ばれると、main 用のスタックフレームが積まれます。
そのフレームの中に、ローカル変数 a, s, arr が置かれます。
a はスタック上の int 値(10)。s はヒープ上の String("hello") への参照。arr はヒープ上の int[2] への参照。
new String("hello") と new int[2] の実体は、ヒープに確保されています。
process を呼んだとき
process(s, arr) を呼ぶと、process 用のスタックフレームがその上に積まれます。
そのフレームには、引数 msg, values とローカル変数 len が置かれます。
msg は、s と同じ String への参照。values は、arr と同じ int[] への参照。len は values.length の結果(2)。
ここでも、ヒープ上のオブジェクトは増えていません。
増えたのは「参照」と「プリミティブ値」だけです。
process が終わると、そのフレームはスタックから消えます。main が終わると、main のフレームも消えます。
その結果、s や arr を指していた参照も消えるので、
ヒープ上の String("hello") と int[2] は「どこからも参照されない」状態になります。
次の GC で回収されます。
パフォーマンスの観点から見たヒープ / スタック
スタックは「軽いが有限」
スタック上のデータは、メソッドが終われば自動的に消えます。
GC の対象にもなりません。
その意味で、とても軽い領域です。
ただし、サイズには上限があり、深い再帰や巨大なローカル配列などで簡単に溢れます。
「メソッド呼び出しの深さ」と「ローカル変数のサイズ」は、スタックの容量と直結します。
ヒープは「大きいが GC コストがある」
ヒープは大きく、柔軟にオブジェクトを置けますが、
その分 GC のコストがかかります。
短命なオブジェクトを大量に作ると、Young 領域での GC が頻繁に起きます。
長生きする大きなオブジェクトをたくさん残すと、Old 領域の GC が重くなります。
「何でもかんでも new する」のではなく、
「本当にオブジェクトが必要か」「再利用できないか」を意識することが、パフォーマンスに効いてきます。
まとめ:ヒープ / スタックを自分の言葉で説明するなら
あなたの言葉でまとめると、こうなります。
「スタックは、スレッドごとに用意された“メソッド呼び出しの作業机”で、
ローカル変数や引数、計算途中の値が積み重なっている。
メソッドが終われば、そのフレームごと片付くが、深すぎる再帰などで机がいっぱいになると StackOverflowError になる。
ヒープは、new したオブジェクトや配列が住む“大きな倉庫”で、
変数はその倉庫の中のオブジェクトへの参照をスタック上に持っている。
どこからも参照されなくなったオブジェクトは、GC によって自動的に回収される。
コードを書くときは、
『これは一時的な値だからスタックで済む』『これは長く生きるオブジェクトだからヒープに置かれる』
というイメージを持っておくと、メモリの動きとパフォーマンスがぐっと理解しやすくなる。」
