DEV Community

Cover image for Escape Analysis: How Can a Compiler Eliminate a Heap Allocation Without Changing Your Code?
Aditya Sharma
Aditya Sharma

Posted on

Escape Analysis: How Can a Compiler Eliminate a Heap Allocation Without Changing Your Code?

Consider this Java method:

double distanceBetween(double x1, double y1, double x2, double y2) {
    Point a = new Point(x1, y1);
    Point b = new Point(x2, y2);
    return Math.sqrt(
        Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2)
    );
}
Enter fullscreen mode Exit fullscreen mode

Two Point objects are created, used in a calculation, and then discarded. The source code calls new, which in Java means heap allocation, object initialization, and eventually garbage collection. But if you look at this from a different angle: both objects are born at the start of the method, used for arithmetic, and are completely useless after the return statement. Nothing outside the method ever sees them. No other thread can observe them. They exist solely to hold two doubles each.

Does the runtime actually need to allocate heap memory for them? That question is what escape analysis is designed to answer.


What It Means for an Object to Escape

Before a compiler can eliminate an allocation, it needs to prove that the allocation can be eliminated. The key concept is whether an object escapes its local scope.

An object escapes when a reference to it can be observed from outside the context in which it was created. There are several ways this happens:

Return. A method that returns a reference to a locally created object immediately causes it to escape. The caller now holds a live reference, so the object must exist somewhere accessible after the method returns. Stack frames are discarded on return, which means the object cannot safely live in one.

Shared state. Storing a reference in a field of an object that outlives the current scope, or in a static field, allows code anywhere in the program to observe the object. The compiler must assume the object could be read at any time.

Opaque callees. When a method passes an object to another function and the compiler cannot fully analyze what that function does with the reference, it must conservatively assume the object might escape. If the callee stores the reference in a field or returns it to its own callers, the assumption would be violated.

Non-escaping objects are those where the compiler can prove that every reference to the object stays within a bounded scope. It is created, used, and the last reference to it disappears, all within a region the compiler can reason about.

The Point objects in the opening example are non-escaping. The references a and b are local variables. They are never returned, never stored in any field, never passed to an opaque method. The compiler can trace every use of them.


How the Compiler Reasons About References

Escape analysis is not a single yes-or-no check. It is a data flow analysis that the compiler runs over the program's intermediate representation, tracking how references to each object propagate through the code.

The classic formulation, and the one underlying HotSpot's C2 compiler, builds a connection graph: a representation of the relationships between object references, the fields through which they flow, and the boundaries across which they might cross. Each object in the graph carries an escape state. HotSpot's C2 uses three states: NoEscape, for objects whose references are entirely contained within the method; ArgEscape, for objects passed as arguments but not observed to escape further; and GlobalEscape, for objects accessible from outside the method or thread.

The analysis is conservative. If the compiler cannot prove that a reference stays local, it assumes it might escape. Conservative analysis is safe: it never eliminates a necessary allocation. But it also means some allocations that a human could see are unnecessary will be retained because the compiler's model of the code is not complete. Inlining is one of the most important enablers of escape analysis. When a callee is inlined into the caller, the compiler gains full visibility into what the callee does with every argument, which allows escape states to be refined. HotSpot's C2 requires inlining to occur before scalar replacement can work. If a method is not inlined because it is too large or was not compiled at the right optimization tier, the analysis may classify its arguments as escaping, even if they do not.


The Optimization: Scalar Replacement

The natural first guess is that escape analysis moves objects from the heap to the stack. For some compilers and runtimes, that is exactly what happens. The Go compiler does this: if it proves that a value will not outlive the current function, it allocates the value in the goroutine's stack frame rather than on the heap. You can observe this directly with go build -gcflags="-m", which annotates each allocation decision.

HotSpot's C2 JIT takes a different path. According to the OpenJDK wiki: "C2 does NOT replace a heap allocation with a stack allocation for non globally escaping objects." Instead, C2 performs scalar replacement.

Scalar replacement decomposes an object into its constituent fields and eliminates the object itself. Rather than allocating a block of memory and storing field values in it, the compiler treats each field as an independent local variable. The object never materializes as a contiguous region of memory. No allocation happens. No garbage collector ever needs to reclaim it.

The transformation for the Point example looks conceptually like this. Before scalar replacement:

// What you wrote
Point a = new Point(x1, y1);
double ax = a.x;
double ay = a.y;
Enter fullscreen mode Exit fullscreen mode

After scalar replacement, the compiler replaces all field accesses with synthetic local values:

// What the compiler works with internally
double a_x = x1;  // scalar substitute for a.x
double a_y = y1;  // scalar substitute for a.y
Enter fullscreen mode Exit fullscreen mode

The Point object never exists. The field reads (a.x, a.y) become references to local values that can be kept in registers. Aleksey Shipilëv's JVM Anatomy Quarks describes this precisely: HotSpot "does not do stack allocations per se, but it does approximate that with Scalar Replacement."

From there, register allocation can place those scalar values in CPU registers for the duration of the computation. The two doubles that would have been heap-allocated objects end up as register values, with no allocation, no initialization overhead, and no GC involvement.


A Concrete Walk-Through

Take the full distance method from the opening:

