Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Object Layout, Escape Analysis and the JIT

How many bytes an object really costs, why compressed oops stop working above 32GB, how the JIT proves an object never escapes and removes the allocation, and what deoptimisation is.

Expert8 min readUpdated
On this page

This is the deepest level interviews reach, and it appears mostly in performance-focused or platform-team roles. The value is not the trivia: it is that object layout explains collection memory costs, escape analysis explains why the "avoid allocation" advice is often wrong, and deoptimisation explains benchmark results that make no sense.

Key Takeaways

  • Object header is 12 bytes with compressed oops (8 mark + 4 class pointer), padded to an 8-byte boundary. Minimum object size is 16 bytes.
  • Compressed oops work up to 32GB. Crossing it makes every reference 8 bytes and can reduce usable capacity.
  • Escape analysis can prove an object never leaves a method, allowing scalar replacement — the allocation disappears entirely.
  • Tiered compilation runs C1 for fast warm-up and C2 for peak performance, driven by invocation counters.
  • Deoptimisation is the JIT undoing a speculation that proved wrong. It is normal and it explains odd benchmark results.

Object layout

a 64-bit JVM with compressed oops
class Point { int x; int y; }
 
offset  size  type          description
     0     8  (header)      mark word: hash, GC age, lock state
     8     4  (header)      compressed class pointer
    12     4  int           x
    16     4  int           y
    20     4  (padding)     to the 8-byte alignment boundary
                            ------
                            24 bytes

The mark word is reused for several purposes over an object's life: identity hash code once computed, GC age and mark bits, and lock state. That last one is why calling hashCode() on an object can interfere with lock optimisations — the same 8 bytes cannot hold both.

Fields are reordered by the JVM to minimise padding: longs and doubles first, then ints and floats, then shorts and chars, then bytes and booleans, then references. Declaration order is not layout order.

measure it, do not count
// org.openjdk.jol:jol-core
System.out.println(ClassLayout.parseInstance(new Point(1, 2)).toPrintable());
System.out.println(GraphLayout.parseInstance(myMap).toFootprint());   // whole graph

JOL is the definitive answer, and running it on a domain object is often startling: an "empty" wrapper around two fields commonly costs 32–48 bytes, and a HashMap entry closer to 48 before the key and value.

ThingBytes
Object header12 (+ padding)
Reference4 compressed, 8 uncompressed
boolean, byte1
char, short2
int, float4
long, double8
Array header16 (12 + 4 for length)
Integer16
String (Latin-1, n chars)40 + n

Compressed oops

A 64-bit reference is 8 bytes, which doubles the memory cost of every pointer relative to a 32-bit JVM. Compressed ordinary object pointers avoid most of that: because objects are 8-byte aligned, the low three bits of every address are always zero, so a 32-bit value can encode a heap offset shifted left by three — addressing 2³² × 8 = 32GB.

check it
java -XX:+PrintFlagsFinal -version | grep UseCompressedOops
# bool UseCompressedOops = true {lp64_product}

Cross 32GB and the JVM silently disables it. Every reference becomes 8 bytes, class pointers grow, object headers grow, and cache lines hold fewer references. The net effect is that a 32GB heap can hold less live data than a 31GB one, and you need roughly 40–48GB before you are genuinely ahead.

The practical rule: keep heaps at or below 31GB, and if you need more memory, run more JVMs — or move to ZGC, where the pointer scheme is different and the cliff does not apply in the same way.

Escape analysis

The JIT analyses whether an object's reference can be observed outside the method that created it.

three escape states
// NoEscape — the object never leaves. Eligible for scalar replacement.
public double distance(double x1, double y1, double x2, double y2) {
    Point a = new Point(x1, y1);
    Point b = new Point(x2, y2);
    return Math.sqrt(sq(a.x - b.x) + sq(a.y - b.y));
}
 
// ArgEscape — passed to a method, but that method does not store it.
// The allocation stays, but locks on it can be elided.
public void log(Order o) { logger.debug(o.toString()); }
 
// GlobalEscape — stored in a field, returned, or thrown. No optimisation.
public Point origin() { this.cached = new Point(0, 0); return this.cached; }

When C2 proves NoEscape, it can apply scalar replacement: rather than allocating the object, it promotes its fields into registers or stack slots. The allocation disappears completely — no heap traffic, no GC pressure, no header.

what the JIT effectively produces
public double distance(double x1, double y1, double x2, double y2) {
    // No Point objects exist at all. Just four doubles in registers.
    return Math.sqrt(sq(x1 - x2) + sq(y1 - y2));
}

