rgoussu@goussu: ~/library/rust/ownership-and-concurrency
~/library/rust/ownership-and-concurrency cat memory-without-gc.md

Memory without GC — layout, smart pointers & interior mutability

# Rust's memory anatomy — stack vs heap in the types, layout and niche optimization, the Box/Rc/Arc/Cow smart-pointer ladder, interior mutability from Cell to Mutex, leaks, and allocators.

Conceptsaved 2026-08-09 #rust#memory#smart-pointers#interior-mutability#allocators

Overview

The counterpart of Java's memory anatomy reads very differently: there are no runtime-managed regions to diagram, because where a value lives is written in its type. Locals live on the stack; Box<T> is a heap allocation; Rc/Arc are heap allocations with reference counts; and when the last owner drops, the free happens right there, compiled into the code. Where Go's escape analysis decides stack-vs-heap invisibly and the JVM decides everything at runtime, Rust surfaces the decision as API — which is why this note is mostly a tour of types, not of runtime machinery.

Key points

  • Stack by default, heap by request: values live inline (locals, struct fields — no per-object headers, no pervasive pointer-chasing); Box<T> moves one value to the heap (recursion, huge values, trait objects); Vec/String own heap buffers with (ptr, len, capacity) on the stack.
  • Layout is optimized, not stable: repr(Rust) may reorder fields to minimize padding; niche optimization packs enums into invalid bit patterns — Option<Box<T>>, Option<&T>, Option<NonZeroU32> are all pointer-sized, the null-pointer optimization generalized. repr(C) opts into stable layout for FFI.
  • The sharing ladder: &T (borrowed view, free) → Box<T> (one owner) → Rc<T> (shared ownership, single-thread, non-atomic counts) → Arc<T> (atomic counts, crosses threads) → add Weak<T> to break cycles (caches, parent pointers). Climb only as far as the design forces; each rung costs.
  • Cow<'a, T> defers own-vs-borrow to runtime — borrow when unchanged, allocate only on write; the workhorse of zero-copy parsing and normalize-if-needed APIs.
  • Interior mutability is the sanctioned escape hatch: shared-reference mutation through types that re-impose the rules differently — Cell<T> (copy in/out, zero cost), RefCell<T> (borrow rules checked at runtime, panics on violation), OnceCell/LazyLock (write-once init, the static initialization answer), Mutex/RwLock/atomics (the thread-safe rungs — see fearless concurrency). Rc<RefCell<T>> is the single-threaded "shared mutable object" idiom — and its panics are the price.
  • Safety ≠ no leaks: mem::forget, Box::leak and Rc cycles all leak safely — leaking is not memory-unsafety. Long-lived services still watch growth; the heap-profiling tools (dhat, heaptrack) and Weak discipline are the answers.
  • Drop order is specified: locals in reverse declaration order, fields in declaration order, and drop(value) forces early release — relevant for lock guards and RAII handles; a let _guard = … vs let _ = … distinction that bites exactly once per career.
  • Allocators are swappable: the global allocator (system malloc by default) can be replaced with one attribute — jemalloc/mimalloc for multithreaded churn; no compaction exists, so long-running fragmentation behaviour is the allocator's problem, not a GC's. #[global_allocator] is also how dhat instruments.

Details

The decision table

Need Reach for Cost
Pass a value without giving it up &T / &mut T Free — this is the default
Heap-place one value (recursion, dyn Trait) Box<T> One allocation
Several owners, one thread Rc<T> (+ RefCell if mutated) Non-atomic count; runtime borrow checks if RefCell
Several owners, across threads Arc<T> (+ Mutex/RwLock if mutated) Atomic count; lock contention
Break an ownership cycle Weak<T> Upgrade check on access
Maybe-modify borrowed data Cow<'_, T> Allocation only on write
Write-once global / lazy init OnceCell / LazyLock One-time synchronization
Mutate through &self, one thread Cell (Copy) / RefCell Zero / runtime checks & panic risk

Contrast with the managed runtimes

The JVM buys allocation speed with TLAB bump-pointers and pays in collector complexity; Go buys simplicity with escape analysis and a low-pause collector and pays ~CPU% forever. Rust's trade: allocation and free are explicit-but-inferred (no collector, no barriers, no pauses — the predictability embedded and latency-critical systems buy), and the price is this note's decision table living in your head instead of in a runtime. Fragmentation replaces GC tuning as the long-service memory concern; Arc count traffic replaces card tables as the sharing overhead.

Examples

// Niche optimization — no overhead for the Option:
assert_eq!(size_of::<Option<Box<u64>>>(), size_of::<Box<u64>>());

// The single-threaded shared-mutable idiom, runtime-checked:
let registry: Rc<RefCell<HashMap<Id, Node>>> = Rc::new(RefCell::new(HashMap::new()));
registry.borrow_mut().insert(id, node);      // panics if a borrow is live

// Guard scope — the classic drop-order bite:
let _guard = mutex.lock().unwrap();   // held to end of scope
let _ = mutex.lock().unwrap();        // dropped IMMEDIATELY — lock released

Related

Citations

[1] The Book, ch. 15 — Smart Pointers [2] The Rustonomicon — Data Layout [3] std::cell documentation — the interior-mutability catalog