Skip to content

필사 모드: スタック vs ヒープ:メモリの真実

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — 変数はどこに住むのか

プログラミングを学ぶと、いつか「これはスタックにあって、あれはヒープにある」という言葉を耳にします。最初は聞き流しますが、なぜか繰り返し足をすくわれます。なぜある値は関数が終わると消え、ある値は残るのか。なぜ再帰を深くするとプログラムが死ぬのか。Cでmallocしたものをなぜfreeしなければならず、JavaScriptではなぜしなくてよいのか。Pythonでリストをコピーしたのに、なぜ元まで変わるのか。

これらすべての問いの根にスタックとヒープがあります。この二つは言語を問わず、プログラムがメモリを扱う二つの根本的な方法です。名前はデータ構造から来ていますが、ここでの話は「値がどこに保存され、いつ消えるのか」という実行時の物語です。この記事はその真実を土台から掘り下げます。

プロセスのメモリ地図

プログラムが実行されると、OSはそのプロセスに仮想アドレス空間を与えます。この空間はいくつかの領域に分かれています。

  高いアドレス
  +---------------------------+
  |         スタック          |  <- 下へ伸びる
  |            |              |
  |            v              |
  |                           |
  |            ^              |
  |            |              |
  |          ヒープ           |  <- 上へ伸びる
  +---------------------------+
  |  BSS (未初期化のグローバル) |
  +---------------------------+
  |  Data (初期化済みグローバル)|
  +---------------------------+
  |  Text (コード)            |
  +---------------------------+
  低いアドレス

興味深いのは、スタックとヒープが同じアドレス空間の両端から互いに向かって伸びることです。スタックは高いアドレスから低いアドレスへ、ヒープは低いアドレスから高いアドレスへ大きくなります。こう配置すると、それぞれ自分の方向に伸びていき、本当にメモリが足りなくなる極限でだけ衝突します。この記事の主役はこの二つの領域、スタックとヒープです。

スタック — フレーム、LIFO、そして速度

スタックは関数呼び出しを管理するメモリです。関数を一つ呼ぶたびに、スタックにはスタックフレーム(stack frame)が一つ積まれます。このフレームの中には、その関数のローカル変数、引数、そして戻り先アドレス(return address)が入ります。

核心は、この積み上げと取り崩しが徹底して後入れ先出し(LIFO, Last In First Out)であることです。最も最後に呼ばれた関数が最も先に終わり、そのフレームが最も先に取り崩されます。皿を積み上げて上から取っていくのと同じです。

  呼び出しの流れ:  main() -> a() -> b()

  スタック (ここでは上へ積む):
  +-----------+
  |   b()     |  <- いま実行中 (最上部)
  +-----------+
  |   a()     |
  +-----------+
  |  main()   |
  +-----------+

  b()が終わると b のフレームだけ取り崩され、a() へ戻る

この構造がスタックを途方もなく速くします。メモリの確保と解放が、事実上ポインタを一つ動かすだけだからです。CPUの中にはスタックの頂上を指すスタックポインタ(stack pointer)レジスタがあります。フレームを積むときはこのポインタをその分だけ下げ(スタックは下へ伸びるので)、取り崩すときはまた上げます。複雑な管理ロジックも、空き領域の探索もありません。ただの算術演算一つです。

void b() {
    int y = 20;   // b のフレームに保存
}

void a() {
    int x = 10;   // a のフレームに保存
    b();          // b のフレームが上に積まれる
    // b() が返ると y は即座に消える
}

スタックのもう一つの特徴は、サイズが固定でおおむね小さいことです。OSはスレッドごとにスタックに決まったサイズ(よく1〜8MB)を前もって割り当てます。この限界が、後で話すスタックオーバーフローの原因になります。まとめると、スタックは速く、自動で管理され、寿命が関数の生涯とちょうど一致し、サイズが限られています。

ヒープ — 動的で自由な空間

スタックが関数の生涯に縛られているなら、ヒープはその束縛から解き放たれた空間です。ヒープにある値は関数が終わっても生き残れますし、プログラムが実行中に望むだけのサイズを決めて確保できます。ただしこの自由には代償が伴います。

ヒープからメモリを得るには、明示的に要求しなければなりません。Cではmallocがその役割を果たします。

#include <stdlib.h>

int *make_array(int n) {
    // ヒープに整数 n 個分の領域を要求
    int *arr = malloc(n * sizeof(int));
    for (int i = 0; i < n; i++) {
        arr[i] = i * i;
    }
    return arr;   // 関数が終わってもこのメモリは生きている
}

ここでスタックと決定的に異なる点が現れます。make_arrayが返ると、ローカル変数arr(ポインタ自身)はスタックから消えます。しかしarrが指していたヒープ上の配列はそのまま残ります。だから呼び出した側がそのアドレスを受け取って使い続けられます。

