Compact object headers: what the flag saves on JDK 25

A Java catalog service keeps five million products in memory, in a HashMap<Long, Entry>. Each entry has three fields: a long id, a reference and an int counter. The container has 2 GB of heap and the Full GCs are becoming more frequent. JDK 25 offers a flag, -XX:+UseCompactObjectHeaders, which shrinks the header of every object. Before enabling it in production, we measure what it saves.

$ java -Xmx2g -XX:+UseSerialGC -XX:-UseCompactObjectHeaders -Xlog:gc -cp . Shapes hashmap 5000000
[0.819s][info][gc] GC(9) Pause Full (System.gc()) 487M->487M(1179M) 169.469ms

$ java -Xmx2g -XX:+UseSerialGC -XX:+UseCompactObjectHeaders -Xlog:gc -cp . Shapes hashmap 5000000
[0.768s][info][gc] GC(8) Pause Full (System.gc()) 373M->373M(902M) 173.690ms

Same program, same map, two processes. We keep the last Pause Full line of each run. After three System.gc() calls, the live heap of the process goes from 487 MB to 373 MB. The flag saves 114 MB, or 23%. This total includes a 19 MB array of the test program, identical in both modes. The five runs per mode give the same result within 1 KB.

We measured with Corretto 25.0.4.1 on a Mac and in the eclipse-temurin:25-jdk image. This image contains Temurin 25.0.4.1+1 on Linux aarch64. Each size measurement runs in its own process, with -XX:+UseSerialGC and -Xlog:gc, without -Xms. We read the heap after three System.gc() calls, then print a histogram with jcmd GC.class_histogram. Object sizes come from JOL 0.17. In the Docker image, every measured object has the same size as on the Mac.

What the flag changes in the header

Without the flag, every object starts with a 12-byte header. It holds an 8-byte mark word, then a 4-byte compressed class pointer. JOL shows it on an empty class:

