Every Java object carries a header before its first field. The JVM uses it to find the object's class, store its identity hash code, track its age for the garbage collector and coordinate locking. On 64-bit HotSpot with compressed class pointers, the default configuration, that header has been 12 bytes. For an application whose heap is dominated by small objects, such as map entries, boxed numbers, records and short arrays, the header can be a large fraction of the memory it uses.

Project Lilliput is the OpenJDK project that shrinks the header. Its first result, compact object headers, reduces it to 8 bytes. It arrived as an experimental feature in JDK 24 (JEP 450), became a product feature in JDK 25 (JEP 519), and JEP 534, targeted to JDK 27, makes it the default. This article explains the two layouts bit by bit, why fitting into 64 bits forced changes to locking and garbage collection, what it saves on real object shapes, and how to test it on your own service before it becomes the default.

Advertisement

Why the header matters

HotSpot aligns objects to 8 bytes by default. An object's size is its header plus its fields, rounded up to the next multiple of 8. With a 12-byte header, 4 bytes of the first 8-byte boundary after the header are free for a field, and anything bigger spills into the next slot. Shrinking the header by 4 bytes does not save 4 bytes on every object; it saves 8 bytes on objects whose fields previously crossed an alignment boundary, and nothing on the rest.

That is why results vary between applications. Heaps full of small objects with 8 to 16 bytes of fields gain the most. Heaps dominated by large arrays, such as byte buffers, off-heap-backed structures or big primitive arrays, gain almost nothing, because one header per megabyte array is noise. For background on how the heap is laid out and where objects live, see JVM memory, in depth.

The legacy layout

The legacy header has two words. The mark word is 64 bits: 31 bits of identity hash code, 4 bits of GC age, 2 lock tag bits and unused space. The class word points to the object's class metadata: 64 bits if compressed class pointers are off, 32 bits when they are on, which is the usual case. Arrays add a 4-byte length field.

The mark word was not always stable. With the old stack-locking scheme, locking an object copied the mark word onto the thread's stack and replaced it with a pointer to that copy; inflating to a full monitor replaced it with a pointer to the monitor. Garbage collectors that copy objects overwrote the mark word with a forwarding pointer to the new location. Those designs assumed that anything in the mark word could be temporarily displaced, which is fine while the class pointer lives in its own word.

Advertisement

The compact layout

Compact object headers put everything into one 64-bit word. JEP 450 gives the layout, from the most significant bit down: a 22-bit compressed class pointer, a 31-bit identity hash code, 4 bits reserved for Project Valhalla, 4 bits of GC age, 1 self-forwarded bit and 2 lock tag bits. The hash code keeps its full 31 bits, so identity hash values are unchanged. Arrays keep their length field after the header.

HotSpot object header on 64-bit, compressed class pointersLegacy: 96 bitsMark word: 64 bitshash 31, GC age 4, lock tag 2, unusedClass word: 32 bitscompressed class pointerCompact (JEP 450 / 519): 64 bitsClass pointer22 bitsIdentity hash31 bitsV4Age4S1Tag2class pointer moves into the mark wordV: bits reserved for Project Valhalla. S: self-forwarded bit used by GC when copying fails.Tag: 01 unlocked, 00 lightweight-locked, 10 inflated monitor. Legacy stack locking is not supported.What changes for softwareEvery object and array is 4 bytes shorter before 8-byte alignment, so small objects shrink most.Class pointers drop from 32 to 22 bits: roughly four million classes can be addressed.Locking and GC forwarding must never overwrite the class pointer, which is why both were redesigned.
Legacy and compact headers side by side. The class pointer moves into the mark word, which is why nothing may overwrite the mark word any more.

Fitting the class pointer into 22 bits required a new encoding of compressed class pointers. The consequence is a limit on how many classes can be addressed: JEP 450 puts it at about four million, and notes that no application loading that many classes had been seen. Frameworks that generate classes at run time, such as proxies, lambdas and bytecode-generating serialisers, still load far fewer than that, but a process that leaks classes through repeated class-loader creation reaches the limit sooner than before.

What had to change inside HotSpot

