Rust

Rustのスタックとヒープ|値がヒープへ脱出する条件とメモリ領域の使い分け

Rustでは、変数は既定でスタックに置かれます。ところが「サイズがコンパイル時に決まらない」「自分自身を含む再帰的な型」「大きなデータを所有権ごと安全に移したい」といった条件がそろうと、値はスタックからヒープへ脱出します。この脱出条件を理解しないままだと、Boxを使うべき場面を取り違えたり、大きな配列でスタックオーバーフローを起こしたりします。本記事は、スタック・ヒープ・静的領域という3つのメモリ領域の違いから、値がヒープへ脱出する具体的な条件、所有権による解放のしくみ、そしてメモリ使用量を減らす実践までを、実行できるコードとともに整理します。

まとめ:Rustのメモリ配置と脱出条件の要点

  • 既定はスタック:サイズがコンパイル時に確定する値(整数・bool・固定長配列・構造体)はスタックに置かれる。
  • ヒープへ脱出する条件:①コンパイル時にサイズが決まらない型(dyn Trait・スライス)を固定サイズの文脈で使う、②自己参照する再帰的な型、③大きなデータを移動時にコピーしたくない ―― のいずれか。手段はBoxVecStringなど。
  • 解放は所有権が決める:ヒープのデータは所有者がスコープを抜けた時点で自動的にdropされる。ガベージコレクタは無い。
  • 大きな配列は最初からヒープへvec!Box<[T]>で確保する。Box::new([0u8; N])は一度スタックに実体を作るためオーバーフローの原因になる。
  • 迷ったらスタック:脱出条件を満たすときだけヒープを使う。むやみなBox化はキャッシュ効率を落とす。

Rustのメモリ構成|3領域と所有権による配置

実行中のRustプログラムのメモリは、役割の異なる領域に分かれます。どの値がどこに置かれるかは、型のサイズと所有権のルールで自動的に決まり、開発者が明示的にヒープを触るのはBoxなどのスマートポインタを使うときだけです。

領域 置かれるもの 確保/解放のタイミング 速度
スタック 関数の引数・戻りアドレス・ローカル変数(固定サイズ) 関数の呼び出しと復帰で自動 高速
ヒープ 動的サイズのデータ本体(Vec/String/Boxの中身) 所有者のスコープ終了でdrop スタックより低速
静的領域 static変数・文字列リテラル プログラム終了まで常駐

スタック領域:関数フレームとローカル変数

スタックはLIFO(後入れ先出し)で管理され、関数を呼ぶたびに引数・ローカル変数・戻りアドレスをまとめたスタックフレームが積まれ、関数を抜けると即座に破棄されます。確保・解放はスタックポインタを動かすだけなので高速ですが、容量は有限(メインスレッドでLinux/macOSは8MB前後、Windowsは1MB前後)で、深い再帰や巨大なローカル変数は溢れます。

fn main() {
    let n = 10;              // スタックに置かれる(i32はサイズ確定)
    let sum = add(n, 20);    // add呼び出しでフレームが積まれ、復帰で破棄
    println!("{sum}");
}

fn add(a: i32, b: i32) -> i32 {
    a + b
}

ヒープ領域:動的サイズのデータ本体

ヒープはサイズが実行時に変わるデータ、実行中に伸縮するデータを置く領域です。VecStringは、要素数や容量・データ本体のポインタといった「見出し情報」をスタックに置き、データ本体だけをヒープに確保します。ヒープはスタックのようにポインタ操作だけでは解放できず、確保コストも高いぶん、伸縮する大きなデータに向きます。

静的領域とリテラル:プログラム全体で常駐するデータ

