Choosing How Memory Is Managed
Five answers — manual, tracing collection, ownership, reference counting and regions — and for each one, the code the compiler has to emit that the programmer never wrote.
Which memory management model should my language have, and what does each one make the compiler responsible for?
The definition's answer to two questions: when is storage released, and who decides. That answer becomes a compiler obligation — a set of instructions inserted into the program that no programmer wrote. Ownership inserts drops; reference counting inserts retains and releases; tracing collection inserts safepoints, write barriers and stack maps describing where every live reference is at every point the collector might run.
Every insertion and every elision has a precondition. A reference-counting compiler may remove a retain/release pair only if it can prove the object is kept alive by another strong reference for the whole interval — which fails if any intervening call could release that other reference, and fails across a thread boundary unless the counts are atomic. A compiler may omit a safepoint from a loop only if the loop provably terminates or contains a call that already has one, otherwise a tight loop makes the whole program unable to reach a collection and the process appears to hang.
Key points
- Every model except manual makes the compiler responsible for emitting code the programmer never wrote — drops, retains, safepoints, stack maps or barriers.
- Ownership is the only model that gives memory safety and deterministic release together, and it pays for that with a checker that rejects correct programs.
- Reference counting is mostly a compiler optimization problem, and the elimination it depends on requires an IR that still knows which values are counted.
- Cycles and atomic counts are the two reference-counting costs no optimizer removes.
- The models compose: regions inside any of them, counting inside ownership, manual inside managed. The design question is which is the default.
Five models, and what the compiler owes each
The question is usually posed as a runtime question. Half of it is a compiler question, and that half is what this domain owns: what the compiler must emit so that the chosen model works at all. The runtime behavior of a collector — generations, pauses, throughput — belongs to Runtime Internals and to the performance domain, and is deliberately not reteaching here.
| Model | When storage is released | What the compiler must emit | What it costs |
|---|---|---|---|
| Manual | When the programmer says so | Nothing. All obligations are on the programmer | Use-after-free, double-free and leaks, all as ordinary program behavior with no diagnostic |
| Tracing collection | Some time after the last reference is dropped, decided by the collector | Safepoints, stack maps naming every live reference at each one, and write barriers on reference stores, all of which must survive every optimization pass | Pauses the language cannot bound, plus barrier overhead on every reference write |
| Ownership and borrowing | Deterministically, when the owner goes out of scope | Drop calls at every scope exit including unwinding paths, plus a borrow checker that rejects programs | A checker that rejects correct programs, and a learning curve that is the language's main adoption cost |
| Automatic reference counting | Deterministically, when the last strong reference is released | Retain and release calls at every ownership transfer, and an optimizer that removes the redundant ones | Overhead on every reference operation, atomics if shared across threads, and cycles that are never collected |
| Regions and arenas | All at once, when the region is destroyed | Allocation into a bump pointer, and a check that no reference outlives its region | Memory held until the region ends, and a lifetime discipline that has to be enforced somewhere |
Ownership pushes the work into the type system
The distinctive property of ownership is that it moves the decision from the runtime to the checker. There is no collector, nothing scans the heap, and release points are decided statically — which is why the model gives both memory safety and predictable timing, the combination every other model gives up one half of.
The compiler obligation is larger than it looks. Drops must be inserted at every scope exit, including the exits produced by early returns and by unwinding after a panic — which means the model interacts directly with the error model from [[language-design-questions]], and is one reason Rust's panic-versus-abort choice is a build-level decision. Conditional initialisation requires drop flags, so the generated code sometimes carries a hidden boolean tracking whether a value still needs dropping. And the checker itself must be able to explain its rejections, which returns to the diagnostic architecture in [[language-ergonomics]].
The price is stated honestly by the language itself: the borrow checker rejects programs that are correct, notably those whose lifetimes are correct but not provably so by the rules, and the escape hatch is an explicitly unsafe block where the obligations return to the programmer. See [[ownership-types]] and [[lifetime-analysis]].
Reference counting is a compiler optimization problem
Automatic reference counting looks like a runtime technique and is largely a compiler one. The runtime part is trivial — increment, decrement, free at zero. The engineering is in removing the operations, because a naive implementation inserts a retain and a release around every use and the overhead is severe.
The optimizer's job is pairwise elimination: if an object is provably kept alive by another strong reference across an interval, the retain and release inside it are redundant and may be removed. The precondition is exactly the one in this lesson's legality field, and it is hard to establish because any intervening call might release the other reference. Swift's ARC optimizer spends most of its effort here, which is why Swift has SIL — an IR that still knows which values are reference-counted, a fact LLVM IR does not carry.
The two costs that do not go away are cycles, which are never collected and must be broken by the programmer with weak references, and atomicity: a reference shared across threads needs atomic counts, which are substantially more expensive than ordinary increments and which no optimizer can remove without proving the object is thread-local. That proof is escape analysis, and it is the same analysis discussed in [[escape-analysis]].
retain(obj) // because `local` now holds a reference local = obj use(local) release(obj) // `local` goes out of scope
local = obj use(local)
Only if some other strong reference to obj is provably live across the entire interval between the retain and the release, and nothing in that interval can release it. use must not be able to store local somewhere that outlives the interval, must not release the other reference, and — if the count is atomic because the object may be shared — the elision additionally requires that no other thread can drop the last remaining reference during the interval.
If use may release the other strong reference, directly or through a callback, or if obj came from a weak reference that another thread may clear, or if use stores local into a structure that outlives the scope. In any of those cases the object may be freed while local still points at it, and the program reads freed memory — a use-after-free introduced by the optimizer, in a language whose entire promise was that this could not happen.
The choice is a chain, not a menu
Return to the audience answer from [[who-is-the-language-for]] and the model usually falls out. If unbounded pauses are unacceptable, tracing collection is out, and the remaining options are manual, ownership or reference counting. If the audience will not learn a borrow checker, ownership is out, and the remaining options are manual or reference counting. If memory safety is required, manual is out. Three audience facts, one answer.
Regions deserve more attention than they get, because they are the model that composes with the others. An arena inside a garbage-collected language, or inside a manual one, converts many small allocations with individually unclear lifetimes into one allocation with an obvious one — which is why compilers themselves, including this domain's subject matter, are so often arena-allocated. The AST lives until the phase ends and then all of it dies at once, which is precisely the shape a region is for.
And the models mix in production far more than the framing suggests. A garbage-collected language with value types and stack allocation is doing manual management for the cases it can prove; a Rust program using Rc is doing reference counting inside an ownership language; a C++ program using shared_ptr is doing reference counting with a manual escape hatch underneath. The design question is which model is the default and which is the escape, not which is the only one available.
How it works
The steps, in the order the compiler takes them.
- The definition fixes when storage is released and who decides, which determines the compiler obligation.
- For tracing collection the compiler emits safepoints, a stack map at each one naming every live reference, and the barriers the chosen collector requires.
- For ownership the compiler runs a borrow check over an IR that still carries lifetimes, then inserts drop calls at every scope exit including unwind paths, with drop flags where initialisation is conditional.
- For reference counting the compiler inserts retain and release at ownership transfers and then runs an elimination pass over an IR that distinguishes counted references from ordinary pointers.
- For regions the compiler lowers allocation to a bump pointer and checks that no reference escapes the region, either through a type discipline or through a run-time check.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A tight computational loop with no function calls makes the whole process unresponsive, because the compiler emitted no safepoint inside it and the collector cannot start until the loop finishes.
- A reference-counted program leaks steadily under load, and the cause is a cycle between two objects that both hold strong references, which no tool reports as an error.
- A borrow-checked program is rewritten several times to satisfy the checker, and the final version is slower and less readable than the original — the cost of a rejection the rules could not express.
- A garbage-collected service meets its latency target in testing and misses it in production, because the pause distribution depends on live-set size and the test data was smaller.
- An arena-allocated compiler holds every AST from every file until the end of the build, and peak memory becomes the constraint on how large a project it can compile.
- An optimizer removes a retain/release pair whose precondition it established incorrectly, and the program reads freed memory in a language that promised it could not.
When it helps
- Deciding the model early, when it is still a decision. Every model is deeply entangled with the type system, the error model and the concurrency model, and none of them can be swapped later.
- Understanding why a language behaves as it does at the boundary: why Rust has
Rc, why Swift hasweak, why Go has escape analysis, why Java has value types now. - Choosing an allocation strategy inside a program in any language, where arenas are usually available and usually underused.
When it hurts
- Treating the model as the dominant performance factor. Allocation rate, object size and locality usually matter more than the reclamation mechanism, and a program that allocates less is faster under every model.
- Adopting ownership for an audience that will not pay for it. The checker is not optional and cannot be made advisory without giving up the guarantee that justified it.
What it costs
Every one of these is paid by something.
- Tracing collection buys memory safety, cycle collection and a programming model with no lifetime obligations, and costs pauses the language cannot bound, barrier overhead on reference stores, and a compiler that must maintain precise stack maps through every optimization.
- Ownership buys safety plus deterministic release with no runtime component, and costs a checker that rejects correct programs, an unsafe escape hatch that reintroduces every manual hazard where it is used, and a learning curve that is measurably the language's largest adoption barrier.
- Reference counting buys deterministic release and predictable pauses, and costs an increment and decrement on every reference operation, atomic operations wherever sharing is possible, cycles that leak, and an optimizer whose elimination pass is where most of the implementation effort goes.
- Regions buy near-free allocation and a single deallocation, and cost peak memory — everything in the region is retained until the region ends, whether it is needed or not.
What else you could do
What a different compiler or language does instead, and when that is better.
- A hybrid default: garbage collection with value types and escape analysis so that provably local objects are stack-allocated, which is what Go and modern JVMs do and which recovers a large fraction of manual management's benefit with none of its hazards.
- Ownership with an opt-in counted type, which is Rust with
RcandArc: the default is static, the escape is dynamic, and the cost is visible in the type. - Linear or affine types without the full borrow discipline, which several research and systems languages use to get single-ownership guarantees with a much smaller rule set.
- No heap at all. Many embedded and real-time languages forbid dynamic allocation entirely, which removes the question and constrains the programs — a legitimate answer for a specific audience.
See it for yourself
The flag, dump or tool that shows you this directly.
- What the compiler inserted for you:
rustc -Z unpretty=miron nightly shows the drop calls and drop flags that do not appear in your source. - Reference-counting traffic: Swift's
swiftc -emit-silshows retain and release at the SIL level, before and after the ARC optimizer. - Safepoint and barrier code: compile a Go or Java method and disassemble it — the extra loads and compares around loop back-edges and reference stores are the obligation this lesson describes.
- Whether an allocation escaped:
go build -gcflags="-m"prints escape-analysis decisions per allocation, which is the most directly useful version of this whole lesson for a working engineer. - Arena behavior in a real compiler: build a large project and watch peak resident memory rather than time. The shape of that curve is the region trade.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Garbage collection means the programmer does not think about memory." It means the programmer does not think about *release*. Allocation rate, retention and object size still determine the program's behavior, and they are still the programmer's.
- "Reference counting has no pauses." Dropping the last reference to the root of a large structure frees the whole structure at that point, which is an unbounded amount of work in one place — the pause moved rather than disappeared.
- "The borrow checker prevents memory bugs." It prevents them in checked code. Unsafe blocks exist, are used by every non-trivial program indirectly through libraries, and are where the guarantee is discharged by a human.
- "Arenas are a micro-optimization." Replacing a million small allocations with one is usually the largest single allocation improvement available, and it changes the deallocation cost to nearly zero.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
There are five answers to when memory is released: the programmer says so, a collector decides, the owner going out of scope decides, the last reference disappearing decides, or a whole region ends at once. Each answer creates work for the compiler that the programmer never sees — drop calls, retain and release calls, or the bookkeeping a collector needs to find live references.
practical
For an existing language, the actionable version is allocation, not reclamation: allocate less, allocate larger, and let escape analysis keep things on the stack. go build -gcflags=-m and the equivalent in other toolchains will tell you which allocations escaped and why, and moving one out of a hot loop usually beats any change to collector tuning. For a new language, decide the model before the type system, because it constrains the type system rather than the reverse.
advanced
The models sort by where the lifetime information lives, and that is the useful way to hold them. Manual keeps it in the programmer's head, where it cannot be checked. Tracing keeps it in the heap graph, discovered at run time, which costs pauses and buys freedom from annotation. Counting keeps it in the object, updated continuously, which costs operations and buys determinism. Ownership keeps it in the type, checked at build time, which costs expressiveness and buys both safety and determinism. Regions keep it in the program structure, which costs peak memory and buys near-zero cost. Every hybrid in production is an attempt to keep lifetime information in the cheapest place that can still answer the question, per allocation — which is exactly what escape analysis is, and it is why escape analysis appears in the compiler of every garbage-collected language that cares about performance.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
If you were asked this in an interview
- What must a compiler emit to support a tracing collector, and what happens if it omits a safepoint in a loop?
- Why is reference counting mostly a compiler problem rather than a runtime one?
- Ownership gives safety and deterministic release. What does it give up, and is there a language that gets all three?
Connections
- Programming Languages & Runtime Internals — Collector algorithms, generational hypotheses, pause behavior and heap tuningHow a collector works and how it performs is theirs. What the compiler must emit so that any collector can work at all — safepoints, stack maps, barriers — is ours, and the two are usually written by the same team, which is why the boundary needs stating explicitly.