double distanceBetween(double x1, double y1, double x2, double y2) {
    Point a = new Point(x1, y1);
    Point b = new Point(x2, y2);
    return Math.sqrt(
        Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2)
    );
}
Enter fullscreen mode Exit fullscreen mode

For scalar replacement to apply, C2 needs to:

  1. Inline the Point constructor so it can see that no reference to the new object leaks out of the construction.
  2. Determine that a and b have escape state NoEscape: no return of the references, no field stores into external objects, no passing to opaque methods.
  3. Replace new Point(x1, y1) with two scalar variables a_x = x1, a_y = y1.
  4. Rewrite every a.x and a.y access to use those scalars.
  5. Eliminate the allocation instruction entirely. After this transformation, the method compiles to arithmetic on four double values, none of which requires heap memory. The behavior observed by any caller is identical: they receive the same double result. The only change is internal to the representation of the computation.

Lock elimination is a related optimization that escape analysis enables. If an object is non-escaping, no other thread can hold a reference to it, so any synchronized blocks on that object are unnecessary. The compiler can remove them.


Where the Analysis Fails

Escape analysis has hard limits, and understanding them is as important as understanding what it can do.

The most common barrier is an opaque method call. If distanceBetween passed a to any method that C2 has not inlined, the compiler would have to assume the reference might escape. The Point would remain heap-allocated. This is why HotSpot's OpenJDK scalar replacement documentation explicitly lists non-inlined methods as a case where scalar replacement is prevented.

Interface dispatch is a related problem. When a method is called through an interface, the compiler cannot always determine which concrete implementation will run, which makes it impossible to inline the callee. Without inlining, the escape state of the argument is unknown.

Some operations are identity-sensitive. Java's == operator on objects tests reference identity. If the compiler replaces an object with scalars, two allocations that previously produced distinct references might appear identical or might behave incorrectly if the program depends on them being different objects. The compiler must verify that no code in scope depends on the object's identity before it can safely decompose it.

Synchronization presents a harder constraint. If code synchronizes on an object that the compiler is considering for scalar replacement, the semantics of Java's memory model require that the lock be observable. Lock elimination is sound only when the object is genuinely non-escaping, so the compiler must be certain before removing it.

Conservative analysis also means that HotSpot will retain an allocation if any uncertainty exists. The analysis can handle many cases that appear complex in source code, but it cannot analyze beyond what it can observe. Dynamic loading of classes, reflection, and native code are all effectively opaque.


The Performance Picture

Removing allocations reduces pressure on the garbage collector by producing fewer objects that need to be tracked and collected. In throughput-sensitive code that creates many short-lived objects in tight loops, this can meaningfully reduce GC pause frequency and time spent in allocation paths. Keeping data in registers rather than memory also reduces memory traffic.

That said, allocation in modern JVMs is fast. HotSpot's thread-local allocation buffers (TLABs) allow most allocations to complete with a simple pointer bump. The cost of a single allocation is small. The benefit of elimination comes from the aggregate effect over many allocations, particularly in hot loops, and from the downstream reduction in GC work.

Whether scalar replacement improves a specific workload depends on the compiler's ability to apply it, the allocation rate of the relevant code, and the GC configuration. Profiling with allocation-aware tools is the right way to measure this, not inference from source code alone.


The Developer's Relationship With This Optimization

The existence of escape analysis and scalar replacement changes what source-level object creation actually means. Writing new Point(x, y) in Java does not guarantee that a heap allocation will occur at runtime. Whether one does depends on the JIT compiler's escape analysis, whether the relevant methods were inlined, and the escape state the analysis assigned to the object.

This is intentional. The language specification defines what the program must compute and what observable side effects it must produce. It says nothing about whether intermediate objects need physical representation. The JIT compiler is free to choose any implementation that preserves observable behavior.

Several practical points follow from this:

Write code that is clear and correct. Avoiding allocation for its own sake by using awkward data structures or manual field decomposition will not necessarily produce faster code and will make the compiler's job harder if it disrupts inlining.

Measure before concluding. Allocation sampling profilers can show which allocations actually reach the heap at runtime. An allocation that appears in source code may not appear in profiles if scalar replacement has already eliminated it.

Do not assume Java and Go behave identically. Go's escape analysis operates at compile time and makes stack-allocation decisions that are baked into the generated machine code. HotSpot's analysis operates in the JIT compiler and uses scalar replacement rather than stack allocation. The mechanisms, the triggering conditions, and the diagnostics are different.

Check compiler output when it matters. Go's -gcflags="-m" flag reports escape decisions. HotSpot's -XX:+PrintEliminateAllocations flag reports scalar replacement. These are the ground truth; source code is not.


The Deeper Insight

Escape analysis reflects a broader principle in compiler design: a language's specification describes the required observable behavior of a program, not its required internal representation. The compiler is free to transform how data is stored, how operations are sequenced, and how objects are physically represented, as long as anything the program is permitted to observe remains consistent with the specification.

A heap allocation is a mechanism for giving data a lifetime that outlives a function call. When the compiler can prove that a particular datum does not need that extended lifetime, the allocation is unnecessary. The datum can be represented as local values, stored in registers, and discarded the moment the function returns. The program computes the same result. The method's caller observes the same returned value. Nothing that any correct program could observe has changed.

What the compiler has changed is what never needed to exist in the first place.


Sources

Top comments (0)