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.
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
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 bytesThe 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.
// org.openjdk.jol:jol-core
System.out.println(ClassLayout.parseInstance(new Point(1, 2)).toPrintable());
System.out.println(GraphLayout.parseInstance(myMap).toFootprint()); // whole graphJOL 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.
| Thing | Bytes |
|---|---|
| Object header | 12 (+ padding) |
| Reference | 4 compressed, 8 uncompressed |
boolean, byte | 1 |
char, short | 2 |
int, float | 4 |
long, double | 8 |
| Array header | 16 (12 + 4 for length) |
Integer | 16 |
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.
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.
// 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.
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.
-XX:+UnlockDiagnosticVMOptions -XX:+PrintEscapeAnalysis -XX:+PrintEliminateAllocations
-XX:-DoEscapeAnalysis # turn it off to measure what it was buying youThe 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
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 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.
-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 graphicallyA 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?
Why should a heap stay under 32GB?
What is deoptimisation?
Related tutorials
- Profiling with JFR and async-profilerRunning JFR continuously in production, why traditional samplers suffer safepoint bias, reading a flame graph, allocation profiling, and choosing between CPU and wall-clock sampling.
- JVM Flags and Container Awareness in KubernetesHow the JVM reads cgroup limits, why MaxRAMPercentage beats Xmx in a container, how CPU quota affects GC and pool sizing, and why CPU limits cause latency spikes through throttling.
- Heap Dump Analysis with MATCapturing a heap dump safely in production, the difference between shallow and retained size, reading the dominator tree, using path to GC roots, and OQL queries that answer real questions.
- Every OutOfMemoryError and What It MeansEach OutOfMemoryError message, what it actually indicates, the most likely cause, and the first three things to check — plus why OOMKilled by the kernel is a different failure entirely.