JVM Scope

Escape Analysis and the Allocation That Never Happened

C2 can delete an object entirely by proving it never escapes. Here is when that works, when it silently stops, and how to measure the difference.

4 min read
Escape Analysis and the Allocation That Never Happened — Runtime article cover

The cheapest object is the one that is never created. HotSpot’s C2 compiler removes a surprising number of them, and it does so quietly enough that most developers only discover the optimisation by breaking it.

Three ways an object can escape

Escape analysis asks a single question about each allocation in a compiled method: where can a reference to this object end up? C2 classifies the answer three ways.

GlobalEscape — the reference is stored in a static field, an instance field of an escaping object, an array, or returned to a caller that the compiler cannot see. Nothing can be assumed about its lifetime.

ArgEscape — the reference is passed to a method but does not escape further. It outlives the allocating scope only through arguments that the compiler has visibility into.

NoEscape — the reference stays inside the compiled unit. Nothing outside can observe the object.

Only the last category unlocks the interesting work. And note what “the compiled unit” means: after inlining. Escape analysis runs on the inlined graph, so a callee whose body was pulled into the caller is part of the same analysis scope. This dependency is the single most important practical fact about the optimisation.

Scalar replacement, not stack allocation

The folklore version of this optimisation is “the JVM allocates it on the stack”. HotSpot does something different and better.

When C2 proves an allocation does not escape, it deletes the allocation and replaces the object with its constituent fields, held wherever the register allocator decides to put them. There is no object header, no heap write, no memory to zero, and nothing for the garbage collector to trace or move. A two-field object becomes two values.

Consider the common shape:

public double distance(Point a, Point b) {
    Vector delta = new Vector(b.x() - a.x(), b.y() - a.y());
    return Math.sqrt(delta.dx() * delta.dx() + delta.dy() * delta.dy());
}

If Vector’s constructor and accessors are inlined — they are trivially small, so they will be — the Vector never exists. The compiled code holds two doubles and calls sqrt. Run this in a loop a hundred million times and the allocation profiler reports nothing at all.

The same machinery removes the Iterator created by an enhanced for-loop, the Optional wrapper in a chain that immediately unwraps it, the boxed Integer in an arithmetic expression, and the small record created purely to return two values from a method.

There is a related optimisation worth knowing separately: lock elision. If an object never escapes, no other thread can synchronise on it, so C2 removes the locking entirely. This is why using a StringBuffer in a local scope costs the same as a StringBuilder.

Where it stops working

The preconditions are narrow, and every one of them is a real production failure mode.

The call was not inlined. This is the dominant cause. If Vector’s accessor is reached through an interface with three implementations in the type profile, the call site is megamorphic, the method is not inlined, and the analysis conservatively assumes the reference escapes. The allocation comes back.

The object is stored somewhere durable. Assigning it to a field, putting it in a collection, adding it to an array — any of these makes it GlobalEscape. This includes assignment to a field of this.

The array is too large or its size is not constant. Scalar replacement of arrays is bounded; the default limit is 64 elements, and a size the compiler cannot fold to a constant disqualifies it outright.

The allocation is inside an exception handler path that C2 could not prove unreachable, or the object is passed to a method the compiler declines to analyse.

The code never reached C2. Escape analysis is a C2 optimisation. A method that only ever runs in the interpreter or in C1 allocates every object it writes, which is one reason startup-heavy code has a completely different allocation profile from steady-state code.

Measuring it

Ideally you would ask the compiler directly, but -XX:+PrintEscapeAnalysis and -XX:+PrintEliminateAllocations are development flags present only in debug builds of the JVM. On a production JDK, the measurement is a controlled comparison instead.

Run the workload twice, once normally and once with the optimisation disabled:

java -XX:-DoEscapeAnalysis -jar app.jar

Then compare allocation rate, not wall-clock time. JFR gives it directly:

java -XX:StartFlightRecording=settings=profile,filename=ea-on.jfr -jar app.jar
jfr print --events jdk.ObjectAllocationSample ea-on.jfr | head -50

Under JMH, the same comparison is one flag:

mvn clean package
java -jar target/benchmarks.jar VectorBench -prof gc

The gc.alloc.rate.norm figure is bytes allocated per operation, and it is far more stable than a timing number. If it reads as 0 with escape analysis on and as 32 with it off, the optimisation is doing exactly what you hoped. If both readings are 32, something in the preconditions above is failing and the tuning question is which one.

Deoptimisation puts the object back

There is a detail that makes the whole thing safe and occasionally expensive. If a scalar-replaced object needs to become real again — because the method deoptimises, or because a debugger asks for the local variable — the JVM reallocates it and repopulates its fields from the values it decomposed them into. This is called rematerialisation, and it is why the optimisation is transparent rather than observable.

It also means a method that repeatedly deoptimises pays for the objects it thought it had removed, plus the deoptimisation itself. Escape analysis, inlining and deoptimisation are one system, not three independent ones, and reading the compilation log will tell you more about your allocation rate than reading the allocation profile alone.

Frequently asked

Does the JVM allocate objects on the stack?
Not as such. HotSpot performs scalar replacement, which decomposes a non-escaping object into individual field values held in registers or stack slots. The object header is never created and there is nothing on the heap to collect.
Why did an optimisation that worked in a benchmark disappear in production?
Almost always because a call site that was monomorphic under test saw a second implementation class in production, the call stopped being inlined, and the analysis that depended on that inlining could no longer prove the object stays local.
Share

Related articles

Arrow keys to move, Enter to open.