Loopsimplementation

Escape Analysis

Does this object outlive the scope that created it? If the compiler can prove it does not, the object can live on the stack, or be broken into registers and not exist at all — and the aliasing questions about it disappear with it.

The question

I allocated an object inside a function and never returned it. Does it still cost a heap allocation?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

IR over allocation sites and the values derived from them, plus a lattice per allocation: *does not escape*, *escapes to the caller* (returned or stored into something reachable by the caller), or *escapes globally* (stored somewhere any code could reach). That classification is what downstream passes consume; the analysis exists to compute it and nothing else.

What this phase may assume or do

An allocation may be moved to the stack only if no reference derived from it is live after the frame is destroyed — it is not returned, not stored into anything reachable outside, not captured by a closure that outlives the frame, and not passed to a function that could retain it. It may be *scalar-replaced* — dissolved into individual values with no object at all — only under the stronger condition that no reference to it is taken at all: every access is a direct field access the compiler can see. Both conditions require the analysis to be sound over every path, and treating an unanalysable call as non-escaping is how a stack-allocated object outlives its frame.

Key points

  • The analysis classifies each allocation as non-escaping, escaping to the caller, or escaping globally, and each level unlocks a different optimization.
  • Non-escaping permits stack allocation; non-escaping with no reference taken permits scalar replacement, which removes the object entirely.
  • Thread-local objects permit lock elision, because an object no other thread can see cannot be contended.
  • Escape must be ruled out on every path; a single call the compiler cannot see into is usually enough to force the conservative answer.
  • The analysis is far more effective after inlining, which is why JITs — inlining on measured hotness — capture more of it than static compilers on the same code shape.
  • Removing an allocation in a garbage-collected runtime removes tracing and collection pressure, not just the allocation cost.

Three answers, three different payoffs

The analysis produces a classification, and each level unlocks something different.

Does not escape. The object is dead when the function returns. It can be allocated in the frame instead of the heap — no allocator call, no eventual collection, and the memory is reclaimed by the frame pop. In a garbage-collected runtime this is the difference between an object the collector must trace and one it never sees.

Does not escape and is never referenced indirectly. Now the object need not exist at all. *Scalar replacement of aggregates* dissolves it into individual SSA values, one per field, which then live in registers and are subject to every scalar optimization in the compiler — constant propagation through fields, dead field elimination, the lot. This is the big win, and it is why a small struct or a Point object frequently costs literally nothing.

Escapes only to the caller. Not stack-allocatable in this frame, but the information is still useful: the compiler knows the object is not visible to other threads, which removes the need for synchronization on it. Lock elision in the JVM is exactly this — a lock on an object that provably never escapes the thread is removed, which is why StringBuffer and StringBuilder perform similarly in code where the buffer is local.

From an allocation to no object at all
Before
fn distance(x1, y1, x2, y2) {
  let p = new Point(x1, y1);   // allocation
  let q = new Point(x2, y2);   // allocation
  let dx = p.x - q.x;
  let dy = p.y - q.y;
  return sqrt(dx*dx + dy*dy);
}
After
fn distance(x1, y1, x2, y2) {
  let dx = x1 - x2;            // no objects exist
  let dy = y1 - y2;
  return sqrt(dx*dx + dy*dy);
}
Legal only when

Neither p nor q is returned, stored into anything reachable outside the function, captured, or passed to a function that could retain a reference. No reference to either is taken at all, so each can be replaced by its fields as individual values, which then propagate away entirely. The observable behavior is identical — the same value is returned — and the allocations were never observable.

Illegal when

A reference escapes on any path. If the function ends with registry.add(p), or returns p, or passes p to a function whose body the compiler cannot see, the object must exist and must outlive the frame. A single unanalysable call is usually enough: the callee might store the reference anywhere, so a sound analysis must treat the argument as escaping — which is why [[inlining]] and [[interprocedural-analysis]] are what make escape analysis effective rather than theoretical.