static変数や文字列リテラル(&'static str)は静的領域に置かれ、プログラム開始から終了まで常駐します。初期化は一度だけで、実行中は書き換わりません。複数スレッドから参照されるため、static mutではなくSyncを満たす形(const項目やOnceLockなど)で共有するのが安全です。

static VERSION: &str = "1.0";   // 静的領域に常駐

fn main() {
    println!("{VERSION}");
}

スタックとヒープの違い|Rustでどちらに置かれるか

Rustで「スタックかヒープか」を分ける基準は一つ、コンパイル時にサイズが確定するかです。サイズが決まる型はスタック、決まらない・伸縮する型はヒープにデータ本体を持ちます。C++と違ってnew/deleteを書かず、BoxVecという型の選択がそのまま配置先の選択になります。

fn main() {
    let a = 10;                    // スタック(i32)
    let arr = [0u8; 32];           // スタック(固定長配列)
    let b = Box::new(10);          // 10はヒープ、ポインタはスタック
    let s = String::from("hello"); // 本体はヒープ、長さと容量とポインタはスタック
    println!("{a} {arr:?} {b} {s}");
}

スタックに置かれる代表はCopyを実装する型(数値・boolchar・それらの固定長配列やタプル)で、代入時に値がコピーされます。対してヒープにデータを持つStringVecは代入で所有権が移動(ムーブ)し、二重解放を防ぎます。この「Copyかムーブか」の差は、次章の脱出条件と表裏一体です。

値がヒープへ脱出する条件|Box・再帰型・動的サイズ

スタックに置けない、あるいは置くと不都合な値が、明示的なBox化やVec/Stringの利用によってヒープへ移ることを、ここでは脱出と呼びます。脱出が必要になるのは次の3条件で、いずれも「サイズか所有権の都合」に帰着します。逆に言えば、この条件に当てはまらないならスタックのままで十分です。

条件1:コンパイル時にサイズが確定しない型

トレイトオブジェクト(dyn Trait)やスライス([T])は、実体のサイズがコンパイル時に決まりません。関数の戻り値や構造体フィールドのように「サイズが必要な文脈」で使うには、ポインタ経由でヒープに逃がす必要があり、Box<dyn Trait>が定番です。ポインタ自体はサイズが固定なのでスタックに載ります。

条件2:自分自身を含む再帰的な型

連結リストや木のように「型の定義が自分自身を含む」構造は、そのままではサイズが無限に膨らみコンパイルが通りません。途中にBoxを挟むと、その部分がポインタ1個ぶんのサイズに固定され、実体はヒープへ脱出して定義が成立します。

enum List {
    Cons(i32, Box<List>),  // Boxでサイズを固定し脱出
    Nil,
}

条件3:大きなデータの移動時のコピー回避

数十KB以上の構造体を関数へ渡したり返したりするたびにスタック上でコピーすると、コストがかさみます。Box化しておけば、ムーブ時に動くのはポインタだけになり、実体はヒープに置いたまま所有権だけを渡せます。ここは判断が分かれますが、小さい値までBox化するのは逆効果で、ヒープ確保のコストとキャッシュ効率の低下が上回ります。目安として数値や小さな構造体はスタック、可変長・巨大・再帰のときだけヒープ、と割り切るのが実務的です。

所有権とドロップ|ヒープメモリの解放タイミング

ヒープへ脱出した値をいつ解放するかは、Rustでは所有権が決めます。各データにはただ一つの所有者があり、所有者がスコープを抜けた瞬間にdropが呼ばれてヒープが解放されます。ガベージコレクタも手動freeもなく、解放漏れと二重解放をコンパイル時に排除できるのがRustの核心です。所有権・借用・ライフタイムの基礎はRust入門|環境構築から所有権・基本文法、受託開発での採用判断までで体系的に扱っています。

fn main() {
    let s = String::from("hello"); // ヒープ確保
    let moved = s;                  // 所有権がmovedへ移動、sは無効
    println!("{moved}");
}                                   // movedがスコープ終了、ここでヒープ解放

Rc・Arcと循環参照によるメモリリーク

一つのデータを複数箇所で共有したいときは、参照カウント型のRc<T>(単一スレッド)やArc<T>(マルチスレッド)を使い、カウントが0になった時点で解放されます。ただしRc同士がお互いを強参照で指し合うと、カウントが0にならずメモリリークが起きます。Rustの安全性でも防げない数少ない穴で、親→子はRc、子→親はWeak<T>(カウントを増やさない弱参照)にして循環を断つのが定石です。

メモリ使用量を減らす・スタックオーバーフローを防ぐ実践

Rustはメモリ安全ですが、安全であることと省メモリ・落ちないことは別問題です。safeなコードでもスタックオーバーフローは普通に起こります。ここでは配置の知識を、実際に効く手当てへ落とし込みます。

大きな配列・構造体のヒープ直接確保

巨大な固定長配列をローカル変数で持つと、スタックの容量を一気に食い潰します。注意したいのはBox::new([0u8; N])で、これは一度スタック上に配列の実体を作ってからヒープへコピーするため、Nが大きいと確保する前にオーバーフローします。最初からvec!やスライスのボックス化で確保すれば、実体がスタックを経由しません。

// 危険:スタックに巨大配列を作ってからヒープへ
let a = Box::new([0u8; 10_000_000]);

// 安全:最初からヒープに確保
let v = vec![0u8; 10_000_000];

再帰の見直しとenumサイズの圧縮

深い再帰はフレームを積み上げてスタックを溢れさせます。可能なら反復(ループ)へ書き換えるか、末尾再帰的な設計にします。もう一つ見落としやすいのがenumのサイズで、enumは最大のバリアントに合わせて全体サイズが決まるため、一つだけ巨大なバリアントがあると全インスタンスが太ります。巨大バリアントをBoxで包めば、enum自体はポインタぶんに縮み、その値だけがヒープへ脱出します。

深い再帰時のスタックサイズ拡張

アルゴリズム上どうしても深い再帰が避けられない場合は、専用スレッドを立ててスタックサイズを明示的に拡張します。メインスレッドの上限は環境依存で書き換えにくいため、thread::Builderで必要なぶんを確保するのが確実です。

use std::thread;

let handle = thread::Builder::new()
    .stack_size(64 * 1024 * 1024) // 64MBに拡張
    .spawn(|| deep_recursion())
    .unwrap();
handle.join().unwrap();

ビルドや実行環境の基本操作はCargo(Rust)とは?インストールから使い方・主要コマンドまで完全ガイドにまとめています。

よくある質問

Rustでスタックとヒープはどう使い分けますか?

基準はコンパイル時にサイズが確定するかどうかです。数値・固定長配列・小さな構造体など確定する値はスタック、Vec/Stringのように伸縮するデータや、後述の脱出条件を満たす値はヒープに置きます。迷ううちはスタックを既定にし、脱出が必要な条件を満たしたときだけBox化するのが安全です。

値がヒープへ脱出するのはどんなときですか?

dyn Traitやスライスなどコンパイル時にサイズが決まらない型を固定サイズの文脈で使うとき、②連結リストのような自己参照する再帰的な型を定義するとき、③大きなデータを移動時にコピーしたくないとき、の3つです。いずれもBoxVecStringなどを通じてデータ本体がヒープに置かれます。

Box::newで大きな配列を作るとスタックオーバーフローするのはなぜですか?

Box::new([0u8; N])は、まずスタック上に配列の実体を組み立て、それをヒープへコピーする順序で評価されるためです。Nが大きいとヒープへ移す前にスタックが溢れます。vec![0u8; N]やスライスのボックス化を使えば、実体がスタックを経由せず直接ヒープに確保されます。

Rustでもメモリリークは起きますか?

起きます。所有権システムで解放漏れの大半は防げますが、Rc同士が強参照で循環すると参照カウントが0にならず解放されません。片方向をWeak参照にして循環を断てば防げます。リークは未定義動作ではないため、コンパイラは検出しません。

変数がスタックとヒープのどちらにあるか確認する方法はありますか?

型から判断するのが基本です。std::mem::size_ofで型がスタックに占める大きさを確認でき、Boxやポインタ型は、包む中身が大きくてもポインタぶん(64bit環境で8バイト)を返します。ただしdyn Traitやスライスを包んだBoxは、長さや型情報を持つファットポインタになるため16バイトです。いずれも「見出しはスタック、本体はヒープ」を示す手がかりになります。

fn main() {
    println!("{}", std::mem::size_of::<i32>());       // 4
    println!("{}", std::mem::size_of::<Box<i32>>()); // 8(ポインタ)
}

関連記事

資料請求

RELATED POSTS 関連記事