Software Development

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.

PatternEscape StateScalar Replaced?
Local temp, used and discarded in one methodNoEscapeYes
Assigned to an instance or static fieldGlobalEscapeNo
Passed into a caller-supplied collection or methodGlobalEscapeNo
Returned from the methodGlobalEscapeNo
Two allocations merged via if/else before useNoEscape in principleUsually 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:+PrintEliminateAllocations rather 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.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button