What the analysis needs, and what defeats it

implementationHotSpot performs scalar replacement rather than true stack allocation of whole objects — the object is dissolved into fields, not moved to the frame. Go performs actual stack allocation and reports it with -gcflags=-m. The two are different mechanisms with a similar effect and different limits, so "escape analysis" in a JVM discussion and in a Go discussion do not mean quite the same thing.

Escape analysis is a reachability question over the values derived from an allocation. Start at the allocation site, follow every value that could be a reference to it, and ask whether any of them reaches a point where the object could be observed later: a return, a store into a global or into a field of an escaping object, an argument to an opaque call, a capture by a closure that outlives the frame, a store into a thread-shared structure.

What defeats it, in practice: calls it cannot see into, which is most calls without inlining or whole-program analysis; storing into containers, since a container the compiler cannot analyse is an escape; polymorphism, where the callee is not known so its retention behavior is not either; and any path it cannot analyse, since escape must be ruled out on *every* path, not the common one.

This is why the analysis is so much more effective in a JIT than in a static compiler for the same language shape. The JIT has already inlined the hot call chain using measured hotness, so the "opaque call" problem largely disappears inside a hot region, and it can speculate on the receiver type of a polymorphic call behind a guard. The result is that identical source gets stack-allocated in one setting and heap-allocated in the other.

What each system does with a provably local objectimplementation
SystemWhat it doesWhat limits it
HotSpot (JVM)implementationScalar replacement after inlining; lock elision on non-escaping objectsRequires the allocation and its uses to be in one compiled, inlined region; large methods and megamorphic calls block it
GoimplementationStack allocation decided at compile time; go build -gcflags=-m prints every decisionInterfaces and closures cause escapes readily; the compiler is conservative and says so
C++specObjects are stack-allocated by default; the analysis matters only for new, and C++14 permits eliding some allocationsThe language already gave the programmer the control, so the compiler is rarely the deciding factor
RustOwnership already expresses lifetime, so the choice is in the source; Box versus a value is the programmer's callNot an optimizer question in the same way — the type system moved it earlier
V8 (JavaScript)implementationEscape analysis over inlined regions, removing object allocations in hot pathsA deoptimization forces the object to be materialised again, which is why the state map must describe it

Why this is one of the most valuable analyses there is

The direct saving is an allocation and a later reclamation. In a garbage-collected runtime that is more than it sounds: an object that is never allocated is never traced, never copied by a moving collector, and never contributes to the pressure that triggers a collection. Removing allocations in a hot loop can change the collection frequency of a whole application, which is a far larger effect than the allocation cost itself.

The indirect saving is that scalar replacement moves the object's fields into registers, which puts them in reach of every scalar optimization. A field that was a memory location subject to aliasing questions becomes an SSA value with a single definition. In effect, escape analysis converts a memory problem into a register problem, and the register problem is the one the compiler is good at.

It also removes synchronization. An object that never escapes its thread cannot be contended, so locks on it are removable, and the memory ordering constraints associated with it disappear. Combined with inlining, this is a substantial part of why idiomatic allocation-heavy code in a managed runtime performs as well as it does.

The practical corollary for an engineer is that keeping objects local is a performance technique with a mechanism behind it. Storing a reference "just in case" into a field or a container converts a free object into a traced one, and the cost is invisible in the source.

How it works

