Programming Languages · Memory and Ownership

How Escape Analysis Removes a Heap Allocation

How a compiler proves that an allocation cannot be observed outside its function, and what it must give up when object identity or a stored pointer blocks the proof.

The cheapest allocation is the one that never happens. Every managed runtime and most optimizing compilers therefore try to prove that a particular allocation cannot be observed after its function returns, and if the proof succeeds, place the object in the frame or dissolve it into registers. The analysis behind that proof is escape analysis, and its output is not just a stack slot: in the strongest form, the object disappears entirely and its fields become ordinary scalar variables.

The reason this is hard is that the proof must be conservative in one direction only. A wrong “does not escape” conclusion produces a dangling pointer and undefined behaviour; a wrong “escapes” conclusion produces a heap allocation that was not needed. Compilers therefore err heavily toward the second, and the interesting engineering is in how much of the first they can safely rule out.

The connection graph

The standard formulation builds a graph whose nodes are allocation sites and reference variables, with edges recording that a reference to one node may be stored in another. Choi and colleagues described the analysis in these terms for Java, and the shape has been stable since.

Three edge kinds carry the information. A field edge records that a reference is stored into a field of an object; a parameter edge records that a reference is passed to a call; and a global edge records that a reference is stored somewhere reachable from a static or from a native call. Each allocation site starts with a flag meaning “does not escape”, and the solver propagates: if a node that may escape is reachable from an allocation site along any edge, that site escapes.

The propagation is a fixed-point over the graph, and its cost is proportional to the number of edges, which is why the analysis is practical at whole-method scope and expensive at whole-program scope. Java’s HotSpot performs it per method during just-in-time compilation, which bounds the graph to one method’s allocation sites and the callees it inlines. Go performs it at compile time over the whole package, and reports the result through the compiler’s diagnostics so that the decision is visible to the programmer.

The edges that matter most are the ones involving calls. A reference passed to a function that is not inlined must be assumed to escape unless the callee’s signature says otherwise; that is what the //go:noescape directive declares explicitly for a function with no body, and what the compiler’s own analysis has to infer for every other callee. Inlining is therefore not a separate optimization from escape analysis but a precondition for it: a call that is inlined turns a parameter edge into a direct assignment, which the analysis can follow.

Identity is the obstacle

An object whose address is never compared and never used as a key can be dissolved into its fields. Java’s reference equality is the reason the analysis cannot simply do this everywhere: a == b on two references observes whether they are the same object, and an object that was scalar-replaced has no identity to compare. The analysis must therefore prove that no reference to the allocation is ever compared or stored where an identity comparison could occur, which in practice means proving that all uses are field reads and writes.

This is why the transformation is described as scalar replacement rather than as stack allocation. Stack allocation preserves identity, because the object has a real address; scalar replacement does not, and it is only legal when identity is unobservable. HotSpot’s -XX:+EliminateAllocations performs the stronger transformation, and -XX:+EliminateLocks extends the same reasoning to synchronization: a lock on an object that is thread-local cannot be observed by another thread, so the monitor operations can be removed.

The same distinction appears in Rust, where an object’s address is observable through &T and through ptr::eq. The compiler cannot scalar-replace a Box<T> whose address is taken and compared, and the language gives no annotation to promise otherwise. What the LLVM back end can do is remove the call: a Box::new whose result never leaves the function, and whose allocation and deallocation are not otherwise observable, can be turned into a plain alloca that mem2reg and sroa may then promote to SSA values. There is no guarantee that this happens, and the reason is that a Rust program may install a custom global allocator, which makes every call to alloc and dealloc a potentially observable side effect; removing one is legal only when the optimizer can see both the allocation and its matching deallocation and nothing else reaches the allocator.

From alloca to registers

At the LLVM level the transformation has two stages, and understanding them explains why the result depends so much on what the front end emits.

A front-end that does not run its own escape analysis typically emits every local as an alloca, a stack allocation instruction, and every access as a load and store through the resulting pointer. That is a faithful translation of a mutable local, and it is what makes debug builds slow: every variable access is a memory operation.

