Rustの値は、何もしなければスタックに置かれます。ヒープへ移るのは、Box・Vec・Stringのようにヒープを確保する型を自分で選んだときだけです。Goのエスケープ解析のように、コンパイラが使われ方を見て値を勝手にヒープへ逃がすことはありません。では、どんな値をヒープへ脱出させるべきか。答えは「サイズがコンパイル時に決まらない」「型が自分自身を含む」「大きすぎてスタックに置くと困る」の3条件です。本記事では、この3条件と所有権による解放のしくみを整理したうえで、Rust 1.99.1(2026年10月15日公開)で実行した結果をもとに、Box::new([0u8; N])がデバッグビルドで落ちる理由、スレッドのスタックサイズの変え方、メモリ使用量の減らし方までを扱います。
まとめ:Rustのスタック・ヒープと脱出条件の要点
- 既定はスタック:整数・
bool・固定長配列・構造体など、サイズがコンパイル時に決まる値はスタックに置かれる。ヒープを使うのはBox・Vec・Stringなどを選んだときだけ。 - ヒープへ脱出させる条件は3つ:①
dyn Traitやスライスなどサイズが決まらない型を値として持つ、②連結リストのように自分自身を含む型を定義する、③数MB単位の大きなデータを扱う。 - 解放は所有権が決める:所有者がスコープを抜けると
dropされヒープが返る。ガベージコレクタは無い。Rcの循環参照だけは解放されないので、片側をWeakにする。 - 大きな配列は
Box::newでなくvec!で作る:16MiBの配列をBox::new([0u8; N])で作ると、デバッグビルドではスタックオーバーフローで終了した(終了コード134)。vec![0u8; N].into_boxed_slice()やBox::new_zeroed()は通った。 - スタックサイズはスレッド単位で変える:生成したスレッドの既定は2MiB。
thread::Builder::stack_sizeか環境変数RUST_MIN_STACKで広げる。メインスレッドのサイズはRustではなくOSが決める。 - メモリ使用量はenumのサイズで大きく変わる:1KiBのバリアントを持つenumを100万個並べると約1,025MB、そのバリアントを
Boxで包むと約19MBになった。
Rustのメモリ配置|スタック・ヒープ・静的領域の3つ
実行中のRustプログラムが値を置く場所は、スタック・ヒープ・静的領域の3つです。どこに置かれるかは型で決まり、mallocやnewを直接書く場面はふつうありません。
| 領域 | 置かれるもの | 確保と解放 | 大きさの上限 |
|---|---|---|---|
| スタック | ローカル変数・引数(固定サイズ) | 関数の呼び出しと復帰で自動 | スレッドごとに固定(既定2MiBなど) |
| ヒープ | Box・Vec・Stringの中身 |
所有者のスコープ終了でdrop |
OSが許す限り |
| 静的領域 | static・文字列リテラル |
プログラムの終了まで残る | コンパイル時に確定(実行時に増えない) |
スタック:関数フレームと固定サイズの値
スタックは後入れ先出しで、関数を呼ぶたびに引数とローカル変数をまとめたスタックフレームが積まれ、関数を抜けると捨てられます。確保も解放もスタックポインタを動かすだけなので速い反面、容量はスレッドごとに固定です。The Rust Programming Language 日本語版の「所有権とは?」が書くとおり、スタックに置けるのは既知の固定サイズのデータに限られます。
fn main() {
let n = 10; // i32:スタック
let sum = add(n, 20); // add のフレームが積まれ、戻ると捨てられる
println!("{sum}");
}
fn add(a: i32, b: i32) -> i32 {
a + b
}
ヒープ:伸び縮みするデータの本体
ヒープは、実行時までサイズが決まらないデータや、あとから伸び縮みするデータの置き場です。VecやStringは「ポインタ・長さ・容量」の3つをスタックに置き、データ本体だけをヒープに確保します。確保のたびにアロケータが空き領域を探し、読むときもポインタをたどる必要があるため、スタックより遅くなります(TRPL「所有権とは?」の説明)。
静的領域:staticと文字列リテラル
static変数と文字列リテラル(&'static str)はバイナリに埋め込まれ、プログラムの終了まで残ります。書き換えたい共有状態をstatic mutで持つのは避けてください。Rust 2024エディションではstatic mutへの参照を作るだけで、既定でdenyのstatic_mut_refsがエラーを出します(rustc 1.99.1で確認。コンパイラは&raw constへの置き換えを提案)。可変の共有状態はAtomicU32やMutex、遅延初期化ならOnceLockで持ちます。
static VERSION: &str = "1.0"; // 静的領域に残る
fn main() {
println!("{VERSION}");
}
型のサイズを size_of で確かめる|スタックに載る大きさの実測
値がスタックに占める大きさはstd::mem::size_ofで確かめられます。ヒープに本体を置く型は、スタック側には「ポインタ(と長さ・容量)」しか残りません。Rust 1.99.1・x86_64のmacOSで出力した値は次のとおりです。
use std::fmt::Display;
use std::mem::size_of;
use std::rc::Rc;
fn main() {
println!("{}", size_of::<i32>()); // 4
println!("{}", size_of::<Box<i32>>()); // 8
println!("{}", size_of::<Box<[u8; 1024]>>()); // 8
println!("{}", size_of::<Box<[u8]>>()); // 16
println!("{}", size_of::<Box<dyn Display>>()); // 16
println!("{}", size_of::<String>()); // 24
println!("{}", size_of::<Vec<u8>>()); // 24
println!("{}", size_of::<Rc<i32>>()); // 8
println!("{}", size_of::<Option<Box<i32>>>()); // 8
}
| 型 | スタック上のサイズ | 中身 |
|---|---|---|
Box<i32>・Box<[u8; 1024]> |
8バイト | ポインタ1つ |
Box<[u8]>・Box<dyn Trait> |
16バイト | ポインタ+長さ(またはvtable)※現行の実装 |
String・Vec<u8> |
24バイト | ポインタ+長さ+容量 |
Option<Box<i32>> |
8バイト | Noneをヌルで表す |
中身が1KiBでもBoxは8バイトです。これが後述する「大きなバリアントをBoxで包むとenum全体が縮む」理由になります。Option<Box<T>>がBox<T>と同じ8バイトなのは、ヌルポインタ最適化によるもので、std::optionのドキュメントがこのサイズの一致を保証しています。
値がヒープへ脱出する3つの条件|Box・再帰型・大きなデータ
ここでは、スタックに置けない、あるいは置くと困る値をBoxなどでヒープへ移すことを脱出と呼びます。脱出が必要になるのは次の3つで、どれにも当てはまらない値はスタックのままで構いません。
条件1:dyn Traitなどサイズが決まらない型
トレイトオブジェクト(dyn Trait)やスライス([T])は、中身の型や要素数が実行時まで決まらないため、値のまま変数や戻り値にできません。Box<dyn Trait>にすると、スタックには16バイトのポインタだけが残り、本体はヒープに置かれます。分岐によって返す型が変わる関数は、この形が定番です。
trait Shape { fn area(&self) -> f64; }
struct Circle(f64);
struct Square(f64);
impl Shape for Circle { fn area(&self) -> f64 { 3.14 * self.0 * self.0 } }
impl Shape for Square { fn area(&self) -> f64 { self.0 * self.0 } }
// 返す型が実行時の分岐で決まる=サイズが確定しないので Box で包む
fn make(kind: &str) -> Box<dyn Shape> {
if kind == "circle" { Box::new(Circle(1.0)) } else { Box::new(Square(2.0)) }
}
fn main() {
let shapes = vec![make("circle"), make("square")];
for s in &shapes { println!("{}", s.area()); } // 3.14 と 4
}
条件2:自分自身を含む再帰的な型
連結リストや木のように、型の定義に自分自身が入っていると、サイズが無限に膨らみます。Boxを挟まずに書くと、rustc 1.99.1は次のエラーで止まり、Box・Rc・&のどれかを挟むよう提案します。
error[E0072]: recursive type `List` has infinite size
--> rec.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
2 | Cons(i32, List),
| ---- recursive without indirection
Box<List>にすると、その部分はポインタ1つぶんに固定され、List全体は16バイトに収まります。次の要素はヒープへ脱出します。
enum List {
Cons(i32, Box<List>),
Nil,
}
use List::{Cons, Nil};
fn sum(l: &List) -> i32 {
match l { Cons(v, next) => v + sum(next), Nil => 0 }
}
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
println!("sum={} size={}", sum(&list), std::mem::size_of::<List>()); // sum=6 size=16
}
条件3:スタックに置くと溢れる大きなデータ
数MB単位の配列やバッファは、スタックに置いた時点でスレッドの容量を超えます。生成したスレッドの既定スタックは2MiBなので、そのスレッドでは2MiBを超える配列をローカル変数にできません(メインスレッドもmacOSの既定は8MiB)。こうしたデータは最初からヒープに確保します。一方で、数十バイトの構造体までBoxで包むのは逆効果です。確保のたびにアロケータを呼び、参照のたびにポインタをたどるぶん遅くなります。小さな値はスタック、サイズ不定・再帰・巨大のときだけヒープと割り切ってください。
所有権とdrop|ヒープのメモリが解放されるタイミング
脱出した値をいつ解放するかは、所有権が決めます。値の所有者は常に1つで、所有者がスコープを抜けた時点でdropが呼ばれ、ヒープが返されます。ガベージコレクタも手書きのfreeもありません。所有権・借用・ライフタイムの基礎はRust入門|環境構築から所有権・基本文法、受託開発での採用判断までで扱っています。
ムーブとCopy:代入でコピーされる24バイトと所有権の移動
ヒープに本体を持つStringやVecを別の変数に代入すると、スタック上の24バイト(ポインタ・長さ・容量)だけがコピーされ、所有権が移ります(ムーブ)。元の変数は使えなくなるので、同じヒープを2回解放することは起きません。i32やboolのようにCopyを実装する型は、代入で値そのものが複製され、元の変数も使い続けられます。
fn main() {
let s = String::from("hello"); // 本体をヒープに確保
let moved = s; // 所有権が moved へ移る。s はもう使えない
println!("{moved}");
} // moved がスコープを抜け、ここでヒープを解放
Rc・Arcの循環参照とWeak
1つの値を複数の場所から持ちたいときは、参照カウントのRc<T>(単一スレッド)かArc<T>(複数スレッド)を使い、カウントが0になった時点で解放されます。ただしRc同士が強参照で指し合うとカウントが0にならず、メモリリークになります。リークはメモリ安全性の違反ではないため、コンパイラは止めません(The Rust Programming Language 日本語版「循環参照は、メモリをリークすることもある」)。親→子はRc、子→親はWeakにして循環を断ちます。
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Node {
name: &'static str,
parent: RefCell<Weak<Node>>, // 子→親は Weak
children: RefCell<Vec<Rc<Node>>>, // 親→子は Rc
}
impl Drop for Node {
fn drop(&mut self) { println!("drop {}", self.name); }
}
fn main() {
let parent = Rc::new(Node { name: "parent", parent: RefCell::new(Weak::new()), children: RefCell::new(vec![]) });
let child = Rc::new(Node { name: "child", parent: RefCell::new(Weak::new()), children: RefCell::new(vec![]) });
*child.parent.borrow_mut() = Rc::downgrade(&parent);
parent.children.borrow_mut().push(Rc::clone(&child));
println!("parent strong={} weak={}", Rc::strong_count(&parent), Rc::weak_count(&parent));
println!("child strong={} weak={}", Rc::strong_count(&child), Rc::weak_count(&child));
}
実行するとparent strong=1 weak=1・child strong=2 weak=0と出たあと、drop parent・drop childの順で両方が解放されました。子→親もRcにすると、どちらのdropも呼ばれません。リーク全般の調べ方はメモリリークとは?原因の特定方法と直し方を言語・環境別に解説にまとめています。
スタックオーバーフローの原因と防ぎ方|大きな配列の実測
Rustはメモリ安全ですが、スタックオーバーフローはunsafeなしで普通に起きます。溢れると、Rustの標準ライブラリが次のメッセージを出してプロセスを中断します(SIGABRT・終了コード134)。C言語では同じ状況がSIGSEGV(同じmacOSでの再帰テストは終了コード139)としてしか報告されず、Rustのように原因がスタックだとは表示されません。C/C++側の落ち方はセグメンテーションフォルト(Segmentation fault)の原因と調べ方で扱っています。
thread 'main' (49662405) has overflowed its stack
fatal runtime error: stack overflow, aborting
Box::new([0u8; N])がデバッグビルドで溢れる理由
Box::newは普通の関数なので、Rust Referenceの評価順序どおり、引数の配列は呼び出しより先に評価され、呼び出し側のスタックに組み立てられてからヒープへコピーされます。最適化でこのコピーが消えることはありますが、当てにはできません。rust-lang/rustのissue #53827にも、Box::new([-1; 3000000])がスタックオーバーフローする再現例が報告されています。16MiBの配列をヒープへ置く4つの書き方を、メインスレッド(macOSのulimit -sは8192KB)で実行した結果です。
| 書き方(N = 16MiB) | debugビルド | releaseビルド |
|---|---|---|
Box::new([0u8; N]) |
オーバーフロー(終了コード134) | 成功 |
vec![0u8; N].into_boxed_slice() |
成功 | 成功 |
vec!→try_intoでBox<[u8; N]> |
成功 | 成功 |
Box::new_zeroed() |
成功 | 成功 |
releaseで通るからといってBox::newのままにしないでください。テストやデバッグビルドでだけ落ちる、最適化の具合で落ちたり落ちなかったりする、という追いにくい不具合になります。
もう1つ注意点があります。最初はこの4つを1つのmainのmatchに並べて実行すると、実行しないBox::newの分岐があるだけで、vec!の分岐を選んでもdebugビルドで溢れました。スタックフレームの大きさは関数の入口でまとめて確保されるので、同じ関数のどこかに巨大な一時値があれば、その分岐を通らなくても溢れます。表の結果は、書き方ごとに#[inline(never)]の関数へ分けて測り直したものです。
大きな配列をヒープへ直接置く3つの書き方
どれも呼び出し側のスタックに配列を作らず、ヒープへ直接確保します。
const N: usize = 16 * 1024 * 1024;
fn main() {
// 1. 長さ可変のスライスでよいなら
let a: Box<[u8]> = vec![0u8; N].into_boxed_slice();
// 2. 型で長さを固定したいなら(Vec から変換)
let b: Box<[u8; N]> = vec![0u8; N].into_boxed_slice().try_into().unwrap();
// 3. ゼロ初期化でよいなら(Rust 1.92.0 で安定化)
let c: Box<[u8; N]> = unsafe { Box::<[u8; N]>::new_zeroed().assume_init() };
println!("{} {} {}", a.len(), b.len(), c.len());
}
迷ったら1のvec!で十分です。Vecのまま持てばinto_boxed_sliceすら要りません。3のBox::new_zeroedはゼロのビット列が正しい値になる型(整数や浮動小数点の配列など)に限って使えます。参照やNonNullを含む型に対してassume_initすると未定義動作です(new_zeroedの呼び出し自体は安全)。
Clippyのlarge_stack_arrays・large_stack_framesによる事前検出
Clippyでスタック上の巨大な値を見つける主なlintは次の3つです(ほかにasyncのFutureの大きさを見るlarge_futuresもあります)。既定で警告が出るのはlarge_enum_variantだけで、残り2つは明示的に有効にしないと黙っています。閾値はclippy_configの既定値から確認しました。
| lint | グループ | 既定の閾値 | 検出するもの |
|---|---|---|---|
large_stack_arrays |
pedantic(既定は無効) | 16,384バイト | ローカルの巨大配列 |
large_stack_frames |
nursery(既定は無効) | 512,000バイト | 巨大なスタックフレーム |
large_enum_variant |
perf(既定で警告) | 200バイト | バリアント間のサイズ差 |
cargo clippy -- -W clippy::large_stack_arrays -W clippy::large_stack_frames
表の計測に使ったBox::new([0u8; N])だけの関数にかけると、warning: this function may allocate 16777264 bytes on the stackとallocating a local array larger than 16384 bytesが出ました。上に載せた3つの書き方には何も出ません。CIで-Wを付けておけば、debugビルドでしか落ちない配列をレビュー前に拾えます。Cargoの基本操作はCargo(Rust)とは?インストールと使い方・主要コマンド一覧を参照してください。
スタックサイズを変更する|スレッド既定2MiBとRUST_MIN_STACK
std::threadのドキュメントによると、生成したスレッドのスタックは現在すべてのTier 1プラットフォームで2MiBです(将来変わりうると明記)。変える方法は2つで、Builder::stack_sizeを指定するか、環境変数RUST_MIN_STACKにバイト数を入れます。両方あればBuilder::stack_sizeが優先です。メインスレッドのスタックサイズはRustが決めないとも書かれており、macOSやLinuxではシェルのulimit -s(多くの環境で8MiB)に従います。ulimit -s 65520にしてから実行すると、debugビルドのBox::new([0u8; N])(16MiB)も通りました。
各フレームに1KiBの配列を持たせた再帰関数(表の下のコード)を、releaseビルドで深さを変えて実行した結果です。
| スレッドの作り方 | 深さ | 結果 |
|---|---|---|
| 既定(2MiB) | 1,500 | 成功 |
| 既定(2MiB) | 2,000 | オーバーフロー |
RUST_MIN_STACK=8388608(8MiB) |
3,000 | 成功 |
stack_size(8MiB) |
3,000 | 成功 |
stack_size(8MiB) |
50,000 | オーバーフロー |
stack_size(64MiB) |
50,000 | 成功 |
use std::hint::black_box;
use std::thread;
// 各フレームに1KiBの配列を持たせた再帰
fn depth(n: u64) -> u64 {
let buf = black_box([0u8; 1024]);
if n == 0 { return buf[0] as u64; }
depth(n - 1) + buf[1] as u64
}
fn main() {
let handle = thread::Builder::new()
.stack_size(64 * 1024 * 1024) // 64MiB。この行を外すと既定の2MiB
.spawn(|| depth(50_000))
.unwrap();
println!("{}", handle.join().unwrap());
}
同じコードをdebugビルドで動かすと、フレームが大きくなるため64MiBでも50,000段で溢れました。必要なスタックはビルド設定でも変わるので、拡張する量はreleaseとdebugの両方で確かめてください。
スタックを広げるのは対症療法です。入力の大きさに比例して深くなる再帰(木の走査、構文解析など)は、入力次第でいずれ溢れます。そうした処理はループとVecで自前のスタックを持つ形に書き換えるのが根本対策で、スタックサイズの拡張は深さの上限が分かっている処理に限ってください。再帰とスタックの関係は再帰関数とは?自分を呼ぶ関数の仕組みとスタック・末尾再帰・実務判断を解説で詳しく扱っています。
Rustのメモリ使用量を減らす|enumのBox化とVecの容量
メモリ使用量を減らすとき、最初に見るべきは「大量に並ぶ値の1個あたりのサイズ」です。size_ofが数バイト縮むだけでも、100万個並べば数MBの差になります。
enumの巨大バリアントのBox化とRSSの差
enumのサイズは、いちばん大きいバリアントに合わせて決まります。1KiBのデータを持つバリアントが1つでもあれば、データを持たないPingも1,025バイトを占めます。100万要素のうち1%だけが1KiBのデータを持つVecを作り、/usr/bin/time -lで最大RSSを比べました。
enum Msg { Ping, Data([u8; 1024]) } // size_of = 1025
enum MsgBoxed { Ping, Data(Box<[u8; 1024]>) } // size_of = 8
| 要素の型 | 1要素のサイズ | 最大RSS(100万要素) |
|---|---|---|
Msg |
1,025バイト | 約1,025.7MB |
MsgBoxed |
8バイト | 約19.1MB |
大きなバリアントがまれにしか現れないなら、Boxで包むだけで約54分の1になりました。逆に、ほとんどの要素が大きなバリアントなら、要素ごとのヒープ確保が増えるだけなので包む意味はありません。Clippyのlarge_enum_variantが既定で警告するのは、最大のバリアントと2番目に大きいバリアントのサイズ差が200バイトを超えたときで、このMsgも警告の対象でした。
Vecの容量を見直す:with_capacityとshrink_to_fit
Vecはpushで容量が足りなくなると、より大きな領域を確保し直します。5,000万個のu64をpushで積んだ場合、最後の容量は67,108,864要素ぶん(約537MB)で、格納している5,000万要素より約1,700万要素多く予約していました。Vec::with_capacity(50_000_000)で始めると容量はぴったり5,000万です。ただし、このmacOS環境では最大RSSはどちらも約401MBで差が出ませんでした。触っていない予約ぶんは物理メモリに載らないためです。差が効いてくるのは、そのあと要素を書き足したときや、長く保持するときです。件数が分かっているならwith_capacity、作り終えたあと長く持つならshrink_to_fitで容量をlenに合わせます。
メモリ使用量の測り方:/usr/bin/timeの最大RSS
プロセス全体の最大RSSなら、追加ツールなしで測れます。
# macOS:「maximum resident set size」がバイト単位で出る
/usr/bin/time -l ./target/release/app
# Linux(GNU time):「Maximum resident set size (kbytes)」が出る
/usr/bin/time -v ./target/release/app
どの型がどれだけ確保しているかまで知りたいときは、ヒーププロファイラ(Linuxならheaptrack、Rustのコードに組み込むならdhatクレート)に進みます。非同期ランタイムを使うサーバーなら、タスクごとに持つ状態のサイズも効く要素です。asyncの仕組みはRustのasync/await入門|Futureとpollの仕組み、Tokioでの実行とよくあるエラーで扱っています。
よくある質問
Rustでスタックとヒープはどう使い分けますか?
コンパイル時にサイズが決まる値はスタック、伸び縮みするデータや、dyn Trait・再帰型・数MB単位の配列はヒープです。迷ったらスタックのままにし、脱出の3条件に当てはまったときだけBoxやVecを選びます。
Rustでは値が自動でヒープへ移動することはありますか?
ありません。Goのようなエスケープ解析で、コンパイラが値をヒープへ移すことはしません。ヒープを使うのは、Box・Vec・String・Rcなど、内部でアロケータを呼ぶ型を使ったときだけです。
Box::newで大きな配列を作るとスタックオーバーフローするのはなぜですか?
引数の配列がいったん呼び出し側のスタックに作られ、そのあとヒープへコピーされるためです。Rust 1.99.1で16MiBの配列を試すと、debugビルドでは溢れ、releaseビルドでは最適化でコピーが消えて通りました。vec![0u8; N]かBox::new_zeroed()に置き換えれば、どちらのビルドでも通ります。同じ関数の中にBox::new([0u8; N])が残っていると、その分岐を通らなくてもdebugビルドで溢れるので、書き換え漏れに注意してください。
Rustのスタックサイズはどうやって変更しますか?
生成するスレッドならthread::Builder::new().stack_size(バイト数)か、環境変数RUST_MIN_STACKで指定します。既定は2MiBです。メインスレッドのサイズはOS側の設定(ulimit -sなど)で決まるため、深い再帰は別スレッドを立てて実行するのが確実です。
変数がスタックとヒープのどちらにあるか確認する方法はありますか?
型を見るのが基本で、std::mem::size_ofでスタック上の大きさを確かめられます。64ビット環境ではBox<i32>は8バイト、StringとVecは24バイトで、どれも本体はヒープにあり、スタックにはポインタなどの管理情報だけが載っています。