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

AspektStackHeap
VerwaltungAutomatisch (Funktions-Calls)Allokiert/freigegeben explizit
GrößeBegrenzt (oft 1–8 MB pro Thread)Begrenzt nur durch System-RAM
GeschwindigkeitSehr schnell (Pointer-Inkrement)Langsamer (Allokator-Lookup)
LebensdauerFunktions-ScopeBeliebig, an Besitzer gebunden
Was lebt hierLokale Bindungen, Funktions-Args, kleine WerteHeap-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:

Rust Reine Stack-Werte
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:

Rust Stack-Frames
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?

Auf dem Heap landen Werte, deren Größe nicht zur Compile-Zeit bekannt ist (String, Vec) oder die explizit dort platziert werden sollen (Box). Die Stdlib-Typen verstecken die Heap-Operation hinter ihrer API — du musst nicht selbst malloc/free schreiben, sondern bekommst Allokation und Freigabe automatisch durch Konstruktoren und Drop. Folgende Typen sind die wichtigsten Heap-Wrapper:

String — die UTF-8-Bytes des Strings leben auf dem Heap; der Stack hält nur den Header mit Pointer, Länge und Kapazität.

Vec<T> — die Elemente leben in einem zusammenhängenden Heap-Block; der Stack hält denselben Header (Pointer, Länge, Kapazität).

HashMap, BTreeMap — die internen Buckets bzw. Knoten leben auf dem Heap; BTreeMap allokiert pro Knoten separat, HashMap einen großen Buckets-Block.

Box<T> — wraps genau einen Wert von T auf dem Heap. Der Stack hält einen Pointer; der Heap den Wert. Anwendung u. a. für rekursive Typen und große Werte.

Rc<T>, Arc<T> — Reference-Counted Smart Pointer. Auf dem Heap liegt der innere Wert plus der Counter; auf dem Stack ein Pointer pro Klon. Ermöglichen geteilten Owner-Zugriff.

Allen gemeinsam ist die zweistufige Struktur: Header auf dem Stack (schnell zu kopieren, klein), Inhalt auf dem Heap (kann beliebig groß sein). Genau diese Trennung macht Move-Semantik in Rust billig — beim Move wird nur der Stack-Header umkopiert, der Heap-Inhalt bleibt liegen.

String als Beispiel

Rust String-Struktur
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 als len sein, 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. Tatsächlich ist String intern ein Vec<u8> mit der UTF-8-Invariante, was die strukturelle Gleichheit kein Zufall, sondern Konsequenz ist.

Rust Vec-Struktur
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.

Rust Box
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 identisch

Box ist der minimalste Heap-Wrapper, den es gibt: ein Pointer auf dem Stack, der Wert auf dem Heap, beim Drop wird beides freigegeben. Anders als Vec oder String hält er genau einen Wert, keine Sequenz. Die Anwendungsfälle teilen sich grob in drei Kategorien — rekursive Typen, Trait-Objekte und einfach große Werte.

1. Rekursive Datenstrukturen

Rust Rekursive Struktur
// 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 mathematisch unendlich, weil Cons ein direktes Liste-Feld hätte und damit rekursiv enthalten wäre. Der Compiler scheitert mit „recursive type has infinite size". Mit Box<Liste> ist die Rekursion entkoppelt: das Box-Feld ist nur ein Pointer (8 Bytes), die nächsten Knoten leben auf dem Heap. Damit hat Liste eine finite, berechenbare Größe.

Dieses Pattern ist nicht auf einfache verkettete Listen beschränkt — Bäume (AST, Syntaxbäume in Compilern), Graphen mit beschränkter Struktur, JSON-Strukturen mit verschachtelten Werten brauchen alle Box (oder einen verwandten Smart Pointer) an den rekursiven Stellen.

2. Trait-Objekte

Rust dyn Trait
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());
    }
}

