Guide contents

Lesson 74 of 94

Memory Without a Garbage Collector

Rust has no garbage collector. What matters instead is knowing when memory is allocated and freed, and what each of those costs. You already know the rules from Ownership and Smart Pointers. This section adds the model an experienced engineer uses to reason about memory in a real service:

  1. This lesson: where values live, when they are freed, what freeing costs, how leaks happen, and how to design data without a GC.
  2. Allocators & Measuring Memory: the global allocator, measuring and limiting memory, fragmentation, arenas, and pools. This is the practical side of memory tuning.
  3. The Rust Memory Model: happens-before, atomic orderings, fences, and false sharing.
  4. Aliasing & Undefined Behavior: the rules the optimizer relies on, and what unsafe code must never break.
Concept Rust
Where values live Inline (stack, struct field, Vec slot) unless you choose Box, Vec, String, Rc, ...
Per-value overhead No header; a struct is exactly its fields plus padding
When memory is freed Ownership decides: freed when the owner goes out of scope
When cleanup runs drop runs right away, on the thread that drops the value
Pauses No pauses, but freeing a large structure takes time where it is dropped
Leaks Reachable data you forgot about, Rc/Arc cycles, Box::leak, and mem::forget
Resource cleanup Drop (RAII), which covers memory, files, locks, and sockets alike
Object graphs One owner per value; graphs use indices, arenas, or Rc + Weak

Part 1: Where values live

1. Values are inline by default

In Rust, let p = Point { x: 1, y: 2 } puts the eight bytes of the point in the variable itself, on the stack. Only types that manage a heap buffer (Box, Vec, String, HashMap, ...) allocate.

Rust
use std::mem::size_of;

#[allow(dead_code)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 1, y: 2 };
    let boxed = Box::new(Point { x: 3, y: 4 });

    println!("Point on the stack: {} bytes", size_of::<Point>());
    println!("Box<Point> variable: {} bytes (a pointer to 8 heap bytes)", size_of::<Box<Point>>());
    println!("String variable: {} bytes (pointer, capacity, length)", size_of::<String>());
    println!("Vec<Point> variable: {} bytes (pointer, capacity, length)", size_of::<Vec<Point>>());
    println!("{} {}", p.x + p.y, boxed.x + boxed.y);
}
Output
Point on the stack: 8 bytes
Box<Point> variable: 8 bytes (a pointer to 8 heap bytes)
String variable: 24 bytes (pointer, capacity, length)
Vec<Point> variable: 24 bytes (pointer, capacity, length)
3 7

2. No object headers: collections are contiguous

A Vec<Point> is one heap buffer with the points stored one after another. Iterating over it reads memory sequentially, which is what CPU caches are best at.

Rust
use std::mem::size_of;

#[derive(Clone, Copy)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let points: Vec<Point> = (0..1000).map(|i| Point { x: i, y: -i }).collect();

    let first = &points[0] as *const Point as usize;
    let second = &points[1] as *const Point as usize;
    println!("distance between neighbours: {} bytes", second - first);
    println!("total heap bytes for 1000 points: {}", points.capacity() * size_of::<Point>());
    println!("sum: {}", points.iter().map(|p| p.x + p.y).sum::<i32>());
}
Output
distance between neighbours: 8 bytes
total heap bytes for 1000 points: 8000
sum: 0

3. Ownership replaces reachability

Rust frees a value when its owner goes out of scope, at exactly that point in the code. Drop shows when it happens.

Rust
struct Buffer(&'static str);

impl Drop for Buffer {
    fn drop(&mut self) {
        println!("  freed {}", self.0);
    }
}

fn main() {
    println!("start");
    let _outer = Buffer("outer");
    {
        let _inner = Buffer("inner");
        println!("end of inner block");
    }
    let temp = Buffer("temp");
    drop(temp);
    println!("end of main");
}
Output
start
end of inner block
  freed inner
  freed temp
end of main
  freed outer

There's nothing to tune and nothing that runs "eventually". A reader can point at the line where each value is freed.

4. Drop order is defined

Local variables are dropped in reverse order of declaration. Struct fields are dropped in declaration order, after the struct's own drop. Temporaries are dropped at the end of the statement that created them. The order matters when one value depends on another, for example a transaction that must finish before its connection closes.

Rust
struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("drop {}", self.0);
    }
}

