スタックとヒープ:僕のコードはメモリのどこに住んでいるのか

豊かさの中の忘却、忘れ去られたメモリ

大学のOS(オペレーティングシステム)やシステムプログラミングの授業で「メモリ構造」を習ったことはある。スタックだのヒープだのという用語が試験に出た記憶もある。

だが正直に言って、卒業するまでメモリを真剣に気にしたことはほとんどなかった。今の個人のノートPCはRAMが16GB、32GBあるのが当たり前の時代だ。学部レベルの課題でメモリが足りなくなることなんて事実上なかった。それに、Javaのような言語はガベージコレクタ(GC)が勝手に掃除までしてくれるから、あえて僕がメモリアドレスを意識する必要なんてなかったんだ。

そうやって僕は、ハードウェアの豊かさと言語の便利さに酔いしれ、エンジニアの基本素養である「メモリ感覚」を忘れたまま実務に飛び込んだ。

実務が教えてくれた手痛い教訓

学生時代にAtCoderなどでコーディングテストの練習をする時は、せいぜい配列に数字をいくつか入れて回すのが関の山だった。入力データが多くても10万件を超えなかった。しかし実務は違った。どんなに小さなスタートアップでも、データベース(DB)には数十万、数百万件の「生きた顧客データ」が蓄積されていた。

実務では、この膨大なデータを楽に扱うために「ORM (Object Relational Mapping)」という技術を使う。SQLクエリを直接書かなくても、Javaのオブジェクトのようにデータを扱えるようにしてくれる非常にありがたい道具だ。(Java陣営ではHibernate/JPAが代表的だ)

問題は、この道具が「便利すぎる」ことにあった。

メソッドを一つ呼ぶだけでDBにあるデータをListとして全部持ってきてくれるから、僕はその裏に隠されたデータの重さを想像できなかった。「顧客情報を取ってこい」とコードを一行書いただけなのに、数万人の情報とそれに紐づく注文履歴まで、全部メモリに汲み上げてしまったのだ。

結果は悲惨だった。32GBのRAMが一瞬で埋まった。サーバーはメモリを確保しようとするGC(清掃員)のせいで「激しいラグ」に苦しみ、ついには停止してしまった。

親しみのあるスタック、見知らぬヒープ

サーバーが吐き出したエラーログを眺めていた僕は、ある単語に目が釘付けになった。

java.lang.OutOfMemoryError: Java heap space

実は「スタック(Stack)」という単語には馴染みがあった。データ構造の時間に嫌というほど習ったし、エンジニアなら一日に一回はアクセスするサイト名(Stack Overflow)でもあるからだ。再帰関数をミスるとスタックが爆発する(Overflow)ということも常識として知っていた。

しかし、実務でサーバーが死ぬたびに出くわす犯人はスタックではなかった。エラーログは常に「ヒープ(Heap)」を指差していた。

「スタックがいっぱいになったんじゃなくて、ヒープ領域が足りないだって?」

瞬時に疑問がよぎった。スタックは分かるが、ヒープとは一体何で、なぜ実務でこんなに頻繁に爆発するのか? データ構造のあのヒープ(Heap)と同じものか? なぜ僕のコードはスタックではなくヒープをいじめているのか?

その疑問が、僕を再び埃をかぶった専攻書籍とGoogle検索の世界へと導いた。そして知ることになった。僕のコードが住んでいる家である「メモリ(RAM)」は、実は一つの巨大なワンルーム(Studio)ではなく、用途によって徹底的に分けられて運営される「分離型空間」だという事実を。

メモリは一つの巨大な空間ではない。速くて狭い「スタック」と、遅くて広い「ヒープ」に分かれる。

デジタル物流センターの作業台と倉庫

我々の「デジタル物流センター」の世界観に戻ってみよう。 ここで「RAM(メモリ)」は、作業員(CPU)が仕事をするために物を広げておく空間だ。しかしこの空間は、効率性のために二つに徹底的に分離されて運営される。

1. スタック (Stack):作業員の個人作業台

  • 特徴:作業員(CPU)の目の前にある、狭いが手の届きやすい高い机だ。
  • 用途:今すぐに遂行中の業務(関数)に必要な変数たち(ローカル変数、引数)だけを一時的に置いておく。
  • 寿命:業務(関数)が終われば、机の上をサッと片付ける(Pop)。管理が非常に簡単で速い。
  • 比喩:料理する時にまな板の上に置いた食材たち。料理が終わればすぐに片付ける。