Once the class pointer lives in the mark word, the JVM can never overwrite the mark word wholesale, because the object's type would be lost while it is displaced. Three subsystems depended on doing exactly that.

  • Locking. Legacy stack locking replaced the mark word with a stack pointer. Compact headers work only with lightweight locking, which records lock ownership in a per-thread lock stack and flips the tag bits from 01 to 00, leaving the rest of the header intact. When contention inflates a lock to a full monitor, the tag becomes 10 and the monitor is found without overwriting the header. JEP 450 states that legacy stack locking is incompatible with compact headers.
  • Copying collectors. When a copying GC cannot evacuate an object, for example because space ran out, it marks the object as forwarded to itself. That used to mean writing a pointer into the mark word; with compact headers it sets the dedicated self-forwarded bit instead.
  • Sliding (compacting) collectors. Full GCs that slide objects need to record where each object moves. JEP 450 describes a compact encoding of the forwarding address in the lower 42 bits, which addresses up to 8 TB of heap. That limit applies to collectors other than ZGC; ZGC handles forwarding separately and is not bound by it.

JEP 450 also lists what is not supported: 32-bit platforms, and configurations with JVMCI enabled, in which case compact headers are disabled. Compressed class pointers must be on. If you run a JVMCI-based compiler or an unusual flag combination, check with -XX:+PrintFlagsFinal that the setting actually took effect.

Status and flags by JDK release

The feature moved in three steps. In JDK 24 it was experimental and needed an unlock flag. JEP 519 made it a product feature in JDK 25, so a single flag enables it, and it remains opt-in there and in JDK 26. JEP 534, targeted to JDK 27, makes it the default and keeps the legacy layout available with the negated flag; removing the legacy layout is explicitly not part of that JEP.

# JDK 24: experimental
java -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders -jar app.jar

# JDK 25 and 26: product feature, still opt-in
java -XX:+UseCompactObjectHeaders -jar app.jar

# Confirm what the running JVM actually chose
java -XX:+UseCompactObjectHeaders -XX:+PrintFlagsFinal -version | grep -E "UseCompactObjectHeaders|UseCompressedClassPointers"

# JEP 534, targeted to JDK 27, makes compact headers the default; opting out would then be
java -XX:-UseCompactObjectHeaders -jar app.jar

JEP 519 reports that in one configuration SPECjbb2015 used 22 percent less heap and 8 percent less CPU time, that the number of collections fell by 15 percent with both G1 and Parallel, and that a highly parallel JSON parser benchmark ran in 10 percent less time. Those numbers are real but workload-specific. Treat them as the reason to test, not as a forecast.

Project Lilliput has further work in progress. A separate JEP draft proposes 4-byte object headers as an experimental feature; it is a draft, not a targeted JEP, and none of its details should be relied on yet.

Worked example: what shrinks and what does not

The numbers below are alignment arithmetic for 64-bit HotSpot with compressed oops (4-byte references) and 8-byte object alignment. Field layout can differ between JDK builds, so confirm them with JOL, shown in the next section.

ObjectFieldsLegacy: 12-byte headerCompact: 8-byte header
java.lang.Integerone int (4 B)12 + 4 = 16 B8 + 4 = 12, aligned to 16 B
java.lang.Longone long (8 B)12 + 8 = 20, aligned to 24 B8 + 8 = 16 B
record Point(int x, int y)two ints (8 B)12 + 8 = 20, aligned to 24 B8 + 8 = 16 B
HashMap.Nodeint hash plus three references (16 B)12 + 16 = 28, aligned to 32 B8 + 16 = 24 B
long[1]4-byte length plus one long16 + 8 = 24 B8-byte elements stay at offset 16: 16 + 8 = 24 B
int[1]4-byte length plus one int16 + 4 = 20, aligned to 24 B12 + 4 = 16 B

Now apply it to a cache holding 10 million entries in a HashMap<Long, Long>. Each entry has one node and two boxed Longs. Legacy: 32 + 24 + 24 = 80 bytes per entry, about 800 MB. Compact: 24 + 16 + 16 = 56 bytes per entry, about 560 MB. The backing table of references is the same size in both. That is a 30 percent cut in the entry objects with no code change, and correspondingly less work for the collector, since fewer bytes are allocated and copied per entry.

Contrast an Integer cache, where each object is 16 bytes either way, or a service whose heap is mostly large byte[] buffers. Both see little change. Your saving depends on your object mix, which is why measuring is the only way to know.

Measuring it on your service

Start with layouts. JOL, the Java Object Layout tool from OpenJDK, prints the real size and field offsets of a class, and the total footprint of an object graph. Running the same program with and without the flag shows exactly which types shrank.

// Maven: org.openjdk.jol:jol-core
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
import java.util.HashMap;

public class HeaderCheck {
    record Point(int x, int y) {}

