スタックとヒープの実態(コールスタックとフレーム)
なぜローカル変数は速くて再帰は落ちるのか。コールスタックのフレーム構成と戻りアドレス、ヒープの動的確保を配置図で押さえ、性能とクラッシュの勘所をつかむ。
- 1.コールスタックは関数呼び出しごとにフレームを高位アドレスから低位へ積み、戻りアドレス・退避レジスタ・ローカル変数を一括で確保/解放する。
- 2.スタックは確保・解放がスタックポインタの加減算のみで定数時間、ヒープはアロケータが空き領域を管理するため割り当てコストと断片化が生じる。
- 3.深い再帰や巨大なローカル配列はスタック上限を超えてスタックオーバーフローを起こし、隣接領域を踏むとガードページ違反でクラッシュする。
プロセスのメモリ配置
実行中のプロセスは仮想アドレス空間を持ち、その中で領域が役割ごとに分かれています。下端(低位アドレス)にコードとグローバルデータが置かれ、上端(高位アドレス)寄りにスタックが、その中間にヒープが配置されるのが典型です。両者は反対向きに伸び、間の未使用領域を奪い合います。
高位アドレス
┌──────────────┐
│ スタック │ ← 関数呼び出しで下へ伸びる(フレームを積む)
├──────────────┤
│ ↓ │
│ (未使用) │
│ ↑ │
├──────────────┤
│ ヒープ │ ← malloc/new で上へ伸びる
├──────────────┤
│ BSS / Data │ グローバル・静的変数
├──────────────┤
│ テキスト │ 機械語コード(読み取り専用)
└──────────────┘
低位アドレス
スタックとヒープは「動的に増減する領域」という点で似ていますが、管理者が誰かが決定的に違います。スタックはコンパイラが生成した命令とハードウェアのスタックポインタが自動管理し、ヒープはアロケータ(malloc 実装や言語ランタイム)が明示的な要求に応じて管理します。
コールスタックとフレーム構成
関数を呼ぶたびに、その呼び出し1回分の作業領域を1枚のスタックフレーム(活性化レコード)として確保します。フレームには概ね次が含まれます。
- 戻りアドレス — 呼び出し元で次に実行すべき命令の位置。
call命令がスタックへ自動的に積みます。 - 退避レジスタ — 呼び出し規約で「呼び出し先が保存する」とされたレジスタの元の値。
- ローカル変数とテンポラリ — その関数内だけで使う変数や中間結果。
- 引数の一部 — レジスタに乗り切らない引数や、可変長引数など。
確保はスタックポインタ(SP)を必要バイトだけ減算するだけ、解放は加算で元に戻すだけです。つまりフレーム内の何十個の変数も、SPの1回の加減算でまとめて確保・破棄されます。これがスタック確保が定数時間で極めて安い理由です。
呼び出しの流れ: main → compute → helper
│ main のローカル │ 高位(main のフレーム)
├──────────────────────┤
│ 戻りアドレス→main │ ┐
│ compute のローカル │ ┘ compute のフレーム
├──────────────────────┤
│ 戻りアドレス→compute │ ┐
│ helper のローカル │ ┘ helper のフレーム ← 現在の SP(最も低位)
└──────────────────────┘ 低位
helper が return すると、SPが戻りアドレスの位置まで巻き戻り、そこへジャンプして compute の続きが走ります。フレームは**後入れ先出し(LIFO)**で積み下ろしされ、これが「スタック」と呼ばれる所以です。多くのABIではフレームの基準位置を指すフレームポインタ(x86-64 の RBP など)も併用し、デバッガがフレームを順にたどれるようにします。なお 再帰 はこの仕組みを直接利用しており、呼び出し1段ごとに独立したフレームが積まれます。
スタックオーバーフロー
スタックには上限があります(Linux の既定で 8 MB 程度、スレッドごとに固定)。フレームを積みすぎてこの上限を超えると、スタックオーバーフローが発生します。代表的な原因は二つです。
- 深すぎる、あるいは止まらない再帰 — 終了条件のないループ的再帰は、フレームを無限に積んでいきます。
- 巨大なローカル変数 — 関数内で
char buf[10_000_000]のような大きな配列を取ると、1フレームで上限を食い潰します。
OSはスタックの末端に書き込み不可のガードページを1枚置きます。伸びすぎたスタックがそこへ触れると保護違反(セグメンテーション違反)が発生し、ヒープや他の領域を静かに破壊する前にプロセスを止められます。深い再帰を避けられないアルゴリズムは、明示的なスタック(ヒープ上の配列)へ展開するか、末尾呼び出し最適化 でフレームを再利用する形へ書き換えるのが定石です。
ヒープと動的確保
スタックでは寿命が「関数の入れ子」に縛られます。関数を抜けてもなお生き続けるデータや、実行時までサイズが決まらないデータにはヒープを使います。malloc・new・各言語の確保命令でアロケータに領域を要求し、不要になったら free・delete、あるいは ガベージコレクション で回収します。
アロケータは、空き領域をフリーリストなどの内部データ構造で管理します。要求が来るたびに十分な大きさの空きブロックを探し(first-fit、best-fit など)、切り出して返します。ここに二つのコストが生まれます。
- 割り当てコスト — 適切な空きブロックの探索・分割・メタデータ更新が必要で、SPの加減算1回で済むスタックより重い。
- 断片化 — 確保と解放を繰り返すと空きが細切れになり、合計は足りるのに連続した大ブロックが取れない状態に陥る。
確保したアドレスを取り違えると、解放漏れ(メモリリーク)、二重解放、解放済み領域へのアクセス(use-after-free)といった深刻なバグになります。これらの所在の管理には ポインタと参照 の理解が前提になります。
スタックとヒープの比較
| 観点 | スタック | ヒープ |
|---|---|---|
| 管理者 | コンパイラ+SP(自動) | アロケータ/GC(明示要求) |
| 確保・解放コスト | 定数時間(SPの加減算) | 探索・分割が必要で可変 |
| 寿命 | フレームに連動(関数を抜けると消滅) | 解放/回収まで任意に存続 |
| サイズ | 上限が小さく固定(例 8MB) | アドレス空間まで大きく取れる |
| 典型的な失敗 | スタックオーバーフロー | リーク・断片化・use-after-free |
| 局所性 | 高い(連続領域で温かい) | ばらつく(断片化で冷えやすい) |
原則は「フレームの寿命で足り、サイズが小さく既知ならスタック、寿命が呼び出しを超える/サイズが実行時依存/巨大ならヒープ」です。スタックは速く局所性も良いので、迷ったらまずスタックを検討し、寿命やサイズの制約に当たったときだけヒープへ逃がすと、性能と安全性のバランスが取りやすくなります。
まとめ
コールスタックは関数呼び出し1回ぶんのフレームを後入れ先出しで積み、戻りアドレスとローカル変数を一括管理します。確保・解放がSPの加減算だけで済むため高速ですが、上限が小さく、深い再帰や巨大なローカル変数はスタックオーバーフローを招きます。一方ヒープは寿命とサイズの制約を外す代わりに、アロケータによる探索コストと断片化、そして解放管理の難しさを背負います。両者の境界を「誰が管理し、いつ消えるか」で捉えることが、性能とクラッシュの両面を見通す鍵です。
プログラミング Article
スタックとヒープの実態(コールスタックとフレーム)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
メモリ
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
スタックは確保・解放がスタックポインタの加減算のみで定数時間、ヒープはアロケータが空き領域を管理するため割り当てコストと断片化が生じる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「メモリ / コールスタック」に近いか確認する。
- 強みである「コールスタックは関数呼び出しごとにフレームを高位アドレスから低位へ積み、戻りアドレス・退避レジスタ・ローカル変数を一括で確保/解放する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。