ヒープの確保がスタックより遅い理由もここにあります。ヒープ管理器(allocator)は「要求したサイズに合う空き領域はどこか」を探さねばなりません。メモリの確保と返却が繰り返されると、ヒープはあちこち穴の空いたチーズのように断片化(fragment)し、管理器は空きリスト(free list)やサイズ別区画のようなデータ構造をたどって適切な場所を選ばねばなりません。この探索と帳簿付け(bookkeeping)が、スタックの単純なポインタ移動とは比べものにならないコストです。

  スタック確保:  スタックポインタを動かす (演算1回) → とても速い
  ヒープ確保:    空き領域の探索 + メタデータ更新    → 相対的に遅い

まとめると、ヒープは柔軟で(サイズも寿命も自由)、値を関数の境界を越えて共有できますが、確保が遅く、断片化が起き、いつか必ず片付けられねばなりません。その「片付け」を誰がやるかが言語を分ける大きな分かれ道ですが、これは後で扱います。

ポインタと参照 — 二つの世界をつなぐ橋

スタックとヒープはどうつながるのでしょうか。答えはポインタ(pointer)あるいは参照(reference)です。ヒープにある値には名前がありません。mallocはただアドレスを返すだけです。そのアドレスを保持する変数、たいていスタックにあるその変数こそがポインタです。

  スタック                  ヒープ
  +-----------+           +---------------------+
  | ptr       | --------> | 実際のデータ [42]   |
  | (アドレス)|           | (malloc で確保)     |
  +-----------+           +---------------------+
    この変数は              このデータは
    スタックに住む          ヒープに住む

つまり典型的な構図はこうです。ポインタという小さな値(たいてい8バイトのアドレス)はスタックフレームの中にあり、それが指す大きなデータはヒープにあります。スタックの小さな取っ手で、ヒープの大きな箱を掴んでいるわけです。

この概念は言語ごとに表現が違うだけで、どこにでもあります。Cのポインタ、C++の参照とスマートポインタ、Javaのオブジェクト参照、Pythonのすべての変数、JavaScriptのオブジェクト。これらはどれも「実際のデータはヒープのどこかにあり、変数はその位置を指す」という同じ骨格を共有します。高水準言語はこの事実を隠すだけで、なくしはしません。

ポインタを理解すると、長く悩まされてきた現象が一度に説明されます。二つの変数が同じヒープオブジェクトを指すと、片方でオブジェクトを変えると、もう片方でも変わって見えます。データがコピーされたのではなく、アドレスだけがコピーされたからです。この点が次に話す値セマンティクスと参照セマンティクスの核心です。

値セマンティクス vs 参照セマンティクス

変数を別の変数に代入したり関数に渡したりするとき、何がコピーされるのか。この問いの答えが値セマンティクス(value semantics)と参照セマンティクス(reference semantics)を分けます。

値セマンティクスでは、値そのものがコピーされます。コピーは元と完全に独立していて、一つを変えても、もう一つはそのままです。整数や実数のようなプリミティブ型はたいてい値セマンティクスに従い、これらは普通スタックにそのまま収まります。

参照セマンティクスでは、値ではなく参照(アドレス)がコピーされます。だからコピーと元が同じヒープオブジェクトを指すことになり、一方の変更がもう一方に見えます。

# Python: 整数は値のように、リストは参照のように振る舞う
a = 5
b = a
b += 1
print(a, b)   # 5 6  — a はそのまま。互いに独立

x = [1, 2, 3]
y = x          # 参照をコピー (同じリストを指す)
y.append(4)
print(x)       # [1, 2, 3, 4]  — x も変わった!

言語ごとに規則が違うため、この差はよくあるバグの源です。Javaはプリミティブ型(int、doubleなど)を値で、オブジェクトを参照で渡します。JavaScriptも数値・真偽値を値で、オブジェクト・配列を参照で扱います。C++は既定が値コピーですが、参照(&)とポインタ(*)で明示的に参照セマンティクスを選べます。

実務でこの概念を見落とすと、「確かにコピーしたのに元が変わった」という戸惑う状況に出くわします。解決策は意図を明確にすることです。本当に独立した複製が必要なら深いコピー(deep copy)を明示的に行い、共有が意図なら参照をそのままにします。どちらなのかを知ることが核心です。

スタックオーバーフロー vs OOM — 二つの死

スタックとヒープは足りなくなり方も違い、その結果プログラムが死に方も違います。

スタックオーバーフロー(stack overflow)は、スタック領域を使い切ったときに起きます。スタックはサイズが固定なので(普通は数MB)、フレームを積みすぎると限界を超えます。最もよくある原因は、終わらない再帰です。

def recurse(n):
    return recurse(n + 1)   # 終了条件がない

recurse(0)
# RecursionError: maximum recursion depth exceeded  (Python)
# 他の言語では segmentation fault で死ぬこともある