struct Service {
    _cache: Noisy,
    _pool: Noisy,
}

fn main() {
    let _a = Noisy("local a");
    let _service = Service { _cache: Noisy("field cache"), _pool: Noisy("field pool") };
    let _b = Noisy("local b");
    let len = Noisy("temporary").0.len();
    println!("temporary already gone, len = {len}");
}
Output
drop temporary
temporary already gone, len = 9
drop local b
drop field cache
drop field pool
drop local a

5. Moving doesn't copy the heap buffer

A move copies the small handle (for a Vec, 24 bytes) and leaves the heap buffer where it is. Passing ownership of a large collection to a function or another thread is cheap.

Rust
fn consume(data: Vec<u64>) -> usize {
    data.as_ptr() as usize
}

fn main() {
    let data: Vec<u64> = (0..1_000_000).collect();
    let before = data.as_ptr() as usize;
    let after = consume(data);
    println!("same heap buffer after the move: {}", before == after);
}
Output
same heap buffer after the move: true

clone() is the operation that copies the heap buffer. When a profile shows memory or time going into copies, look for clone(), to_vec(), to_string(), and to_owned().

Part 2: What freeing costs

6. Freeing is work, and it happens where you drop

Rust frees each allocation individually: dropping a Vec<String> with a million entries makes a million and one calls to the allocator. A counting allocator shows it (the next lesson explains global allocators).

Rust
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};

struct Counting;
static FREES: AtomicUsize = AtomicUsize::new(0);

unsafe impl GlobalAlloc for Counting {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        unsafe { System.alloc(layout) }
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        FREES.fetch_add(1, Relaxed);
        unsafe { System.dealloc(ptr, layout) }
    }
}

#[global_allocator]
static GLOBAL: Counting = Counting;

fn main() {
    let names: Vec<String> = (0..100_000).map(|i| format!("user-{i}")).collect();
    let ids: Vec<u64> = (0..100_000).collect();

    let before = FREES.load(Relaxed);
    drop(names);
    println!("dropping 100k Strings: {} frees", FREES.load(Relaxed) - before);

    let before = FREES.load(Relaxed);
    drop(ids);
    println!("dropping 100k u64s: {} free", FREES.load(Relaxed) - before);
}
Output
dropping 100k Strings: 100001 frees
dropping 100k u64s: 1 free

This has two consequences:

  • A collection of plain values (u64, [u8; 16], Copy structs) is cheaper to create and to free than a collection of heap-owning values.
  • Dropping a huge structure at the end of a request adds latency to that request, at a place you choose.

7. Moving a big drop off the hot path

When a large structure is replaced, for example a routing table or a cache reloaded from disk, you can hand the old one to another thread to free, so the request thread doesn't pay for it.

Rust
use std::collections::HashMap;
use std::sync::mpsc;
use std::thread;

type Table = HashMap<String, Vec<String>>;

fn load_table(version: u32) -> Table {
    (0..50_000).map(|i| (format!("key-{i}"), vec![format!("v{version}")])).collect()
}

fn main() {
    let (to_dropper, garbage) = mpsc::channel::<Table>();
    let dropper = thread::spawn(move || {
        let mut freed = 0;
        for old in garbage {
            freed += old.len();
            drop(old); // the expensive part runs here, not on the request thread
        }
        freed
    });

    let mut table = load_table(1);
    for version in 2..=3 {
        let old = std::mem::replace(&mut table, load_table(version));
        to_dropper.send(old).unwrap(); // O(1) for this thread
        println!("serving version {version}: {}", table["key-7"][0]);
    }

    drop(to_dropper);
    println!("background thread freed {} entries", dropper.join().unwrap());
}
Output
serving version 2: v2
serving version 3: v3
background thread freed 100000 entries

Use this only when a profile shows the drop matters. For most values, freeing in place is fast and keeps memory use predictable.

8. Capacity: clear keeps memory, shrink_to_fit returns it