It also enables lock elision — removing synchronisation on an object no other thread can see, which is why StringBuffer used locally performs like StringBuilder.

observing it
-XX:+UnlockDiagnosticVMOptions -XX:+PrintEscapeAnalysis -XX:+PrintEliminateAllocations
-XX:-DoEscapeAnalysis          # turn it off to measure what it was buying you

The consequence for everyday code is important: do not contort code to avoid short-lived objects. Allocation in eden is a pointer bump, dying young is nearly free, and the JIT often removes the allocation entirely. Optimise allocation only where a profiler says to.

Tiered compilation

C1 compiles quickly and gathers profile data; C2 uses that profile to speculate aggressively. A wrong speculation sends the method back.

Code starts interpreted. After roughly two hundred invocations, C1 compiles it quickly with instrumentation that counts branch outcomes and records which classes each call site sees. After roughly ten thousand, C2 recompiles it using that profile, applying inlining, escape analysis, loop unrolling, vectorisation and branch layout based on what actually happened.

This is why warm-up matters, and why benchmarking without it measures the interpreter.

Deoptimisation

C2 speculates on the profile: this call site has only ever seen one implementation, so inline it directly and guard with a class check; this branch has never been taken, so do not even emit code for it; this method never throws, so skip the handler setup.

When a guard fails, the JVM deoptimises — discards the compiled frame, reconstructs the interpreter state, and continues from there. The method is later recompiled with the new information.

the classic surprise
// The first 10 million calls pass `ArrayList`. C2 inlines ArrayList.size()
// directly with a class-check guard, and the method is very fast.
public int total(List<?> list) { return list.size(); }
 
// Call 10,000,001 passes a LinkedList. The guard fails, the frame is
// deoptimised, and the call site is recompiled as bimorphic — slower forever.

That transition from monomorphic (one receiver type, inlined) through bimorphic (two, still inlinable with a check) to megamorphic (three or more, a virtual call through a table) is one of the largest real performance cliffs in the JVM, and it is invisible in the source.

watching compilation
-XX:+PrintCompilation                                    # every compile and deopt event
-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining        # what was inlined and why not
# Or use JITWatch to read the compilation log graphically

A made not entrant line in PrintCompilation output is a deoptimisation. A burst of them during a benchmark is usually why the numbers moved.

Why this matters in practice

Three concrete consequences worth being able to state:

Collection memory costs follow from layout. A HashMap entry is ~48 bytes because of the header, the cached hash, and three references — not an arbitrary number. That grounding is what makes the estimates in Collection complexity and memory something you can derive rather than recall.

Escape analysis undermines premature optimisation. Object pooling for small short-lived objects is usually slower than allocating, because pooled objects survive into the old generation while allocated ones die in eden and may not be allocated at all.

Deoptimisation explains inconsistent measurements. A method that got slower after a deploy may have gone megamorphic because a second implementation of an interface was introduced.

What gets asked

Rarely, and only in performance-oriented interviews. When it comes up, the two questions are "how much memory does an object use?" and "what is escape analysis?". A good answer to the second connects it to advice: "it means the JIT can remove allocations for objects that never leave a method, which is why avoiding short-lived objects is usually a waste of effort — I optimise allocation only where a profiler shows it."

Frequently Asked Questions

How many bytes does an empty Java object take?
Sixteen on a 64-bit JVM with compressed oops: a twelve-byte header — eight bytes of mark word plus a four-byte compressed class pointer — padded up to the eight-byte alignment boundary. An object with a single int field is also sixteen, because the four bytes of padding absorb it. Adding a second int still costs sixteen. This is why JOL is worth running rather than counting fields.
Why should a heap stay under 32GB?
Because compressed oops encode references as 32-bit offsets scaled by the eight-byte alignment, addressing up to 32GB. Above that the JVM switches to full 64-bit references, so every reference doubles in size and object headers grow. The result is that a 32GB heap can hold less live data than a 31GB one, plus worse cache utilisation. If you need more, go to about 40GB before you break even.
What is deoptimisation?
The JIT discarding compiled code and falling back to the interpreter because an assumption it optimised on turned out to be wrong. It speculates — this call site only ever sees one class, this branch is never taken, this exception is never thrown — and guards the assumption with a cheap check. When the check fails, execution transfers back to the interpreter and the method is recompiled. It is normal, and it is why a rarely-taken slow path can make a hot method suddenly slower.

Related tutorials