関数を呼ぶたびにフレームが積まれるのに取り崩されないので、スタックが限界にぶつかります。Cのような低水準言語では、これがまさにあの悪名高い「stack overflow」であり、セグメンテーション違反のよくある原因です。Pythonは実際のスタックが溢れる前に、自ら再帰の深さを数えてRecursionErrorを出す安全装置を備えます。

OOM(Out Of Memory)は、ヒープが足りなくなったときに起きます。ヒープに確保し続けて返さなかったり(メモリリーク)、本当に手に負えないほど大きなデータを要求したりすると、ヒープが底をつきます。

  スタックオーバーフロー:
    原因 - 深すぎる呼び出し/再帰でフレーム過多
    限界 - スレッドのスタックサイズ (普通 1〜8MB)
    症状 - 即座のクラッシュ、RecursionError、segfault

  OOM (メモリ不足):
    原因 - ヒープ確保の累積、メモリリーク、巨大データ
    限界 - 利用可能な物理/仮想メモリ (数GB以上)
    症状 - 確保失敗、OOM Killer、徐々に遅くなり死ぬ

二つの死の性格は異なります。スタックオーバーフローは普通、一瞬で決定的に弾け、再現も容易なので、原因(たいてい再帰)が明確です。OOMはしばしば徐々に迫ります。メモリリークが何時間もかけて積もり、ある瞬間にシステムがスワッピングで這うか、OSのOOM Killerがプロセスを強制終了します。だからOOMは診断がより厄介で、プロファイラやヒープダンプのような道具が必要です。

再帰の深さが重要な理由

再帰は優雅ですが、スタックの限界と正面から衝突する技法です。再帰呼び出し一回ごとにスタックフレームが一つ積まれるので、再帰の深さがそのままスタック使用量です。深さがスタックの限界を超えると、プログラムは死にます。

# この再帰はリストの長さぶん深くなる
def sum_list(items):
    if not items:
        return 0
    return items[0] + sum_list(items[1:])   # 深さ = len(items)

sum_list(list(range(100000)))   # スタックが弾ける

リストが10万個なら再帰の深さも10万になり、これは大半の言語の既定のスタック限界をはるかに超えます。解決は二つの方向です。

第一に、反復(iteration)に変えることです。ループはフレームを積まず一つのフレームの中で回るので、スタックを消費しません。上の関数は単純なforループに変えれば、どれだけ長いリストでも安全です。

第二に、末尾呼び出し最適化(tail call optimization, TCO)です。再帰呼び出しが関数の一番最後の動作なら(戻り値に別の演算が乗らないなら)、コンパイラが新しいフレームを積む代わりに現在のフレームを再利用できます。こうすると再帰が事実上反復のように振る舞い、スタックが伸びません。

  普通の再帰:  各呼び出しがフレームを積む     → 深さぶんスタック消費
  末尾再帰:    最後の呼び出しがフレームを再利用 → スタック一定 (TCO対応時)
  反復:        フレームを積まない             → スタック常に一定

注意点は、TCOをすべての言語が対応しているわけではないことです。Schemeや一部の関数型言語は標準で保証しますが、Pythonは意図的に対応せず(スタックトレースの読みやすさのため)、Javaや多数の主流言語も既定ではやりません。だから「この言語がTCOをするか」を確認せずに深い末尾再帰に頼るのは危険です。安全な既定の姿勢は「深くなりうる再帰は反復で書くか、明示的なスタックデータ構造を使う」です。

ヒープの片付け — 所有権、GC、手動管理

ヒープに確保したメモリは、いつか必ず返されねばなりません。返さないとリークが積もりOOMにつながります。この「いつ、誰が返すのか」の問題を、言語たちは三つの方法で解きます。この選択が各言語の性格を大きく左右します。

1. 手動管理 (manual, C系). プログラマが直接確保し、直接解放します。mallocで得たものは必ずfreeで返さねばなりません。

char *buf = malloc(1024);
// ... buf を使う ...
free(buf);   // 忘れればリーク、二度やればクラッシュ

この方法は最大の制御権と性能を与えます。いつ正確にメモリが返されるかをプログラマが完璧に制御します。代わりにミスの余地が大きいです。解放を忘れればリーク(leak)、すでに解放したものをまた使えばuse-after-free、二度解放すればdouble-free。こうしたバグはセキュリティ脆弱性の常連の原因でもあります。

2. ガベージコレクション (GC, Java・Python・JavaScript・Go). ランタイムが自動で「もう誰も参照していない」ヒープオブジェクトを見つけて回収します。プログラマは解放を気にしません。

let obj = { data: [1, 2, 3] };
obj = null;   // もう誰もそのオブジェクトを指していない
// GC がいつか勝手に回収する。free() のようなものはない