    public static void main(String[] args) {
        System.out.println(ClassLayout.parseClass(Point.class).toPrintable());
        System.out.println(ClassLayout.parseInstance(new long[1]).toPrintable());

        var map = new HashMap<Long, Long>();
        for (long i = 0; i < 1_000_000; i++) map.put(i, i);
        System.out.println(GraphLayout.parseInstance(map).toFootprint());
    }
}
// Run twice, with and without -XX:+UseCompactObjectHeaders, and diff the output.
// JOL may need -Djdk.attach.allowAttachSelf=true or --add-opens flags on recent JDKs.

Then measure the whole service under load. Run two instances on the same build with identical heap settings, differing only in the header flag, and compare GC logs over a representative period.

# A/B run: same build, same load, same heap limits; only the header flag differs
JAVA_OPTS_A="-Xms8g -Xmx8g -XX:+UseG1GC -Xlog:gc*:file=gc-a.log:time,uptime"
JAVA_OPTS_B="$JAVA_OPTS_A -XX:+UseCompactObjectHeaders -Xlog:gc*:file=gc-b.log:time,uptime"

# Compare per run:
#   live heap after full or mixed collections   (the footprint signal)
#   young collections per minute                (allocation pressure)
#   p99 latency and CPU per request              (what users and bills see)
#   jcmd <pid> GC.class_histogram                (which types shrank)

The strongest signal is live heap after collections, because it reflects retained data rather than allocation noise. A smaller live set lets you either lower -Xmx and save memory, or keep it and collect less often. Then look at CPU and tail latency; better cache density often helps both, but a latency-critical service should confirm it rather than assume it. For collector-specific tuning after the change, see G1 GC, in depth and ZGC, in depth.

If you build class-data sharing or ahead-of-time caches, create them with the same header setting you run with, and check with -Xlog:cds that the archive is actually mapped at startup. An archive built for a different header layout cannot be used, and the only visible symptom is slower startup.

Failure modes and trade-offs

  • Expecting uniform savings. A 4-byte header cut produces 0 or 8 bytes of saving per object depending on alignment. Services with large-array heaps may see no change, and that is not a bug.
  • Flag silently ignored. With JVMCI enabled or an incompatible setting, the JVM runs with legacy headers. Always verify the final flag value in the running process.
  • Code that assumes header size. Libraries that compute object sizes with hard-coded constants, or use Unsafe offsets derived from a 12-byte header, give wrong answers. Array base offsets change too. Use JOL or the JVM's own offset queries, never constants.
  • Class-count limit. About four million classes is far above normal use, but a class-loader leak that once exhausted Metaspace slowly may now also hit the class pointer range. Monitor loaded class counts.
  • Heap above 8 TB. JEP 450 describes the forwarding encoding limit for non-ZGC collectors. Only very large heaps are affected, and they usually run ZGC.
  • Less room for the future. JEP 534 notes that future features may need header bits; the project's answer is further compression, which is part of why Lilliput continues.

The trade-off overall is favourable: the costs fall mostly on JVM implementers, and the benefits go to applications without code changes. Related OpenJDK work attacks memory from other angles; Project Valhalla removes identity and headers from value objects altogether, which is why Lilliput reserves four bits for it.

What to do next

  1. Upgrade a test environment to JDK 25 or later and run your service with -XX:+UseCompactObjectHeaders, confirming the flag with PrintFlagsFinal.
  2. Use JOL on your hottest domain classes and collections to see which ones shrink.
  3. Run an A/B load test with identical heap settings and compare live heap after GC, GC frequency, CPU and p99 latency.
  4. Search your dependencies for hard-coded object-size or Unsafe offset assumptions, and regenerate CDS or AOT caches with the new setting.
  5. Roll out to one production canary, then fleet-wide; revisit -Xmx once live-heap data is in.
  6. Plan for JDK 27, where compact headers become the default, and note the opt-out flag in your runbook.
Key takeaway: Project Lilliput shrinks the HotSpot object header from 12 to 8 bytes by moving a 22-bit compressed class pointer into the mark word alongside the 31-bit hash, GC age and lock bits. That required lightweight locking and new GC forwarding schemes, and it limits addressable classes to about four million. Compact headers are a product feature from JDK 25, opt-in until JEP 534 makes them the default in JDK 27. Savings come in 8-byte steps set by alignment, so small-object heaps gain most. Verify the flag, measure with JOL and GC logs, and roll out behind a canary.