Wer Ownership wirklich verstehen will, muss das mentale Modell von Stack und Heap im Kopf haben. Auf dem Stack leben Funktions-Frames und lokale Bindungen mit zur Compile-Zeit bekannter Größe — schnell, automatisch, LIFO. Auf dem Heap leben Werte variabler Größe oder mit dynamischer Lebensdauer — flexibler, aber teurer, und vom Programm explizit angefordert. Dieser Artikel zeigt, was wo lebt, wie String und Vec intern aussehen, wann Box<T> die richtige Wahl ist und welche Performance-Implikationen diese Wahl in der Praxis hat.
Die zwei Speicher-Bereiche
| Aspekt | Stack | Heap |
|---|---|---|
| Verwaltung | Automatisch (Funktions-Calls) | Allokiert/freigegeben explizit |
| Größe | Begrenzt (oft 1–8 MB pro Thread) | Begrenzt nur durch System-RAM |
| Geschwindigkeit | Sehr schnell (Pointer-Inkrement) | Langsamer (Allokator-Lookup) |
| Lebensdauer | Funktions-Scope | Beliebig, an Besitzer gebunden |
| Was lebt hier | Lokale Bindungen, Funktions-Args, kleine Werte | Heap-Allokate (String, Vec, Box) |
| LIFO? | Ja (Last-In-First-Out) | Nein (beliebige Drop-Reihenfolge) |
Wichtig: die Frage ist nicht „Stack oder Heap?" für jeden Wert — die meisten Werte haben beide Anteile. Ein String zum Beispiel hat seinen Header (Pointer + Länge + Kapazität) auf dem Stack, die eigentlichen Bytes auf dem Heap.
Was lebt auf dem Stack?
Alles mit zur Compile-Zeit bekannter, fester Größe, das als lokale Bindung deklariert wird:
fn beispiel() {
let a: i32 = 42; // 4 Bytes Stack
let b: bool = true; // 1 Byte Stack
let c: (f64, f64) = (1.0, 2.0); // 16 Bytes Stack
let d: [u8; 256] = [0; 256]; // 256 Bytes Stack
// Alle a, b, c, d leben komplett auf dem Stack-Frame von beispiel.
} // Stack-Frame wird komplett freigegeben.Funktions-Aufrufe und Stack
Jeder Funktions-Call legt einen neuen Stack-Frame an:
fn aussen() {
let x: i32 = 1; // x lebt im Stack-Frame von aussen
innen(x);
// Nach innen: Stack-Frame von innen ist weg.
}
fn innen(y: i32) { // y lebt im Stack-Frame von innen
let z: i32 = y * 2;
println!("{z}");
} // Stack-Frame von innen wird gepoppt.x, y, z haben alle bekannte Größe (4 Bytes) zur Compile-Zeit. Sie leben kurz und werden mit dem Funktions-Return automatisch freigegeben — kein Allokator, keine Drop-Logik.
Was lebt auf dem Heap?
Werte mit dynamischer Größe oder dynamischer Lebensdauer:
String— die Bytes des Strings.Vec<T>— die Elemente.HashMap,BTreeMap— die Knoten/Buckets.Box<T>— der gewrappte Wert.Rc<T>,Arc<T>— der innere Wert plus Counter.
Der Header dieser Typen lebt auf dem Stack, der eigentliche Inhalt auf dem Heap.
String als Beispiel
let s = String::from("Hallo");
// Stack-Layout von s:
// ┌───────────────┐
// │ ptr (8 Byte) │ ──┐
// │ len = 5 │ │
// │ cap = 5 │ │
// └───────────────┘ │
// ▼
// Heap-Layout: ┌─────────────┐
// │ H a l l o │
// └─────────────┘Drei Felder auf dem Stack (insgesamt 24 Bytes auf 64-bit):
ptr— Pointer auf den ersten Byte des Heap-Allokats.len— aktuell genutzte Bytes.cap— allokierte Kapazität (kann größer alslensein, für Wachstum).
Die eigentlichen UTF-8-Bytes („Hallo" = 5 Bytes) leben auf dem Heap.
Vec als Beispiel
Vec<T> ist strukturell identisch zu String — drei Felder auf dem Stack, Elemente auf dem Heap:
let v: Vec<i32> = vec![1, 2, 3];
// Stack: { ptr, len=3, cap=3 }
// Heap: [1, 2, 3] (12 Bytes für drei i32)Box: explizite Heap-Allokation
Box<T> ist der einfachste Smart-Pointer: er allokiert genau einen T-Wert auf dem Heap.
let auf_stack: i32 = 5; // 4 Bytes Stack
let auf_heap: Box<i32> = Box::new(5); // 8 Bytes Stack (Pointer) + 4 Bytes Heap
println!("{auf_stack} {auf_heap}"); // funktioniert beides identischWann brauchst du Box?
1. Rekursive Datenstrukturen
// Geht NICHT:
// enum Liste { Cons(i32, Liste), Nil } // Unendlich groß!
// Geht — Box bricht die Rekursion:
enum Liste {
Cons(i32, Box<Liste>),
Nil,
}
fn main() {
let l = Liste::Cons(1, Box::new(Liste::Cons(2, Box::new(Liste::Nil))));
let _ = l;
}Ohne Box wäre die Größe von Liste unendlich (rekursiv). Mit Box ist nur der Pointer (8 Bytes) im Stack — die nächsten Knoten leben auf dem Heap.
2. Trait-Objekte
trait Tier { fn laute(&self) -> String; }
struct Hund;
struct Katze;
impl Tier for Hund { fn laute(&self) -> String { "Wuff".into() } }
impl Tier for Katze { fn laute(&self) -> String { "Miau".into() } }
fn main() {
let tiere: Vec<Box<dyn Tier>> = vec![
Box::new(Hund),
Box::new(Katze),
];
for t in &tiere {
println!("{}", t.laute());
}
}dyn Trait ist ein „unsized" Typ — seine Größe variiert je nach konkretem Typ. Box<dyn Trait> wickelt das in einen heap-allokierten Pointer.
3. Große Werte aus dem Stack heraushalten
struct GrossesArray([u8; 10_000_000]); // 10 MB
fn auf_stack() {
let _g = GrossesArray([0; 10_000_000]);
// RISIKO: Default-Stack ist oft 8 MB → Stack-Overflow.
}
fn auf_heap() {
let _g = Box::new(GrossesArray([0; 10_000_000]));
// Box::new alloziert direkt auf dem Heap (bei optimierten Builds).
}Stack-Größe ist limitiert (typisch 8 MB auf Linux). Sehr große Werte gehören auf den Heap.
Performance-Implikationen
Stack ist schnell
Eine Stack-Allokation ist ein Inkrement des Stack-Pointers — eine Instruktion. Frei wird der Speicher mit dem Funktions-Return (Dekrement des Stack-Pointers). Kein Allokator, keine Fragmentierung.
Heap kostet
Box::new(...), String::from(...), Vec::new() rufen den globalen Allokator (oft malloc/jemalloc). Das ist:
- Langsamer als Stack (mehrere hundert Zyklen).
- Anfällig für Fragmentierung — der Allokator muss freien Block finden.
- Thread-synchronisiert — multi-threaded Allokator hat Locking.
Daher gilt: wenn möglich, Stack nutzen. [u8; 256] ist schneller als Vec<u8> mit 256 Elementen. Allerdings: bei dynamischer Größe ist Heap meist unvermeidbar.
Cache-Lokalität
Stack-Daten leben dicht beieinander (im aktuellen Frame). Heap-Daten können verstreut sein. Für CPU-Caches ist Stack daher freundlicher — aber konsekutive Heap-Allocations (Vec::with_capacity) sind auch cache-effizient.
Move auf dem Heap vs. Stack
Ein wichtiger Punkt zum Verständnis von Move:
let s1 = String::from("Hallo"); // Stack: {ptr, len, cap}; Heap: "Hallo"
let s2 = s1;
// Stack: nur die 24 Header-Bytes werden umkopiert (von s1-Slot zu s2-Slot)
// Heap: BLEIBT UNVERÄNDERT — kein memcpy von 5 BytesMove ist immer eine flache Bit-Kopie der Stack-Repräsentation — auch wenn der Wert Heap-Bytes hat. Heap-Bytes werden nie kopiert. Das ist der Grund, warum Move billig ist.
let a: i32 = 42; // 4 Bytes Stack
let b = a; // Bit-Kopie der 4 Bytes
// a und b sind unabhängig.Bei Copy-Typen ist es konzeptuell identisch zu Move (Bit-Kopie der Stack-Repräsentation), nur dass die Original-Bindung weiter nutzbar bleibt.
Praxis: Stack vs. Heap im echten Code
Fixed-Size-Buffer auf Stack
// SHA-256 produziert genau 32 Bytes — perfekt für Stack
struct Hash([u8; 32]);
fn berechne_hash(daten: &[u8]) -> Hash {
let mut h = Hash([0u8; 32]);
// ... SHA-256-Berechnung ...
h.0[0] = daten.len() as u8; // vereinfacht
h
}Der Hash hat fixe Größe (32 Bytes), passt also super auf den Stack. Keine Heap-Allocation, keine Vec-Overhead.
Stream-Verarbeitung mit [u8; 4096]
use std::io::Read;
use std::fs::File;
fn checksumme(pfad: &str) -> std::io::Result<u32> {
let mut datei = File::open(pfad)?;
let mut buffer = [0u8; 4096]; // 4 KB Stack-Buffer
let mut summe = 0u32;
loop {
let n = datei.read(&mut buffer)?;
if n == 0 { break; }
for &b in &buffer[..n] {
summe = summe.wrapping_add(b as u32);
}
}
Ok(summe)
}[u8; 4096] als Read-Buffer — kein Heap-Allokat, sehr schnell. Der Buffer wird mit dem Funktions-Return automatisch freigegeben.
Box für riesige Tabellen
const TABELLEN_GROESSE: usize = 1_000_000;
fn baue_tabelle() -> Box<[u32; TABELLEN_GROESSE]> {
// Direkt auf Heap allozieren (über Box):
let mut tabelle = Box::new([0u32; TABELLEN_GROESSE]);
for i in 0..TABELLEN_GROESSE {
tabelle[i] = (i * i) as u32;
}
tabelle
}4 MB als Stack-Variable wäre riskant. Mit Box lebt das Array auf dem Heap — der Stack hat nur den 8-Byte-Pointer.
Rekursiver AST
enum Expr {
Literal(i64),
Add(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
}
fn auswerten(e: &Expr) -> i64 {
match e {
Expr::Literal(n) => *n,
Expr::Add(a, b) => auswerten(a) + auswerten(b),
Expr::Mul(a, b) => auswerten(a) * auswerten(b),
}
}
fn main() {
// (2 + 3) * 4
let e = Expr::Mul(
Box::new(Expr::Add(Box::new(Expr::Literal(2)), Box::new(Expr::Literal(3)))),
Box::new(Expr::Literal(4)),
);
assert_eq!(auswerten(&e), 20);
}Klassisches AST-Pattern: rekursive Enum mit Box für die Kinder. Ohne Box würde der Compiler die Größe nicht berechnen können.
dyn Trait für Heterogene Sammlungen
trait Plugin {
fn name(&self) -> &str;
fn start(&mut self);
}
struct AuthPlugin;
impl Plugin for AuthPlugin {
fn name(&self) -> &str { "auth" }
fn start(&mut self) { println!("auth gestartet"); }
}
struct LogPlugin;
impl Plugin for LogPlugin {
fn name(&self) -> &str { "log" }
fn start(&mut self) { println!("log gestartet"); }
}
fn main() {
let mut plugins: Vec<Box<dyn Plugin>> = vec![
Box::new(AuthPlugin),
Box::new(LogPlugin),
];
for p in plugins.iter_mut() {
p.start();
}
}Vec<Box<dyn Plugin>> erlaubt heterogene Sammlung — verschiedene konkrete Typen, gemeinsamer Trait, dynamischer Dispatch.
String vs. &str — Stack-Trade-off
// String — Heap-Allokat, ownend
let s_owned: String = String::from("Hi");
// &str-Literal — kein Heap, lebt im Read-Only-Datensegment des Binaries
let s_borrowed: &'static str = "Hi";
// String-Slice einer anderen String — Borrow auf bestehendes Allokat
let s_slice: &str = &s_owned[..];String-Literale ("...") leben im Binary, nicht auf Stack oder Heap. Auf 64-bit-Systemen ist ein &'static str ein 16-Byte-Header (Pointer + Länge), der Pointer zeigt in das Read-Only-Datensegment.
Vec::with_capacity für Performance
fn baue_grosse_liste(n: usize) -> Vec<u64> {
// Schlecht — mehrere Reallocations beim Wachsen:
// let mut v = Vec::new();
// Besser — eine Allocation upfront:
let mut v = Vec::with_capacity(n);
for i in 0..n {
v.push(i as u64);
}
v
}Vec::new() startet mit cap=0. Bei jedem push über die aktuelle Kapazität wird der gesamte Heap-Block umkopiert. with_capacity(n) alloziert einmal die richtige Größe — drastisch schneller bei großen Vecs.
Box::leak für Static-Lifetime
fn lade_config_einmalig() -> &'static str {
let s = String::from("geladen aus Env");
Box::leak(s.into_boxed_str()) // explizit leaken → &'static str
}Box::leak gibt eine &'static-Referenz zurück und „verliert" den Heap-Speicher absichtlich (kein Drop). Sehr selten gewollt — typischerweise bei einmal initialisierter globaler Konfiguration.
Interessantes
Stack-Allokation ist „kostenlos“.
Stack-Allokation ist eine einzelne Instruktion (Inkrement des Stack-Pointers). Deshalb bevorzugt idiomatisches Rust Stack — [u8; 256] statt Vec<u8> mit 256 Elementen, wenn die Größe bekannt ist.
String, Vec, Box sind Heap-Wrapper.
Header (24, 24, 8 Bytes auf 64-bit) leben auf dem Stack, eigentlicher Inhalt auf dem Heap. Beim Move wird nur der Header umkopiert — der Heap-Inhalt bleibt liegen, nur sein „Besitzer" wechselt.
Stack hat eine harte Größenbegrenzung.
Default-Stack auf Linux/macOS: 8 MB. Auf Windows: 1 MB. Wer [u8; 10_000_000] als lokale Variable nutzt, riskiert Stack-Overflow. Lösung: Box::new(...) oder Vec::with_capacity(...).
Rekursive Datentypen brauchen Box oder ähnliches.
enum Tree { Node(Tree, Tree) } ist Compile-Fehler — Größe wäre unendlich. Box<Tree> bricht die Rekursion, weil ein Box (8 Bytes) eine bekannte Größe hat. Rc<Tree> oder Arc<Tree> lösen das gleiche Problem mit Reference Counting.
Box ist genau ein Heap-Wert, kein Container.
Anders als Vec<T> (variabel viele) oder Rc<T> (gezählt) hat Box<T> genau einen Wert. Verhalten wie eine &T-Referenz, aber owned — beim Drop wird Heap freigegeben.
Die Drop-Reihenfolge ist im Stack klar definiert.
LIFO innerhalb eines Scopes — letzte deklariert, erste gedroppt. Strukturen werden Feld für Feld in Deklarationsreihenfolge gedroppt, nachdem die eigene drop-Methode lief.
Allokator-Verhalten ist konfigurierbar.
Standardmäßig nutzt Rust den System-Allokator (malloc auf Linux/macOS). Mit #[global_allocator] kannst du auf jemalloc, mimalloc oder eigene Allokatoren wechseln — relevant für Hot-Path-Code mit vielen Allocations.
Fragmentation ist auf dem Heap ein Thema, nicht auf dem Stack.
Beim Heap entstehen über die Zeit Lücken zwischen freigegebenen Blöcken. Lange laufende Server-Prozesse müssen das im Auge behalten. Stack hat das Problem nicht — er ist immer kompakt.
Weiterführende Ressourcen
Externe Quellen
- The Rust Book – Stack and Heap
- The Rust Book – Box
- std::boxed::Box
- The Rustonomicon – Memory Layout
- Rust Reference – Type Layout