Every Java object pays a tax before it stores a single field: a header the JVM uses for the class pointer, the identity hash, locking, and GC. On a 64-bit HotSpot JVM that header has historically been 96 bits (12 bytes) or 128 bits (16 bytes), depending on whether compressed class pointers are on.
That is a lot of metadata when the payload is a boxed Integer, a two-int record, or a graph node. Project Lilliput measurements cited in JEP 450 put typical average object sizes in the 32–64 byte range — so headers alone can be more than 20% of live data.
Compact object headers fold that metadata into one 64-bit (8-byte) word on 64-bit x64 and AArch64. Your source does not change. The JVM layout does.
# Java 25: product flag, still off by default (JEP 519)
java -XX:+UseCompactObjectHeaders -jar app.jar
The problem the header tax creates
A 64-bit HotSpot object header is two pieces today (the legacy layout):
- A mark word the size of a machine address (8 bytes) — identity hash, lock tags, GC age.
- A class word — 8 bytes uncompressed, or 4 bytes with compressed class pointers.
Most deployments run with compressed class pointers, so you see 12-byte headers. Turn compression off and you pay 16. Tiny objects feel this immediately: the header can be as large as the fields. Object alignment can amplify that 4-byte shrink — or hide it, if padding already filled the gap — which is why you measure heap, not “object count × 4.”
Caches of boxed numbers, pools of small DTOs, and arrays of tree or list nodes multiply that cost by millions of instances. You GC more often, fit fewer replicas per host, and bounce around more cache lines than the domain data itself would justify.
The usual “fix” is a rewrite: intern more, flatten structures, pack fields by hand. Compact headers make that the wrong first move. Do not redesign the domain to dodge a header the JVM is about to shrink.
When it shipped
| Release | Status | Spec |
|---|---|---|
| Java 24 | Experimental, off by default | JEP 450 |
| Java 25 LTS | Product feature, still off by default | JEP 519 |
| Java 27 | On by default | JEP 534 |
JEP 519 is the Java 25 change: experimental → product. Its non-goal is explicit — compact headers are not the default on 25. JEP 534 flips the default in 27 and keeps the old 96-bit layout available behind a flag. Removing that legacy layout is a later step, not this one.
The numbers the JEPs are willing to quote, after production soak at Amazon (including 21/17 backports) and a default-on SapMachine at SAP:
- SPECjbb2015: 22% less heap, 8% less CPU in one setting; 15% fewer GCs with both G1 and Parallel in another.
- A highly parallel JSON parser: 10% less time.
- Early Lilliput adopters: live data typically 10%–20% smaller.
Treat those as existence proofs, not a promise for your heap.
Mental model
Legacy headers keep type information in a separate class word so locking, hashing, and GC can overwrite the mark word without hiding the class. Compact headers subsume a compressed class pointer into the mark word and stop overwriting the whole word for locks.
Legacy (64-bit HotSpot)
mark word 8 bytes hash, lock tags, GC age
class word 4 or 8 compressed or full class pointer
total 12 or 16 bytes
Compact (JEP 450 layout, product in 25)
one header 8 bytes 22-bit compressed class pointer
+ identity hash (width unchanged)
+ GC age, lock tags
+ 4 bits reserved for Project Valhalla
JEP 450 is precise about what does not move into that word:
- Field layout and array element encoding stay as they are.
- Array length stays array metadata, not packed into the 8-byte header.
- 32-bit ports already have 64-bit headers; this feature targets 64-bit x64 and AArch64.
So “8-byte headers” means the object header, not “every byte[] is now 8 bytes total.”
Two subsystems had to stop stealing the whole header:
Locking. Lightweight locking flips tag bits (01 unlocked → 00 lightweight-locked). Monitor locking flips to 10 and allocates a monitor. Neither path overwrites the class pointer. Legacy stack locking — copy the header to the stack and plant a pointer in the object — cannot coexist with compact headers. If you configure both, the JVM disables compact headers.
Identity hash. The compact layout keeps the hash-code field width the same as the legacy mark word. JEP 519/534 note that hash codes could shrink later if Valhalla or another runtime feature needs more bits. That is a future option, not a Java 25 behavior change. System.identityHashCode still works; you just store it in a tighter header.
Compressed class pointers, not compressed oops. Compact headers require compressed class pointers and shrink that encoding from 32 bits to 22. That is a different switch from UseCompressedOops (object references in the heap). Do not pass -XX:-UseCompressedClassPointers and expect compact headers to stay on.
Enable it on 24, 25, and 27
Pick the line that matches the JDK you actually run. The flag name is stable; the unlock and the default are what change.
Java 24 — experimental. You must unlock experimental VM options or the flag is rejected:
java -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders -jar app.jar
Java 25 — product, still opt-in. Drop the experimental unlock:
java -XX:+UseCompactObjectHeaders -jar app.jar
Java 27 — on by default. You only pass a flag if you need the old layout:
java -XX:-UseCompactObjectHeaders -jar app.jar
Confirm the JVM accepted the setting. PrintFlagsFinal is the reliable check — the flag can be requested and still end up false:
java -XX:+UseCompactObjectHeaders -XX:+PrintFlagsFinal -version 2>/dev/null \
| grep UseCompactObjectHeaders
Healthy Java 25 output looks like this — true, and a product flag, not experimental:
bool UseCompactObjectHeaders = true {product} {command line}
If the line says false, the runtime declined the layout. Typical causes from JEP 450 are compressed class pointers off, legacy stack locking, or a heap larger than 8 TB without ZGC.
Note: JEP 450 also disabled compact headers when JVMCI (Graal as the JIT) was in use, because that compiler walked headers directly. By Java 25 the feature is a product option after a lot of soak testing, but if you run Graal as the JIT, still read PrintFlagsFinal instead of assuming the flag stuck.
Lab: prove the flag, then look at GC
The whole lab is one JDK and one throwaway program. No new dependency, no bytecode rewrite, no domain-model change.
Start with a pile of tiny objects you can keep alive — the shape that pays header tax:
record Node(int id, int next) {}
public class HeaderTax {
public static void main(String[] args) {
Node[] nodes = new Node[8_000_000];
for (int i = 0; i < nodes.length; i++) {
nodes[i] = new Node(i, i + 1);
}
System.out.println("nodes=" + nodes.length
+ " identityHash=" + System.identityHashCode(nodes[0]));
}
}
Run it twice on Java 25, same heap, opposite header layout. GC logging is enough to see occupancy move; do not turn the toy into a fake SPECjbb:
java -Xms512m -Xmx512m -XX:+UseCompactObjectHeaders \
-Xlog:gc HeaderTax.java
Same heap, legacy headers — the control run on Java 25:
java -Xms512m -Xmx512m -XX:-UseCompactObjectHeaders \
-Xlog:gc HeaderTax.java
On Java 24, add -XX:+UnlockExperimentalVMOptions to the first command. On Java 27, the interesting run is the second one — that is the opt-out.
The program line is the same either way. Compact headers did not remove identity hashing; the hash still lives in the header, without a separate class word beside it:
nodes=8000000 identityHash=12345678
GC log lines (Pause Young, heap occupancy) are the comparison, not that number.
A laptop delta on eight million two-int records is a sanity check, not a capacity plan. For a go/no-go, replay a production-shaped load with the flag on and compare heap after full GC, pause time, and CPU. That is the same evidence path the JEPs used, just on your allocation mix.
Who actually cares
Turn the flag on (or stop fighting the Java 27 default) when the heap is full of small, numerous objects:
- Boxed primitives and other wrappers in caches or APIs that still box.
- Tiny records / value-like carriers allocated per request or per node.
- Graphs, parse trees, and collections of small entries — JSON and SPECjbb are the JEP’s own examples for a reason.
- Services that are heap-bound or GC-bound at the current replica size: 10–20% less live data is extra headroom or fewer pods.
You will notice less if the live set is a few large arrays, off-heap buffers, or a handful of coarse aggregates. Headers are a constant per object; the win scales with object count, not with the size of any one payload.
Virtual-thread servers that allocate a small carrier object per request are in the “many small objects” bucket. The header change is still a JVM flag, not a reason to restyle that code.
What you do not rewrite
Compact headers are an implementation of the same Java objects you already have. Field order, equals, records vs classes, and whether a cache keys on identity or value are design questions. This JEP does not answer them.
Skip:
- Merging types or inlining fields “to amortize the header.”
- Replacing records with primitives-in-arrays as a header workaround (wait for Valhalla if that is the real goal; four header bits are already reserved).
- Micro-optimizing
identityHashCodeusage because you heard the hash field might shrink someday. It does not shrink in 25.
Measure, flip the flag, ship. If a profiler still says “too many tiny objects,” then talk about allocation sites.
Gotchas
The flag can silently not apply
“I passed +UseCompactObjectHeaders” is not the same as “headers are 8 bytes.” If compressed class pointers are off, locking is the legacy stack scheme, or the heap is over 8 TB on a collector other than ZGC, HotSpot disables compact headers. Check PrintFlagsFinal.
8 TB heaps without ZGC
Copying collectors that are not ZGC encode a forwarding pointer in the lower 42 bits of the compact header, which covers up to 8 TB. Larger heaps on those collectors keep the old layout. ZGC uses a side forwarding table, so it is the exception named in JEP 450.
Class-count ceiling
Compressed class pointers already top out around four million loaded classes. Compact headers need that encoding (and use 22 bits, not 32). The escape hatch of “just disable compressed class pointers” is how you leave compact headers, not how you stretch them. Almost no application is near that ceiling; a megamorphic plugin host might be.
Arrays still have a length word
Shrinking the object header does not fold length into those 8 bytes. A byte[1] is still header + length + payload + alignment. The JEP’s non-goals say so on purpose.
Experimental unlock on 24 only
Copy-pasting -XX:+UnlockExperimentalVMOptions onto Java 25 is harmless noise; forgetting it on Java 24 means the flag does not start. Prefer 25+ for production opt-in so you are on the product path JEP 519 delivered.
Cheat sheet
Java 24 JEP 450 experimental
java -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders ...
Java 25 JEP 519 product, off by default
java -XX:+UseCompactObjectHeaders ...
Java 27 JEP 534 on by default
java -XX:-UseCompactObjectHeaders ... # opt out only
Header size (64-bit x64 / AArch64)
legacy 96 or 128 bits (12 or 16 bytes)
compact 64 bits (8 bytes)
Requires compressed class pointers (22-bit encoding in compact mode)
Hash-code width unchanged in the compact layout
Locking: tag bits only — not legacy stack locking
Heaps > 8 TB: ZGC, or compact headers stay off
No application source change
JEP-quoted wins (not your SLA)
SPECjbb2015: 22% heap, 8% CPU; 15% fewer GCs (G1 and Parallel)
JSON parser: 10% time
Lilliput early adopters: 10–20% less live data
Do:
- Enable on Java 25 with
-XX:+UseCompactObjectHeadersand confirmPrintFlagsFinal. - Compare heap after GC and CPU on a production-shaped load, not only a microbench.
- Leave the domain model alone until the flag is on and still not enough.
- Plan for Java 27 to default it on; keep
-XX:-UseCompactObjectHeadersas a rollback, not a lifestyle.
Don’t:
- Expect 24 to accept the flag without
-XX:+UnlockExperimentalVMOptions. - Confuse compressed oops with compressed class pointers.
- Rewrite records into hand-packed buffers to save a header the JVM already knows how to shrink.
- Treat SPECjbb’s 22% as a guarantee for a service whose live set is three large arrays.
Wrap-up
Compact object headers are a runtime layout change, not an API. JEP 450 proved that 64-bit HotSpot can keep class, hash, lock, and GC metadata in 8 bytes instead of 12–16. JEP 519 makes that a product flag in Java 25. JEP 534 turns it on by default in Java 27.
Add -XX:+UseCompactObjectHeaders on 25, grep the flags, and replay load. If the live set is millions of small objects, you should see less heap and less GC without touching a line of Java. If it is not, you lost nothing but a JVM argument — and you still should not re-architect the domain around a header tax the platform is already removing.