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?

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

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:

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

Wann brauchst du Box?

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 unendlich (rekursiv). Mit Box ist nur der Pointer (8 Bytes) im Stack — die nächsten Knoten leben auf dem Heap.

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

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

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).
}

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:

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.

dyn Trait für Heterogene Sammlungen

Rust Plugin-Liste
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

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.

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

/ Weiter

Zurück zu Ownership

Zur Übersicht