「変数には値が入っている」——最初に習うこの言い方が、あとで必ず足を引っかけてきます。
== で比較したら false になる。メソッドに渡した変数が書き換わっている。あるいは、書き換わっていない。nullにしたはずのオブジェクトがメモリから消えない。
これらは全部、スタックとヒープで何が起きているかを1枚の図にすれば同時に解けます。
下のページでは、コードを1行ずつ実行しながら、その図が動きます。矢印が「参照」、灰色になった箱が「どこからも辿れなくなったもの」です。GCの判定は説明用の演出ではなく、スタックとstaticを出発点に参照を実際にたどって計算しています。
INSIDE THE JVM MEMORY
変数は、箱そのものではない
コードを1行ずつ実行して、スタックとヒープに何が起きるかを見ます。矢印が「参照」、灰色になった箱が「もう誰も辿れないもの」です。
コード
メモリ
スタックメソッドごとの作業台
ヒープnewで作られた箱の置き場
ヒープの中は3つの区画に分かれています。新しい箱はまずEdenに置かれ、GCを生き延びるたびに右の区画へ引っ越します。
Edenエデン=新しい箱が最初に置かれる場所
SurvivorGCを1回生き延びた箱の引っ越し先
Old何度も生き延びた、長生きの箱の置き場
到達できるかどうかは、スタックとstaticを出発点にして、矢印を実際にたどって判定しています(マーク&スイープ)。
PARTS LIST /目次
スタックとヒープの役割分担
スタックは、メソッドごとの作業台です。メソッドを呼ぶと1枚積まれ、終わると捨てられます。ここに置かれるのは、intのような基本型の値そのものと、オブジェクトの置き場所(参照)だけです。
ヒープは、newで作られた箱の置き場です。こちらはメソッドが終わっても消えません。誰からも辿れなくなったとき、GCが回収します。
この線引きが、次の3つの「なぜ」をまとめて説明します。
「変数は箱ではない」がすべての出発点
String b = a; でコピーされるのは、箱の中身ではなく置き場所です。上のページの1つめのタブで、矢印が2本になって同じ箱を指すのが見えます。
だから == は「矢印の先が同じか」を、equals は「箱の中身が同じか」を比べています。同じ文字列でもnewすれば別の箱になるので、== は false。この1枚を覚えておけば、文字列比較で悩むことはなくなります。
メソッド呼び出しも同じ理屈です。Javaは引数を必ずコピーして渡します。intなら値のコピーなので呼び出し元は無傷、オブジェクトなら場所のコピーなので、たどった先の箱を書き換えれば呼び出し元からも見えます。「参照渡し」と呼ばれがちですが、正確には参照を値渡ししている、というのが2つめのタブで見えるものです。
GCが見ているのは「たどれるか」だけ
3つめのタブがGCの本質です。d2 = null; としても、d1.next から辿れるうちは回収されません。逆に、変数が1つも指していなくても、どこか1か所から辿れれば生き続けます。
GCが数えているのは参照カウントではなく、到達可能性です。スタックとstaticを出発点(GCルート)にして矢印をたどり、たどり着けなかったものだけを回収する。これがマーク&スイープで、上のページでも実際にその計算をしています。
だから、互いに参照し合っている2つのオブジェクトも、外から辿れなくなれば両方まとめて回収されます。循環参照でメモリが残らないのはこのためです。
メモリリークは「消えないゴミ」ではなく「捨て忘れ」
4つめのタブは、staticなリストに入れ続けるだけのコードです。GCを何度押しても、ひとつも減りません。staticはプログラムが動いているあいだ、ずっとGCルートであり続けるからです。
Javaのメモリリークは、「GCが壊れている」のではなく「まだ辿れてしまう」状態です。キャッシュ、リスナー登録、ThreadLocal、staticなMap。どれも入れたものを取り出し忘れると、同じ図になります。
対策も図から素直に出てきます。使い終わったらremoveする、参照を持つ期間を短くする、あるいはWeakHashMapのように「他から辿られなくなったら消えていい」参照を使う。どれも、矢印を減らす話です。
世代別GCとStop the World
5つめのタブでは、ヒープの中が3つの区画に分かれます。名前だけ見ても意味が分からないので、先に整理しておきます。
- Eden(エデン):
newした箱が最初に置かれる場所。エデンの園=生まれる場所、という名前です。 - Survivor(サバイバー):GCを1回生き延びた箱の引っ越し先。生存者、という意味そのままです。
- Old(オールド):何度も生き延びた、長生きの箱が最後に落ち着く場所。老年世代とも呼ばれます。
なぜわざわざ分けるのか。「ほとんどのオブジェクトは生まれてすぐ死ぬ」という経験則があるからです。ループの中で作った一時オブジェクトは、次の周回には用済みになります。生き残るのはごく一部です。
新しい箱はまずEdenに置かれ、Edenが埋まるとMinor GCが走ります。生き残った少数だけをSurvivorへ移し、生き残り続けたものをOldへ昇格させる。死ぬ前提の場所だけを頻繁に掃除するので、毎回ヒープ全体を見に行かずに済みます。
そしてGCの最中は、アプリケーションのスレッドが止まります。上のページで赤いバーが伸びるのがそれで、これがStop the Worldです。Eden中心に新世代だけを見るMinor GCは短く、Oldまで含むFull GCは長い。「たまにアプリが数秒固まる」の正体は、たいていこちらです。
図が読めると、対処の順番が変わる
メモリが増え続けるとき、最初にやるべきはヒープサイズを増やすことではありません。どこからの矢印が切れていないのかを探すことです。ヒープを増やしても、捨て忘れが直らなければ到達するまでの時間が延びるだけです。
逆に、一時的に大量のオブジェクトを作る処理でGCが頻発しているなら、それはEdenの広さやアルゴリズムの選択の話になります。同じ「メモリが苦しい」でも、見る場所がまったく違うわけです。
この図が頭に入っていれば、==の罠も、引数の書き換わりも、リークも、GCログの読み方も、全部ひとつの絵の上に載ります。まずは上のページで、矢印が切れる瞬間だけでも見ていってください。
| REV. | DATE | 内容 |
|---|---|---|
| 2 | 2026-10-06 | 記載内容の事実確認と修正 |
| 1 | 2026-09-23 | 初版 |