Collections keep their buffer when you remove elements. That's usually what you want: a reused buffer never allocates again. It also means a Vec that once held a million elements keeps that memory until it is dropped or shrunk.

Rust
fn main() {
    let mut batch: Vec<u64> = Vec::with_capacity(1_000_000);
    batch.extend(0..1_000_000);
    println!("after filling:       len {:>7}, capacity {:>7}", batch.len(), batch.capacity());

    batch.clear();
    println!("after clear:         len {:>7}, capacity {:>7}", batch.len(), batch.capacity());

    batch.extend(0..10);
    batch.shrink_to_fit();
    println!("after shrink_to_fit: len {:>7}, capacity {:>7}", batch.len(), batch.capacity());
}
Output
after filling:       len 1000000, capacity 1000000
after clear:         len       0, capacity 1000000
after shrink_to_fit: len      10, capacity      10

The same holds for String, HashMap, VecDeque, and BinaryHeap. A long-lived buffer that sometimes spikes should be shrunk after the spike (shrink_to(n) keeps some headroom), or rebuilt.

9. Growth doubles the buffer

A Vec that grows past its capacity allocates a buffer twice as large, copies the elements, and frees the old one. Pushes are cheap on average, but at the moment of growth the old and new buffers usually exist together (unless the allocator can extend the block in place), so peak memory can reach three times the old size.

Rust
fn main() {
    let mut v: Vec<u32> = Vec::new();
    let mut last = v.capacity();
    println!("capacity {last}");
    for i in 0..100 {
        v.push(i);
        if v.capacity() != last {
            last = v.capacity();
            println!("push #{:>3} -> capacity {last}", i + 1);
        }
    }
}
Output
capacity 0
push #  1 -> capacity 4
push #  5 -> capacity 8
push #  9 -> capacity 16
push # 17 -> capacity 32
push # 33 -> capacity 64
push # 65 -> capacity 128

When you know the final size, Vec::with_capacity(n) or collect() from an iterator with an exact length allocates once. Performance has more allocation habits.

Part 3: Leaks

10. Leaks are safe in Rust

Rust guarantees memory safety: no use-after-free, no double free, no dangling reference. It does not guarantee that everything is freed. Leaking memory is safe, and there are several ways to do it:

Leak How it happens Fix
Growing collection a HashMap cache or queue that is never trimmed bound it: capacity limit, TTL, LRU eviction
Rc/Arc cycle two values that own each other make one direction Weak
Box::leak deliberately turns a Box<T> into &'static mut T intended for data that lives until exit
mem::forget skips Drop use ManuallyDrop when you really need this
Detached task or thread a spawned loop that never ends and owns data keep a handle; cancel or signal it on shutdown

The first one is still the most common in production.

11. A reference cycle keeps both values alive

A parent and a child that hold Rcs to each other each keep the other's count above zero. Neither is ever freed. Rc & Weak covers this in detail; the short version is that ownership must point one way.

Rust
use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Node {
    name: &'static str,
    parent: RefCell<Option<Rc<Node>>>,
    parent_weak: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

impl Drop for Node {
    fn drop(&mut self) {
        println!("freed {}", self.name);
    }
}

fn node(name: &'static str) -> Rc<Node> {
    Rc::new(Node { name, parent: RefCell::new(None), parent_weak: RefCell::new(Weak::new()), children: RefCell::new(vec![]) })
}

fn main() {
    {
        let parent = node("leaky parent");
        let child = node("leaky child");
        parent.children.borrow_mut().push(Rc::clone(&child));
        *child.parent.borrow_mut() = Some(Rc::clone(&parent)); // strong back pointer: a cycle
    }
    println!("-- the leaky pair was never freed --");
    {
        let parent = node("parent");
        let child = node("child");
        parent.children.borrow_mut().push(Rc::clone(&child));
        *child.parent_weak.borrow_mut() = Rc::downgrade(&parent); // weak back pointer
        println!("child's parent: {}", child.parent_weak.borrow().upgrade().unwrap().name);
    }
    println!("end of main");
}
Output
-- the leaky pair was never freed --
child's parent: parent
freed parent
freed child
end of main

12. Box::leak: a deliberate leak for program-wide data

Sometimes you want a value to live for the rest of the program: configuration read at startup, a string table, a logger. Box::leak turns an owned value into a &'static reference. The memory is never freed, which is fine because it's needed until exit anyway, and every thread can borrow it without Arc.

Rust
use std::thread;

struct Config {
    region: String,
    workers: usize,
}

fn load_config() -> &'static Config {
    let config = Box::new(Config { region: "eu-west-1".to_string(), workers: 3 });
    Box::leak(config)
}