2. ヒープ (Heap):共用物流倉庫

  • 特徴:作業台の裏側にある巨大な倉庫だ。空間は広いが、物を探しに行くのに時間がかかる。
  • 用途:サイズが大きかったり、長く保管しなければならないデータ(オブジェクト、インスタンス)を保存する。
  • 寿命:使用者が直接捨てるか、清掃員(Garbage Collector)が片付けるまではずっと残っている。
  • 比喩:冷蔵庫や食材倉庫。料理が終わっても、余った材料はそのまま残っている。
関数が終われば消える「スタック」、清掃員が片付けないと消えない「ヒープ」。

[Code Verification] エラーで証明する二つの世界

本当にメモリがこのように分かれているのだろうか? コードでわざとエラーを出してみれば、この二つの空間の存在を確実に証明できる。

1. スタックを爆発させる (StackOverflowError)

スタックは「作業台」だと言った。作業台は狭い。もし関数が終わらずに自分自身を呼び出し(再帰)続けると、作業台の上に書類が天井まで積み上がり、結局崩れ落ちる。

public class StackTest {
    public static void recursiveCall(int depth) {
        // 無限再帰呼び出し:関数が終わらずに作業台(Stack)に積み上がり続ける
        System.out.println("Stack Depth: " + depth);
        recursiveCall(depth + 1);
    }

    public static void main(String[] args) {
        recursiveCall(1);
    }
}

結果:数千回ほど回ってから StackOverflowError を吐いて落ちる。ヒープ領域がいくらガラガラに空いていても、スタック(作業台)がいっぱいになればプログラムは死ぬ。

2. ヒープを爆発させる (OutOfMemoryError)

今度は僕が体験した悪夢を再現してみよう。ORMを間違って使って数万個のオブジェクトを一度にロードする状況と同じように、巨大なリストを作り続けて倉庫(Heap)に詰め込んでみる。

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

public class HeapTest {
    public static void main(String[] args) {
        List<byte[]> warehouse = new ArrayList<>();
        while (true) {
            // 1MBのデータを生成し続け、倉庫(Heap)に積む
            // 実務例:DBから数万件のデータをページングなしで一度に照会した時に発生
            warehouse.add(new byte[1024 * 1024]);
        }
    }
}

結果java.lang.OutOfMemoryError: Java heap space。 これは作業台(Stack)の問題ではない。倉庫(Heap)にこれ以上荷物を積むスペースがなくて発生するエラーだ。自分が書いたコードがヒープをどういじめているのか、目で確認する瞬間だ。

清掃員の存在:Garbage Collector (GC)

ここで重要な違いがある。 スタックは関数が終われば「自動的に」空になる。気にする必要がない。 しかしヒープは、誰かが片付けてくれないとゴミが溜まり続ける。

C言語のような昔の言語は、開発者が直接 free() という命令語で掃除をしなければならなかった。忘れると倉庫がゴミで溢れかえって爆発する(メモリリーク)。

対してJava、Python、JavaScriptのような現代の言語は、「GC (Garbage Collector)」という専門の清掃員を雇った。 「このオブジェクト、もう誰も使ってないな?」 GCは定期的にヒープを巡回し、持ち主のいないオブジェクトを見つけて捨てる。おかげで我々はメモリ解放コードを書かなくて済む。

しかしタダ(無料)ではない。GCが大掃除をする瞬間、一時的に物流センターのすべての作業が止まる(Stop-the-world)。ゲームが急にカクついたり、サーバーが1秒くらい止まる現象は、まさにこの掃除時間のせいだ。

終わりに:見えないものを見る目

スタックとヒープを理解したことで、僕のモニターの中のコードが違って見え始めた。

以前は new Student() というコードを見て、単に「オブジェクトを一つ作った」とだけ思っていた。しかし今は見える。 「今、ヒープ(Heap)という倉庫に箱が一つ入ったな。これは僕が消さないと(あるいはGCが来ないと)、ずっとメモリを食い続けるんだな」

この「見えないものを見る目」ができてから、僕は漠然とした恐怖から抜け出すことができた。サーバーが OutOfMemory を吐いても、以前のように慌てて再起動ボタンを押したりはしない。代わりに落ち着いて「どのオブジェクトがヒープを占領したんだ?」と問いかけ、分析ツール(Heap Dump)を開く。原因が分かれば解決できるからだ。

我々はこれでコードが保存される空間(Memory)を征服した。物流倉庫は完璧に準備された。

それでは、この倉庫で実際に荷物を運んで組み立てる「作業員」たちは誰だろうか? 一人で働く時と、複数人で同時に働く時(Multi-Tasking)は何が違うのか?

次回は、OSの花形であり、バックエンドエンジニアの永遠の宿題である「プロセスとスレッド (Process & Thread)」の世界へ旅立とう。

コメントする