Wenn du eine heterogene Sammlung verschiedener Typen haben willst, die alle einen gemeinsamen Trait erfüllen, brauchst du Trait-Objekte. dyn Tier ist ein „unsized" Typ — seine Größe ist nicht zur Compile-Zeit festgelegt, weil sie vom konkreten implementierenden Typ abhängt (Hund ist anders groß als Katze). Rust erlaubt unsized Typen nicht als Wert auf dem Stack, weil der Stack-Frame eine feste Größe braucht.

Box<dyn Tier> löst das, indem das Trait-Objekt auf dem Heap landet und der Stack nur einen Pointer (zusammen mit einem Vtable-Pointer für dynamischen Method-Dispatch) hält. Damit kannst du einen Vec<Box<dyn Tier>> mit unterschiedlichen konkreten Typen haben, gemeinsam manipulierbar durch das Trait. Der Preis ist dynamischer Dispatch (Vtable-Lookup pro Method-Call) statt statischer Inline-Calls — bei seltenen Aufrufen unspürbar, bei sehr eng getakteten Loops messbar.

3. Große Werte aus dem Stack heraushalten

Rust Großer Wert
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).
}

Eine wichtige praktische Grenze: der Stack hat eine harte Größenbeschränkung. Linux/macOS-Threads haben typisch 8 MB Default-Stack, Windows nur 1 MB. Wer ein lokales [u8; 10_000_000] deklariert, riskiert sofortigen Stack-Overflow — das Programm crasht mit einem segfault, nicht mit einem schönen Fehler.

Mit Box::new(...) umgehst du das Problem. Bei optimierten Builds platziert der Compiler die Box-Konstruktion direkt auf dem Heap, ohne dass die 10 MB überhaupt durch den Stack laufen müssen. Bei nicht-optimierten Debug-Builds kann das jedoch tatsächlich passieren — daher die Empfehlung, bei wirklich großen Werten gleich vec!-Makro oder andere Heap-Konstruktoren zu verwenden, die garantiert direkt auf dem Heap arbeiten.

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:

Rust Move bei Heap-Typen
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 Bytes

Move 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.

Rust Move bei Stack-Typen (Copy)
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

Rust Stack-Buffer für Hash
// 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]

Rust Buffered Read
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

Rust Lookup-Table auf Heap
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

Rust Expression-Baum
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.

Heterogene Sammlungen über Box — Ausblick

Eine weitere typische Anwendung von Box sind heterogene Sammlungen: ein Vec, der verschiedene konkrete Typen aufnehmen kann, sofern sie alle eine gemeinsame Schnittstelle erfüllen. Die Syntax dafür (Vec<Box<dyn Trait>>) und das zugrundeliegende Konzept der Traits und Trait-Objekte kommen ab dem Traits-Kapitel — hier reicht das Bild „Box auf dem Heap, konkreter Typ erst zur Laufzeit bekannt".

String vs. &str — Stack-Trade-off

Rust String-Optionen
// 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.

Die Markierung 'static ist eine sogenannte Lifetime und bedeutet hier „lebt für die gesamte Programm-Laufzeit". Lifetimes sind ein eigenes Thema und werden in Kapitel 16 ausführlich behandelt; für jetzt reicht das mentale Bild: 'static heißt „immer gültig".

Vec::with_capacity für Performance

Rust Vorab-Allokation
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

Rust Leak (selten gewollt)
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 ist ein bewusster, kontrollierter Memory-Leak. Die Methode nimmt eine Box, gibt eine &'static-Referenz auf den inneren Wert zurück und unterdrückt das normale Drop. Der Heap-Speicher wird also bis zum Programm-Ende belegt bleiben.

Anwendungsfälle sind eng begrenzt: vor allem einmalig initialisierte globale Konfigurationen, die als &'static-Referenz an viele Stellen weitergegeben werden müssen, oder die Konvertierung dynamisch erzeugter Strings in „static"-Strings für APIs, die das erwarten. Bei normalen Anwendungen sollte Box::leak selten vorkommen — meist gibt es bessere Lösungen wie OnceLock, LazyLock oder eigene Singleton-Patterns. Aber gut zu wissen, dass es existiert, falls du an eine API stößt, die unbedingt &'static verlangt.

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

/ Weiter

Zurück zu Ownership

Zur Übersicht