fn main() {
    let config: &'static Config = load_config();
    let handles: Vec<_> = (0..config.workers)
        .map(|id| thread::spawn(move || format!("worker {id} in {}", config.region)))
        .collect();
    for h in handles {
        println!("{}", h.join().unwrap());
    }
}
Output
worker 0 in eu-west-1
worker 1 in eu-west-1
worker 2 in eu-west-1

Don't call Box::leak in a loop or per request: that turns a one-time cost into a real leak. For lazily created globals, prefer static CONFIG: OnceLock<Config> (Atomics, Send & Sync).

13. A bounded cache instead of a growing map

The most common leak is a map used as a cache that only grows. The fix is to bound it. Here a simple cache evicts the oldest entry when it's full.

Rust
use std::collections::{HashMap, VecDeque};

struct BoundedCache {
    capacity: usize,
    map: HashMap<String, String>,
    order: VecDeque<String>,
}

impl BoundedCache {
    fn new(capacity: usize) -> Self {
        Self { capacity, map: HashMap::with_capacity(capacity), order: VecDeque::with_capacity(capacity) }
    }

    fn insert(&mut self, key: String, value: String) {
        if self.map.insert(key.clone(), value).is_none() {
            self.order.push_back(key);
            if self.order.len() > self.capacity {
                let oldest = self.order.pop_front().unwrap();
                self.map.remove(&oldest);
            }
        }
    }
}

fn main() {
    let mut cache = BoundedCache::new(1000);
    for i in 0..1_000_000 {
        cache.insert(format!("session-{i}"), format!("user-{}", i % 97));
    }
    println!("entries: {}", cache.map.len());
    println!("oldest kept: {}", cache.order.front().unwrap());
    println!("still has newest: {}", cache.map.contains_key("session-999999"));
}
Output
entries: 1000
oldest kept: session-999000
still has newest: true

In production, use a crate such as moka or lru, which add LRU/TTL policies and thread safety. The point is that every long-lived collection needs a size policy.

Part 4: Designing data without a GC

14. One owner per value, borrowed everywhere else

In Rust, each value needs one owner, and everything else borrows it. Good Rust designs make the owner obvious: usually a long-lived struct (the application state, a request context) owns the data, and functions take &T or &mut T.

Rust
struct User {
    name: String,
    orders: Vec<u32>,
}

struct AppState {
    users: Vec<User>, // the single owner of all users
}

// Readers borrow. Nothing is cloned, nothing is reference-counted.
fn total_orders(users: &[User]) -> usize {
    users.iter().map(|u| u.orders.len()).sum()
}

fn busiest(users: &[User]) -> Option<&User> {
    users.iter().max_by_key(|u| u.orders.len())
}

fn main() {
    let state = AppState {
        users: vec![
            User { name: "ana".into(), orders: vec![1, 2] },
            User { name: "bo".into(), orders: vec![3, 4, 5] },
        ],
    };
    println!("orders: {}", total_orders(&state.users));
    println!("busiest: {}", busiest(&state.users).map(|u| u.name.as_str()).unwrap_or("-"));
}
Output
orders: 5
busiest: bo

The warning sign is Rc<RefCell<T>> or Arc<Mutex<T>> everywhere. It's usually a sign of an "everything references everything" design. It works, but it gives up compile-time checking and adds reference-count traffic.

15. Object graphs with indices instead of references

Graphs, trees with parent pointers, and entity models are where reference-heavy designs hurt most. The idiomatic Rust design stores all nodes in a Vec owned by the graph, and nodes refer to each other by index. Cycles are no problem, there's one allocation per Vec instead of one per node, and the whole graph is freed in one go.