The steps, in the order the compiler takes them.

  • Identify each allocation site and treat its result as the root of a set of derived values.
  • Propagate: any value copied from, stored from, or computed as a field reference of a rooted value joins that set.
  • For each value in the set, check whether it reaches an escaping context — a return, a store into a global or into an escaping object's field, an argument to a call whose retention behavior is unknown, or a capture by a longer-lived closure.
  • Merge the results across control-flow paths conservatively: an escape on any path is an escape.
  • Classify the allocation, and hand the classification to the consumers: stack allocation, scalar replacement, and lock elision each check for the level they need.
  • For scalar replacement, additionally require that no reference is taken at all, then replace each field access with an SSA value and delete the allocation.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • Allocation rates in a hot loop are far higher than the source suggests, and profiling shows garbage-collection pressure driving the run time. A reference is being stored somewhere the analysis treats as an escape.
  • A small refactor — extracting a helper, adding a log line that passes the object, storing it in a field for debugging — turns a free object into an allocated one. The source change looks harmless and the allocation profile changes sharply.
  • A JVM benchmark shows excellent numbers because the whole hot region inlined and everything was scalar-replaced, and production does not, because the real call sites are megamorphic and inlining stopped.
  • A stack-allocated object outlives its frame because the analysis was unsound about a path — the resulting bug is a use-after-free with no allocator involved, and it manifests as corrupted data far from the allocation.
  • Go code that was zero-allocation starts allocating after a type changes to an interface, and -gcflags=-m shows the escape while the source diff shows nothing about memory.

When it helps

  • Allocation-heavy code in managed runtimes: iterators, boxed values, small temporary objects, closures — the idioms that make the language pleasant and that would otherwise be expensive.
  • Hot loops that construct small aggregates per iteration, where scalar replacement turns per-iteration allocation into register traffic.
  • Synchronized code on thread-local objects, where lock elision removes the synchronization entirely.

When it hurts

  • It does not hurt directly, but relying on it does: it is an optimization, not a guarantee, and code that only performs acceptably when the analysis succeeds is fragile against inlining changes, compiler upgrades and call-site polymorphism.
  • Very large objects moved to the stack can overflow it — compilers apply a size limit for exactly this reason, and a limit that differs between compilers is a portability hazard.

What it costs

Every one of these is paid by something.

  • The analysis buys removed allocations and their downstream collection cost, and pays compile time proportional to the reachability propagation — modest alone, substantial when combined with the interprocedural analysis that makes it effective.
  • Stack-allocating buys speed and pays with stack footprint; a large object or one in a deeply recursive function turns a heap allocation that would have succeeded into a stack overflow, which is why compilers cap the size.
  • Depending on it for performance buys clean idiomatic code and costs predictability: the same source allocates or does not depending on inlining decisions you do not control, which is why allocation-sensitive systems verify with a profiler rather than assuming.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Put the decision in the source. C++ stack objects, Rust values versus Box, Go's value types: if lifetime is expressible in the language, no analysis is needed and no analysis can be wrong.
  • Object pooling reuses allocations explicitly rather than eliminating them. It works when the analysis cannot, and it costs manual lifetime management and a class of bug the collector existed to prevent.
  • Arena or region allocation gives a whole phase's allocations one lifetime and frees them together — the same idea at a coarser granularity, chosen by the programmer.
  • Region inference, as in MLKit and in Cyclone, does this as a type-system feature rather than an optimizer pass, making the lifetime part of the program's meaning instead of a hope.

See it for yourself

The flag, dump or tool that shows you this directly.

  • Go: go build -gcflags=-m prints an escape decision for every allocation, and -gcflags=-m -m explains why. This is the clearest inspection tool for this analysis in any mainstream language.
  • HotSpot: -XX:+PrintEliminateAllocations (with -XX:+UnlockDiagnosticVMOptions) reports scalar replacement; -XX:-DoEscapeAnalysis disables it, which makes a before/after benchmark trivially available.
  • Measure rather than infer: an allocation profiler — pprof for Go, async-profiler in allocation mode for the JVM — shows what actually allocated.
  • LLVM: allocations lowered to alloca versus a call to the allocator are visible directly in -S -emit-llvm output; for C++ the C++14 allocation-elision rules are what to look for.
  • The refactor experiment: add a line storing the object into a field, re-run the tool, and watch the classification change. Doing it once makes the fragility concrete.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "Escape analysis means allocation is free in managed languages." It means allocation is sometimes free, when the analysis succeeds, which depends on inlining and on call-site monomorphism you do not control.
  • "If I do not return the object, it will be stack-allocated." Only if nothing else retains it on any path, including inside calls the compiler cannot analyse.
  • "Scalar replacement and stack allocation are the same thing." Scalar replacement removes the object entirely and needs the stronger condition that no reference is taken; stack allocation keeps the object and only needs it not to outlive the frame.
  • "The analysis is a JIT feature." Go does it at compile time and reports it; C++14 permits allocation elision in the standard. It is a static analysis that a JIT happens to be in a better position to apply.

