Escape Analysis and Scalar Replacement: Why Some Objects Never Touch the Heap
Small, verifiable before and after code examples showing what helps, or defeats, the JIT’s ability to keep an object off the heap entirely.
HotSpot does not actually allocate objects on the stack. That surprises people the first time they hear it, since “escape analysis lets the JVM stack-allocate objects” is the version of the story that gets repeated most often. What HotSpot’s C2 compiler actually does is called scalar replacement: if it can prove an object never leaves the method that created it, it skips the allocation entirely and treats each of that object’s fields as an ordinary local variable. No object, no heap write, no garbage collector involvement at all. The effect looks the same from the outside, but the mechanism is different, and understanding the real mechanism is what makes the before and after examples below make sense.
What Escape Analysis Actually Decides
During JIT compilation, C2 classifies every allocation into one of a few escape states. NoEscape means the object stays entirely inside the method, never stored anywhere and never passed to code the compiler can’t see into, which makes it a candidate for scalar replacement. ArgEscape means it’s passed to another method as an argument but doesn’t spread further than that. GlobalEscape means it leaves the method some other way, stored in a field, added to a collection, returned to a caller, or handed to another thread, and once that happens, a real heap allocation is required. Oracle’s own documentation on the HotSpot performance enhancements describes these three states directly, and only NoEscape objects are eligible for the optimization this article is about.
A Before Example That Gets Scalar Replaced
This helper computes a distance using a small record to hold an intermediate value:
record Delta(int dx, int dy) {}
static double distance(int x1, int y1, int x2, int y2) {
Delta delta = new Delta(x2 - x1, y2 - y1);
return Math.sqrt(delta.dx() * delta.dx() + delta.dy() * delta.dy());
}
The Delta instance is created, read twice, and discarded, all inside distance. It’s never stored, never returned, never passed anywhere. Once this method is hot enough for C2 to compile, the allocation is a strong candidate for scalar replacement: dx and dy become two plain local variables in the compiled code, and no Delta object is ever placed on the heap.
Three Small Changes That Defeat It
Each of the following is a minimal edit to the same method, and each one is enough to force a real heap allocation.
1. Storing it in a field.
Delta lastDelta;
double distance(int x1, int y1, int x2, int y2) {
lastDelta = new Delta(x2 - x1, y2 - y1);
return Math.sqrt(lastDelta.dx() * lastDelta.dx() + lastDelta.dy() * lastDelta.dy());
}
Assigning to an instance field is a textbook GlobalEscape. The object might be read by another thread or another call before it’s overwritten, so the compiler has to allocate it for real.
2. Passing it somewhere the compiler can’t see into.
static double distance(int x1, int y1, int x2, int y2, List<Delta> history) {
Delta delta = new Delta(x2 - x1, y2 - y1);
history.add(delta);
return Math.sqrt(delta.dx() * delta.dx() + delta.dy() * delta.dy());
}
Adding delta to a caller-supplied list hands the reference to code the compiler has no visibility into. There’s no way to prove the list doesn’t retain it, so the allocation escapes.
3. Returning it instead of a derived value.
static Delta computeDelta(int x1, int y1, int x2, int y2) {
return new Delta(x2 - x1, y2 - y1);
}
Once the object itself is the return value, its lifetime is no longer bounded by the method, so it has to exist on the heap for the caller to use.
The Subtle One: Control Flow Merge
This is the pattern that catches people off guard, because to a human reader, nothing here looks like it escapes:
static int pick(boolean flag, int a, int b, int c, int d) {
Delta delta;
if (flag) {
delta = new Delta(a, b);
} else {
delta = new Delta(c, d);
}
return delta.dx() + delta.dy();
}
Both branches create a Delta that’s used once and discarded, exactly like the first example. But HotSpot’s escape analysis is flow insensitive, and once two different allocations merge into the same variable before their final use, C2 typically fails to scalar-replace either one. Aleksey Shipilev documents this exact case with a JMH benchmark in his JVM Anatomy Quark on scalar replacement, and it’s a good reminder that “logically non-escaping” and “provably non-escaping to this specific compiler pass” aren’t the same thing. GraalVM’s compiler implements partial escape analysis, which handles this exact merge case where HotSpot’s C2 does not, so the same code can behave differently depending on which JIT compiles it.
| Pattern | Escape State | Scalar Replaced? |
|---|---|---|
| Local temp, used and discarded in one method | NoEscape | Yes |
| Assigned to an instance or static field | GlobalEscape | No |
| Passed into a caller-supplied collection or method | GlobalEscape | No |
| Returned from the method | GlobalEscape | No |
| Two allocations merged via if/else before use | NoEscape in principle | Usually not, on HotSpot C2 |
Verifying It Yourself
Don’t take any of the above on faith, since the JIT’s behavior can shift between JDK builds and depends on how hot the method actually gets. HotSpot can print its own escape analysis and allocation elimination decisions with a pair of diagnostic flags:
java -XX:+UnlockDiagnosticVMOptions \
-XX:+PrintEscapeAnalysis \
-XX:+PrintEliminateAllocations \
-jar your-app.jar
These flags are development-time tools only, they produce a large volume of compiler log output and should never be left enabled in a production JVM. Run the method enough times to get past C2’s compilation threshold first, a single call rarely triggers the optimizing compiler at all.
Keeping objects eligible for scalar replacement
- Keep short-lived helper objects local to the method that creates them
- Avoid storing them in fields, collections, or anything the caller retains
- Don’t return the object itself if only a derived value is actually needed
- Watch for control flow merges before the final use, since this defeats HotSpot C2 even when nothing logically escapes
- Confirm behavior with
-XX:+PrintEliminateAllocationsrather than assuming it happened
What We Learned
Scalar replacement, not stack allocation, is the real mechanism behind objects that never touch the heap, and it only fires when escape analysis can prove an object stays entirely local to one method. Small, ordinary changes, storing a reference in a field, handing it to an un-inlined method, returning it directly, are all enough to flip that proof from possible to impossible. The control flow merge case is the one worth remembering longest, because it defeats HotSpot’s C2 compiler even in code that a human would call obviously non-escaping, and it’s a solid example of why “the JIT will probably handle it” is worth checking with the actual diagnostic flags rather than assuming.