Rust
#[derive(Clone, Copy, Debug, PartialEq)]
struct EmployeeId(usize);

struct Employee {
    name: String,
    manager: Option<EmployeeId>,
    reports: Vec<EmployeeId>,
}

#[derive(Default)]
struct OrgChart {
    people: Vec<Employee>,
}

impl OrgChart {
    fn add(&mut self, name: &str, manager: Option<EmployeeId>) -> EmployeeId {
        let id = EmployeeId(self.people.len());
        self.people.push(Employee { name: name.to_string(), manager, reports: vec![] });
        if let Some(m) = manager {
            self.people[m.0].reports.push(id); // a back reference: just a number
        }
        id
    }

    fn chain_of_command(&self, mut id: EmployeeId) -> Vec<&str> {
        let mut chain = vec![self.people[id.0].name.as_str()];
        while let Some(m) = self.people[id.0].manager {
            chain.push(&self.people[m.0].name);
            id = m;
        }
        chain
    }
}

fn main() {
    let mut org = OrgChart::default();
    let ceo = org.add("Grace", None);
    let cto = org.add("Linus", Some(ceo));
    let dev = org.add("Ada", Some(cto));
    org.add("Ken", Some(cto));

    println!("{}", org.chain_of_command(dev).join(" -> "));
    let names: Vec<&str> = org.people[cto.0].reports.iter().map(|r| org.people[r.0].name.as_str()).collect();
    println!("Linus manages {names:?}");
}
Output
Ada -> Linus -> Grace
Linus manages ["Ada", "Ken"]

The newtype EmployeeId stops you from mixing up ids of different kinds. If nodes can be removed, use a generational index (the slotmap crate) so a stale id can't silently point to a new node. This is how ECS game engines, compilers (rustc itself), and graph libraries such as petgraph are built.

16. Arc has a cost: prefer borrowing across scoped threads

Arc::clone is an atomic increment, and each drop is an atomic decrement. On a hot path across many cores, those shared counters bounce between CPU caches. When threads don't outlive the data, thread::scope lets them borrow it directly, with no counting at all.

Rust
use std::sync::Arc;
use std::thread;

fn main() {
    let words: Vec<String> = "the quick brown fox jumps over the lazy dog".split(' ').map(String::from).collect();

    // With Arc: every thread holds a counted owner.
    let shared = Arc::new(words.clone());
    let handles: Vec<_> = (0..3)
        .map(|t| {
            let shared = Arc::clone(&shared);
            thread::spawn(move || shared.iter().skip(t).step_by(3).map(|w| w.len()).sum::<usize>())
        })
        .collect();
    let arc_total: usize = handles.into_iter().map(|h| h.join().unwrap()).sum();

    // With scoped threads: plain borrows, freed by the single owner afterwards.
    let scoped_total: usize = thread::scope(|s| {
        let words = &words;
        let handles: Vec<_> = (0..3).map(|t| s.spawn(move || words.iter().skip(t).step_by(3).map(|w| w.len()).sum::<usize>())).collect();
        handles.into_iter().map(|h| h.join().unwrap()).sum()
    });

    println!("{arc_total} {scoped_total}");
}
Output
35 35

Use Arc when data must outlive the code that created it, such as shared state in a server or data handed to spawned async tasks. Inside a function that fans out and joins, borrow.

17. Rc<str> and Arc<[T]> for shared immutable data

When you do need shared ownership of immutable data, Arc<str> and Arc<[T]> store the data in the same allocation as the counts. Arc<String> has two allocations and an extra pointer hop.

Rust
use std::mem::size_of;
use std::sync::Arc;

fn main() {
    let tenant: Arc<str> = Arc::from("acme-corp");
    let copies: Vec<Arc<str>> = (0..3).map(|_| Arc::clone(&tenant)).collect();

    println!("Arc<str> handle: {} bytes, Arc<String> handle: {} bytes", size_of::<Arc<str>>(), size_of::<Arc<String>>());
    println!("owners: {}, all the same data: {}", Arc::strong_count(&tenant), copies.iter().all(|c| Arc::ptr_eq(c, &tenant)));
}
Output
Arc<str> handle: 16 bytes, Arc<String> handle: 8 bytes
owners: 4, all the same data: true