Misconceptions

The claim, and what is actually true.

Objects in Java are always on the heap.
Non-escaping objects in hot, inlined regions are routinely dissolved into scalars and never exist. The heap is where an object goes when the analysis cannot prove otherwise.
Escape analysis eliminates the cost of allocation.
It eliminates the allocation. The larger effect is that the collector never sees the object, so tracing and collection pressure go with it.
If it worked in a benchmark it will work in production.
The analysis depends on inlining, and inlining depends on hotness and on call sites being monomorphic. A benchmark with one implementation of an interface is exactly the case where production differs.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

If an object cannot possibly be used after the function that made it returns, the compiler does not need to put it in the long-lived heap. It can put it in the function's own frame, or — if nothing ever takes its address — take it apart and keep the pieces in registers, so the object never exists at all.

practical

In Go, run -gcflags=-m and read the escape decisions; they are often surprising and always actionable. On the JVM, profile allocations rather than reasoning about them, and remember that everything depends on inlining — a call site that goes megamorphic takes the escape analysis down with it. In general, do not store references you do not need: a reference stored "just in case" is an escape.

advanced

The interaction with deoptimization is the subtle part. If a JIT has scalar-replaced an object and a guard later fails, the interpreter it falls back to expects a real object with real fields. So the compiler must record, in the state map at every deoptimization point, how to *re-materialise* the object from the scalars currently in registers. That obligation propagates through every subsequent transformation: any pass that moves or removes one of those values must keep the state map accurate. It is the clearest example in the domain of an optimization whose cost is not compile time or code size but a permanent constraint on every pass that runs afterwards.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationHotSpot performs scalar replacement of non-escaping objects inside inlined regions and elides locks on them; Go performs true stack allocation with a size cap and reports each decision via -gcflags=-m; V8 does it over inlined regions and must be able to re-materialise the object if a guard fails. The three differ in mechanism and in limits, so results do not transfer between them.
typicalMainstream implementations treat a call whose body they have not inlined as potentially retaining its arguments, so escape analysis is effective mainly inside inlined regions. This is why the same code allocates in a cold path and does not in a hot one, and why microbenchmarks overstate the effect.
specC++14 explicitly permits an implementation to elide or merge allocations made with new, which is a rare case of a standard licensing the removal of an observable-looking operation. Java specifies no such thing — scalar replacement is an optimization the JVM performs under the as-if rule, and nothing in the language guarantees it will.

If you were asked this in an interview

  • What does a compiler have to prove before it can allocate an object on the stack instead of the heap?
  • What is the difference between stack allocation and scalar replacement, and which needs the stronger condition?
  • Why does escape analysis work better in a JIT than in an ahead-of-time compiler for the same language?

Connections

Concurrencydata-races
Domains that do not exist yet
  • Programming Languages & Runtime Internals — Garbage collection: tracing, generations, write barriers and the cost of a live object
    Escape analysis is valuable because of what the collector does not have to do with an object that never existed. The collector itself — how it traces, when it runs, what a write barrier costs — is the runtime's subject; deciding what it never sees is ours.
  • Programming Languages & Runtime Internals — Object representation and header layout
    Whether removing an allocation saves sixteen bytes or forty-eight depends on the object header and field layout the runtime uses, which is defined there and consumed here as a cost input.