GCはメモリ安全を大きく高めます。use-after-freeやdouble-freeのような種類のバグが根本からなくなります。代わりに代償があります。GCが回る間、少しの間プログラムが止まったり(stop-the-worldの一時停止)遅くなったりし、いつ回収されるかの正確な時点をプログラマが制御しにくいです。またGC自体がCPUとメモリを使います。リアルタイム性が重要なシステムでは、この予測不能性が問題になることもあります。

3. 所有権 (ownership, Rust). Rustは第三の道を選びます。GCもなく手動freeもありません。代わりに、コンパイラが所有権の規則で各値の寿命をコンパイル時に追跡し、所有者がスコープを外れる瞬間に自動でメモリを返すコードを挿入します。

{
    let s = String::from("hello");   // s がヒープ文字列の所有者
    // ... s を使う ...
}   // ここで s がスコープを外れる -> ヒープメモリ自動返却 (drop)

核心の規則は「一つの値には所有者が一人だけいて、所有者が消えれば値も解放される」です。ここに借用(borrowing)の規則が加わり、コンパイラがuse-after-freeやデータ競合をコンパイル時に捕まえます。結果としてRustはランタイムGCなしでメモリ安全を得ます。代償は学習曲線です。所有権と借用の規則を満たそうとコンパイラと格闘する時間が必要です。

三つの方法を一目で比べるとこうです。

項目手動 (C)GC (Java/Python/JS)所有権 (Rust)
解放の時点プログラマが明示ランタイムが後でスコープ終了時に自動
性能最高、予測可能GCオーバーヘッド/一時停止最高に近く予測可能
安全性低い (リーク、UAF)高い高い (コンパイル時保証)
負担手動管理のミス時点の制御が難しい学習曲線、コンパイラとの格闘
代表言語C, C++(一部)Java, Python, JS, GoRust

正解はありません。最大の性能と制御が必要な組み込み・システムコードは手動か所有権を、生産性と安全が重要なアプリケーションはGCを、安全と性能を同時に望むなら所有権を選びます。何を諦めて何を得るかの問題です。

実務で出会う落とし穴

ここまでの概念が実際のコードでどう問題として現れるかを見ておきます。

ダングリングポインタとuse-after-free. スタックのローカル変数のアドレスを関数の外へ返すと、そのフレームはすでに取り崩されているので、返されたアドレスはゴミを指します。Cでよくあるミスです。ヒープに確保して返すか、値をコピーして返さねばなりません。

メモリリークはGC言語にもある. GCがあってもリークが不可能なわけではありません。どこかでまだ参照を掴んでいれば、GCはそれを生きているとみなし回収しません。キャッシュにオブジェクトを入れて空にしなかったり、イベントリスナを登録だけして解除しなかったりすると、参照が残り続けリークになります。

大きな値を値でコピーするコスト. 値セマンティクスは安全ですが、巨大な配列や構造体を関数に値で渡すと丸ごとコピーされ遅くなります。C++で大きなオブジェクトをconst参照で渡す理由がこれです。コピーを避けつつ変更は防ぐのです。

スタックに大きな配列を取らないこと. スタックは小さいです。数MBの配列をローカル変数としてスタックに取ると、それだけでスタックが溢れることがあります。大きなデータはヒープに置くのが原則です。

意図しない共有. 参照セマンティクスの言語でオブジェクトを渡し、「コピーだから好きに変えていい」と思って変更すると、元まで変わります。共有なのかコピーなのかを常に意識せねばなりません。

おわりに

スタックとヒープは、ただの教科書の二つの言葉ではなく、プログラムが刻一刻と、値をどこに置きいつ捨てるかを決める実際のメカニズムです。スタックは関数の生涯に縛られた速くて自動的だが小さく固定された空間で、ヒープはその束縛を外れた柔軟だが遅く自ら片付かない空間です。この二つをポインタがつなぎ、値・参照セマンティクスがコピーの規則を定め、再帰の深さがスタックの限界を試し、所有権・GC・手動管理がヒープの片付けを担います。

この絵が頭に収まると、最初に投げた問いはもう謎ではありません。関数が終わるとなぜローカル変数が消えるのか、再帰を深くするとなぜ死ぬのか、リストを「コピー」したのになぜ元が変わるのか、ある言語はなぜfreeが必要である言語は不要なのか。すべてスタックとヒープ、そしてその間を行き来する規則の物語です。メモリを理解するとは、結局この二つの空間とその規則を理解することなのです。

参考資料

현재 단락 (1/150)

プログラミングを学ぶと、いつか「これはスタックにあって、あれはヒープにある」という言葉を耳にします。最初は聞き流しますが、なぜか繰り返し足をすくわれます。なぜある値は関数が終わると消え、ある値は残るの...

작성 글자: 0원문 글자: 9,106작성 단락: 0/150