mem2reg promotes an alloca to SSA values when every use is a direct load or store and the address never escapes. The result is that the variable lives in a register, and the stores disappear. sroa does the same for aggregates: a struct alloca whose fields are accessed individually is split into separate allocas per field, each of which can then be promoted. Run to a fixed point, the two passes remove most of the stack traffic a naive translation would leave, which is why a front end can afford to emit every local as memory.

Neither pass can promote an alloca whose address is passed to a function that is not inlined, stored to memory outside the function, or returned. That is the same escape condition, expressed in the IR rather than in a source-level graph, and it is why the ordering of optimization passes matters: an inlining pass that runs after mem2reg cannot recover the promotion opportunity it created.

What the proof is worth

The payoff differs by runtime, and the difference is instructive.

In a tracing runtime with a bump-pointer nursery, allocation is already a pointer add and a bounds check, so escape analysis saves little at the allocation site. What it saves is collector work: every surviving object must be traced, and every allocation contributes to the rate at which a collection is triggered. Removing an allocation from a hot loop therefore reduces the frequency of minor collections, which is where the visible win is. That is why the analysis is more valuable in a runtime with a copying nursery than in one with a mark-sweep heap.

In Go, the win is more direct because the stack is where the runtime wants values: a stack-allocated object costs a frame adjustment and nothing else, while a heap object costs an allocation and eventual garbage collection. Go’s analysis is deliberately conservative and does not perform general scalar replacement, so the common outcome is stack allocation rather than dissolution. The compiler reports the decision, which makes the analysis part of the language’s practical semantics: converting a value to an interface, storing a pointer in a struct that outlives the call, or passing it to a function that retains it all force the heap.

The failure modes are worth naming because they are silent. A closure that captures a local by reference makes the local escape, because the closure may outlive the frame. A pointer stored into a slice that is returned escapes. A call to a function whose body is unavailable, such as an indirect call or a native function, escapes everything it receives. And a reference stored in a global, or in a field of an object that itself escapes, escapes transitively, which is why a single long-lived cache can force an entire chain of allocations to the heap.

The boundary of the technique

Escape analysis does not compose with concurrency. The analysis leans on the same assumption the memory model does: an object reachable only from one thread’s stack is not observed by another thread. A data race that publishes the pointer without synchronization is already undefined behaviour under the memory model, so no program the compiler must preserve depends on the pointer staying local.

It also does not compose with finalizers or with weak references. An object with a finalizer must exist as an object, because the runtime has to invoke the finalizer on it; a weak reference must have an address to be registered. Both force escape, and both are why the recommendation to avoid finalizers has a performance dimension as well as a correctness one.

The technique’s natural limit is that it is a whole-function proof. An allocation that escapes its creating function but is provably dead shortly after the caller returns is still heap-allocated, because the analysis does not model the caller’s frame. Region-based systems, described in arena allocation, extend the idea to a dynamic extent that crosses calls, at the cost of a static discipline. And the pointer that escapes into an object whose lifetime is not tracked by any of these mechanisms is exactly the case that unsafe abstraction boundaries must handle by hand.

The honest summary is that escape analysis converts a class of heap allocations into stack and register traffic, at the cost of a conservative proof whose precision depends on inlining, on the absence of identity observations, and on the front end emitting code the middle end can promote. When it fails, nothing breaks; the program is merely slower, and the diagnostics that report the decision (Go’s escape reports, the JVM’s compilation logs) are the only way to know which of those two outcomes occurred. The complement of this technique is the one described in stack frames and automatic lifetime: the compiler is trying to recover, for heap data, the cheapness that a frame gets for free.

References

  1. Escape Analysis for Java — Choi, Gupta, Serrano, Sreedhar and Midkiff, OOPSLA 1999
  2. LLVM Language Reference Manual — LLVM Project
  3. Go compiler source: cmd/compile/internal/escape — The Go Project