=== Empty ===
JolShapes$Empty object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4        (object header: class)    0x010c4a58
 12   4        (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

The empty object is 16 bytes: 12 of header and 4 of padding, because the JVM aligns objects on 8 bytes.

With -XX:+UseCompactObjectHeaders, the class pointer moves into the mark word:

=== Empty ===
JolShapes$Empty object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x010c580000000001 (Lilliput)
Instance size: 8 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

The header is 8 bytes and so is the empty object. JOL 0.17 prints Lilliput, the name of the original project, and cannot decode the fields. The JDK 25 source file markWord.hpp gives the bit layout. The mark word holds 22 bits for the class and 31 bits for the identity hash. The other bits are: 4 reserved for Valhalla, 4 for the age, 1 for forwarding and 2 for the lock. The class pointer goes from 32 to 22 bits. JEP 450 states that compressed class pointers already limit the JVM to about four million classes. With the flag, they can no longer be disabled to go past that limit.

The identity hash does not change the size of the object. After a call to System.identityHashCode, JOL still shows 8 bytes, and the hash can be read in bits 11 to 41 of the mark word.

The objects that shrink and those that keep their size

The header loses 4 bytes, but the 8-byte alignment decides what the object really saves. The program allocates two million instances of each object type into a static array, then reads the heap after three System.gc() calls. We compare with a run that allocates only the array. The difference, divided by two million, gives the size per object. The jcmd histogram gives the same value, except for byte[] where it also counts the small arrays of the JDK. For String, its byte[1] and HashMap.Node, the values come from the histogram.

objectwithout the flagwith the flagsaving
empty object1688
one int field16160
Integer16160
Long24168
one-character String, the object alone24240
its byte[1]24168
byte[8]24240
byte[12]32248
byte[16]32320
Entry (long, reference, int)32248
HashMap.Node32248

The saving is 8 bytes or 0, never 4. An object with a single int field is 12 + 4 = 16 bytes without the flag. With the flag, it is 8 + 4 = 12 bytes, rounded up to 16. JOL shows that the padding comes back:

=== OneInt ===
JolShapes$OneInt object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x010c5c0000000001 (Lilliput)
  8   4    int OneInt.x                  0
 12   4        (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

The 4 bytes saved in the header are lost in the padding. Integer has the same shape and saves nothing. String goes from 22 bytes before padding to 18, and both values round up to 24.

An object saves 8 bytes when it already has at least 4 bytes of padding without the flag. Long is 24 bytes without the flag, with 4 bytes of padding between the header and the long. With the flag, the long starts at offset 8 and the object is 16 bytes. Entry and HashMap.Node have 4 bytes of padding at the end and go from 32 to 24 bytes.

Arrays change too. The length moves from offset 12 to offset 8, and the elements start at offset 12 instead of 16. A byte[12] goes from 32 to 24 bytes. A byte[8] stays at 24: 12 + 8 = 20, rounded up to 24. An empty array stays at 16 bytes. For long[] and double[], the elements stay at offset 16, because they must be aligned on 8 bytes.

The map of five million entries

The histogram of the map explains the 114 MB saved on the live heap. The first four lines without the flag:

 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:       5001143      160036576  java.util.HashMap$Node (java.base@25.0.4.1)
   2:       5000000      160000000  Shapes$Entry
   3:       5000129      120003096  java.lang.Long (java.base@25.0.4.1)
   4:           267       33579600  [Ljava.util.HashMap$Node; (java.base@25.0.4.1)

With the flag:

 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:       5001143      120027432  java.util.HashMap$Node (java.base@25.0.4.1)
   2:       5000000      120000000  Shapes$Entry
   3:       5000129       80002064  java.lang.Long (java.base@25.0.4.1)
   4:           267       33579600  [Ljava.util.HashMap$Node; (java.base@25.0.4.1)

Each map entry costs three objects: a Node, an Entry and a Long key. Without the flag, they are 32 + 32 + 24 = 88 bytes. With the flag, 24 + 24 + 16 = 64 bytes, or 27% less. All three objects have at least 4 bytes of padding without the flag. The three lines lose 120 million bytes in total, which is the 114 MB of the GC log. The map’s table, the 32 MB Node[] array, does not change. Its length is a power of two, and the 4 bytes saved in the header are lost in the padding.

In the Docker image, every object has exactly the same size. The live heap goes from 485 MB to 371 MB.

The GC under load

The live heap saving also appears under load. A second program builds the same map, then replaces 1% of the entries on every round for 30 seconds, with G1 and 2 GB of heap. Three runs per mode.

modeheap after the load and one System.gc()Full GCs during the load
without the flag453 MB1 to 3 per run
with the flag339 MB0

Without the flag, the heap fills up to 2048 MB. G1 then triggers a Full GC, G1 Compaction Pause, one to three times per run. With the flag, there is no Full GC during the three runs. The median run with the flag also did about twice as many rounds. The young GC pauses are therefore not comparable between the two modes, because the amount of work is not the same. This test shows two things: the heap saving and the absence of Full GCs.

The flags that interact

On JDK 25, this flag is of type product. It no longer requires -XX:+UnlockExperimentalVMOptions, and it is false by default:

$ java -XX:+PrintFlagsFinal -version | grep UseCompactObjectHeaders
     bool UseCompactObjectHeaders                  = false                          {product lp64_product} {default}

Serial, Parallel, G1, ZGC and Shenandoah accept the flag on this build, without a warning.

The flag needs compressed class pointers. If a script disables them, the JVM starts, but without compact object headers:

$ java -XX:+UseCompactObjectHeaders -XX:-UseCompressedClassPointers -version
OpenJDK 64-Bit Server VM warning: Option UseCompressedClassPointers was deprecated in version 25.0 and will likely be removed in a future release.
OpenJDK 64-Bit Server VM warning: Compact object headers require compressed class pointers. Disabling compact object headers.
[0.006s][warning][cds] Unable to use shared archive file.
[                    ] The saved state of UseCompressedOops and UseCompressedClassPointers is different from runtime, CDS will be disabled.
openjdk version "25.0.4.1" 2026-08-18 LTS
OpenJDK Runtime Environment Corretto-25.0.4.10.1 (build 25.0.4.1+10-LTS)
OpenJDK 64-Bit Server VM Corretto-25.0.4.10.1 (build 25.0.4.1+10-LTS, mixed mode)

The exit code is 0. A script that combines the two flags therefore runs with classic headers, and only the warnings show it. The default CDS archive is disabled too.

The flag does not depend on compressed oops. With -XX:-UseCompressedOops, or with -Xmx40g which disables them, the flag stays active and the empty object is still 8 bytes. References go to 8 bytes, as they do without the flag.

The JDK provides four CDS archives in lib/server: classes.jsa, classes_coh.jsa, classes_nocoops.jsa and classes_nocoops_coh.jsa. With the flag, the JVM loads classes_coh.jsa automatically:

$ java -XX:+UseCompactObjectHeaders -Xlog:cds -version
[0.003s][info][cds] trying to map .../lib/server/classes_coh.jsa
[0.003s][info][cds] Opened shared archive file .../lib/server/classes_coh.jsa.
[0.003s][info][cds] The shared archive file was created with UseCompressedOops = 1, UseCompressedClassPointers = 1, UseCompactObjectHeaders = 1

An AOT cache created with the flag is refused without the flag:

$ java -XX:-UseCompactObjectHeaders -XX:AOTCache=app.aot -Xlog:aot -cp . Shapes empty 1000
[0.003s][info][aot] trying to map app.aot
[0.003s][info][aot] Opened AOT cache app.aot.
[0.003s][info][aot] The AOT cache was created with UseCompressedOops = 1, UseCompressedClassPointers = 1, UseCompactObjectHeaders = 1
[0.003s][warning][aot] Unable to use AOT cache.
[                    ] The AOT cache's UseCompactObjectHeaders setting (enabled) does not equal the current UseCompactObjectHeaders setting (disabled).
[0.003s][error  ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details.
[0.003s][error  ][aot] Loading static archive failed.
[0.003s][error  ][aot] Unable to map shared spaces

With -XX:AOTMode=auto, the default value, the JVM continues without the cache and the program runs. With -XX:AOTMode=on, it stops with exit code 1. If you change the flag, rebuild the AOT cache as well.

In summary

On JDK 25, -XX:+UseCompactObjectHeaders is a product flag, false by default. It shrinks the header of every object from 12 to 8 bytes, with a 22-bit class pointer in the mark word.

An object saves 8 bytes or nothing, because of the 8-byte alignment. Integer, an object with a single int, String, byte[8] and byte[16] keep the same size. The empty object, Long, HashMap.Node, byte[12] and a class with three fields save 8 bytes.

On a HashMap<Long, Entry> of five million entries, each entry goes from 88 to 64 bytes and the live heap from 487 MB to 373 MB. Under load with G1 and 2 GB of heap, no Full GC happened in compact mode. Without the flag, there were one to three per run.

The flag needs compressed class pointers. Without them, it disables itself with only a warning. It does not depend on compressed oops. The JDK provides a CDS archive for each mode, but an AOT cache created with the flag does not work without it.

What next?

To read the Pause Full lines and the other lines of a GC log, see Reading GC logs. To choose the collector and the heap size once the saving is measured, see Tuning the JVM garbage collector.