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.
| object | without the flag | with the flag | saving |
|---|---|---|---|
| empty object | 16 | 8 | 8 |
one int field | 16 | 16 | 0 |
Integer | 16 | 16 | 0 |
Long | 24 | 16 | 8 |
one-character String, the object alone | 24 | 24 | 0 |
its byte[1] | 24 | 16 | 8 |
byte[8] | 24 | 24 | 0 |
byte[12] | 32 | 24 | 8 |
byte[16] | 32 | 32 | 0 |
Entry (long, reference, int) | 32 | 24 | 8 |
HashMap.Node | 32 | 24 | 8 |
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.
| mode | heap after the load and one System.gc() | Full GCs during the load |
|---|---|---|
| without the flag | 453 MB | 1 to 3 per run |
| with the flag | 339 MB | 0 |
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.