Compiler Escape Analysis and Stack Allocation
Escape analysis is a compiler technique that follows an allocated object’s references to determine whether the object can outlive the function that created it. If the compiler proves that it cannot, it may put the object in the function’s stack frame instead of allocating it on the heap.
That decision matters because heap allocation usually involves an allocator, and heap objects must later be found and reclaimed by a garbage collector or another memory-management system. Stack storage is tied to a function call: it is reserved when the call begins and released when the call returns. Reusing that storage is often cheaper.
The problem: allocation does not reveal lifetime
Consider a function that creates a value, uses it, and then returns only a computed result:
func total() int {
p := Point{X: 10, Y: 20}
return p.X + p.Y
}
A simple implementation might allocate p on the heap because Point is a newly created object. But p is never exposed outside total. Its lifetime ends with the call, so heap allocation would add work without providing any benefit.
Now compare it with a function that returns a reference—a value that points to an object rather than containing the object itself:
func newPoint() *Point {
p := Point{X: 10, Y: 20}
return &p
}
The caller receives a pointer to p after newPoint has returned. A normal stack frame cannot be reused at that point, so p must be placed somewhere with a longer lifetime, normally the heap.
The compiler cannot decide this by looking only at the line that creates p. It must examine where references to p travel.
What escape analysis tracks
Escape analysis builds an approximation of reference flow: which storage locations may receive a reference to which object. It asks whether a reference can reach a place whose lifetime is longer than the creating function’s call.
Common destinations include:
- A return value, so the caller can use the object.
- A global variable or another long-lived object.
- A field of an object that is already on the heap.
- A closure that may run after the current function returns. A closure is a function together with the variables it captures.
- A new thread, task, or goroutine whose execution can continue after the current call.
- A call whose implementation the compiler cannot inspect or summarize safely.
The analysis follows indirect paths too. In this example, the object does not escape merely because it is passed to keep; it escapes if keep stores the reference somewhere that survives the call:
var saved *Point
func keep(p *Point) {
saved = p
}
func makeSaved() {
p := Point{X: 10, Y: 20}
keep(&p)
}
The reference flows from &p into the parameter p, then into the global saved. That path means the object must remain valid after makeSaved returns.
By contrast, passing a reference to a function does not automatically force a heap allocation. If the compiler can see that the called function only reads the object during the call, it can keep the object in the caller’s stack frame. The important question is not whether a pointer exists, but whether that pointer can outlive the relevant stack frame.
How the compiler makes the decision
A simplified analysis works like this:
- It identifies allocations and local variables whose addresses or references may be used.
- It records possible flows between them—for example, “the reference in this argument can come from this local object.”
- It marks destinations with longer lifetimes, such as returned values, globals, heap fields, and calls that may retain their arguments.
- It propagates those marks backward through the reference-flow graph.
- Any allocation that can reach a longer-lived destination is classified as escaping.
The analysis is generally conservative. If the compiler cannot prove that a reference stays within the call, it may treat the object as escaping. This avoids an unsafe result: putting an object on the stack when some later code still holds a pointer to it.
Compilers often use summaries for functions they can analyze separately. A summary can record that a function returns its argument, stores it, or uses it only temporarily. Inlining—the compiler’s replacement of a function call with the function’s body—can expose more of the flow and sometimes allow a better decision.
The exact analysis varies by language and compiler. Some implementations reason about whole objects; others can distinguish individual fields or replace a short-lived object with separate scalar values. That latter transformation is often called scalar replacement: the object disappears, leaving its fields in registers or ordinary stack slots.
What “moved to the heap” means
In languages such as Go, compiler diagnostics may say that a variable “escapes to the heap” or that a parameter “leaks.” These messages describe the compiler’s lifetime decision, not a runtime error. A leaking parameter is one whose reference may flow onward; it is not necessarily a bug or a memory leak in the everyday sense.
The opposite decision—keeping an allocation on the stack—does not guarantee a visible stack object. The compiler might remove the allocation entirely, keep values in registers, or embed the object in another stack value. Escape analysis is one input to several optimizations.
Nor is heap allocation always wrong. An object returned from a function needs storage that outlives that function, and the heap provides it. The optimization is to avoid heap work only when the reference graph proves that the object’s lifetime is shorter.
The central rule is therefore simple: an object escapes when a reference to it can reach a longer-lived part of the program. Tracking that reachability lets the compiler preserve correctness while using the stack for objects whose lifetimes end with their creating call.