The Arc<str> handle is 16 bytes because it's a fat pointer that stores the length. Reading the string then takes one pointer hop instead of two.

18. Choosing an ownership strategy

Situation Choose
One clear owner, others read owner holds T, others take &T
Owner is dynamic or the value is large and moved often Box<T>
A tree (children owned by parent) Vec<Child> or Vec<Box<Node>> in the parent
A graph, cycles, back pointers Vec<Node> + index ids, or slotmap
Shared immutable data, single thread Rc<T>, Rc<str>
Shared immutable data across threads or tasks Arc<T>, Arc<str>
Shared mutable state across threads Arc<Mutex<T>> / Arc<RwLock<T>>, or a channel to one owner
Many short-lived values freed together an arena (next lesson)
Program-wide data static, OnceLock, LazyLock, or Box::leak once

19. Realistic example: a request that frees everything at once

Per-request scratch data is short-lived. Collecting it in one owned struct means all of it is freed together, deterministically, when the request ends, and nothing escapes by accident.

Rust
use std::collections::HashMap;

struct RequestScratch<'a> {
    tokens: Vec<&'a str>, // borrows from the request body: no copies
    counts: HashMap<&'a str, usize>,
}

impl Drop for RequestScratch<'_> {
    fn drop(&mut self) {
        println!("  request done: freed {} tokens and {} map entries", self.tokens.len(), self.counts.len());
    }
}

fn handle(body: &str) -> Vec<(String, usize)> {
    let mut scratch = RequestScratch { tokens: body.split_whitespace().collect(), counts: HashMap::new() };
    for t in &scratch.tokens {
        *scratch.counts.entry(t).or_default() += 1;
    }
    let mut top: Vec<(String, usize)> = scratch.counts.iter().map(|(w, c)| (w.to_string(), *c)).collect();
    top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
    top.truncate(2);
    top // only the small result escapes; `scratch` is dropped here
}

fn main() {
    for body in ["to be or not to be", "a b a c a b"] {
        println!("{:?}", handle(body));
    }
}
Output
  request done: freed 6 tokens and 4 map entries
[("be", 2), ("to", 2)]
  request done: freed 6 tokens and 3 map entries
[("a", 3), ("b", 2)]

The tokens borrow from body, so the scratch data holds only pointers into the request. The compiler guarantees none of them outlives it. The only allocations that survive the request are the two result strings.

20. A memory checklist for code review

Look for Why it matters
clone() of a Vec, String, or HashMap in a loop or hot path copies the whole heap buffer every time
Rc<RefCell<_>> / Arc<Mutex<_>> in many types usually a missing owner; consider indices or passing &mut
a back pointer that is an Rc or Arc a cycle, so a leak; make it Weak or an index
a long-lived HashMap, Vec, or channel with no size limit the classic leak; add eviction or back-pressure
Vec<Box<T>> or Vec<String> for data that could be inline one allocation and one free per element
a huge structure dropped at the end of a latency-sensitive request drop time shows up in p99; drop it elsewhere or reuse it
a big buffer that is only clear()ed keeps its peak capacity forever; shrink_to after spikes
Box::leak or mem::forget outside startup code a real leak

Summary

  • Values are stored inline, without object headers. Only Box, Vec, String, Rc, Arc, and other owning types allocate.
  • Memory is freed when the owner goes out of scope, in a defined order and on the dropping thread. There's no collector to tune, but freeing has a cost that lands where you drop.
  • Moves are cheap and never copy the heap buffer; clone() does.
  • Collections keep their capacity. Growth doubles the buffer, and shrink_to_fit gives memory back.
  • Leaks are safe: growing collections, Rc/Arc cycles, Box::leak, and mem::forget all leak. Bound every long-lived collection and make back pointers Weak or indices.
  • Design data around one owner. Graphs use indices, fan-out work uses scoped borrows, and Arc is for data that must outlive its creator.

Next: Allocators & Measuring Memory: swapping the global allocator, measuring and limiting memory, fragmentation, arenas, and pools.