Compact object headers : ce que le flag fait gagner sur JDK 25
Un service de catalogue en Java garde cinq millions de produits en mémoire, dans une HashMap<Long, Entry>. Chaque entrée a trois champs : un identifiant long, une référence et un compteur int. Le conteneur a 2 Go de heap et les Full GC deviennent plus fréquents. JDK 25 propose un flag, -XX:+UseCompactObjectHeaders, qui réduit l’en-tête de chaque objet. Avant de l’activer en production, on mesure ce qu’il fait gagner.
$ 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
Même programme, même map, deux processus. On garde la dernière ligne Pause Full de chaque run. Après trois System.gc(), la heap vivante du processus passe de 487 Mo à 373 Mo. Le flag fait gagner 114 Mo, soit 23 %. Ce total inclut un tableau de 19 Mo du programme de test, identique dans les deux modes. Les cinq runs par mode donnent le même résultat à 1 Ko près.
On a mesuré avec Corretto 25.0.4.1 sur un Mac et dans l’image eclipse-temurin:25-jdk. Cette image contient Temurin 25.0.4.1+1 sur Linux aarch64. Chaque mesure de taille tourne dans son propre processus, avec -XX:+UseSerialGC et -Xlog:gc, sans -Xms. On lit la heap après trois System.gc(), puis on affiche un histogramme avec jcmd GC.class_histogram. Les tailles d’objets viennent de JOL 0.17. Dans l’image Docker, chaque objet mesuré a la même taille que sur le Mac.
Ce que le flag change dans l’en-tête
Sans le flag, chaque objet commence par un en-tête de 12 octets. Il contient un mark word de 8 octets, puis un pointeur de classe compressé de 4 octets. JOL le montre sur une classe vide :
=== 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
L’objet vide fait 16 octets : 12 d’en-tête et 4 de remplissage, parce que la JVM aligne les objets sur 8 octets.
Avec -XX:+UseCompactObjectHeaders, le pointeur de classe entre dans le 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
L’en-tête fait 8 octets et l’objet vide aussi. JOL 0.17 affiche Lilliput, le nom du projet d’origine, et ne sait pas décoder les champs. Le fichier source markWord.hpp de JDK 25 donne la répartition des bits. Le mark word contient 22 bits pour la classe et 31 bits pour le hash d’identité. Les autres bits sont : 4 réservés pour Valhalla, 4 pour l’âge, 1 pour le forwarding et 2 pour le lock. Le pointeur de classe passe de 32 à 22 bits. JEP 450 indique que les pointeurs de classe compressés limitent déjà la JVM à environ quatre millions de classes. Avec le flag, on ne peut plus les désactiver pour dépasser cette limite.
Le hash d’identité ne change pas la taille de l’objet. Après un appel à System.identityHashCode, JOL montre toujours 8 octets, et le hash est lisible dans les bits 11 à 41 du mark word.
Les objets qui rétrécissent et ceux qui gardent leur taille
L’en-tête perd 4 octets, mais l’alignement sur 8 octets décide de ce que l’objet gagne vraiment. Le programme alloue deux millions d’instances de chaque type d’objet dans un tableau statique, puis lit la heap après trois System.gc(). On compare avec un run qui alloue seulement le tableau. La différence, divisée par deux millions, donne la taille par objet. L’histogramme jcmd donne la même valeur, sauf pour les byte[] où il compte aussi les petits tableaux du JDK. Pour String, son byte[1] et HashMap.Node, les valeurs viennent de l’histogramme.
| objet | sans le flag | avec le flag | gain |
|---|---|---|---|
| objet vide | 16 | 8 | 8 |
un champ int | 16 | 16 | 0 |
Integer | 16 | 16 | 0 |
Long | 24 | 16 | 8 |
String d’un caractère, l’objet seul | 24 | 24 | 0 |
son byte[1] | 24 | 16 | 8 |
byte[8] | 24 | 24 | 0 |
byte[12] | 32 | 24 | 8 |
byte[16] | 32 | 32 | 0 |
Entry (long, référence, int) | 32 | 24 | 8 |
HashMap.Node | 32 | 24 | 8 |
Le gain est de 8 octets ou de 0, jamais de 4. Un objet avec un seul champ int fait 12 + 4 = 16 octets sans le flag. Avec le flag, il fait 8 + 4 = 12 octets, arrondis à 16. JOL montre que le remplissage revient :
=== 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
Les 4 octets gagnés dans l’en-tête sont perdus dans le remplissage. Integer a la même forme et ne gagne rien. String passe de 22 octets avant remplissage à 18, et les deux valeurs s’arrondissent à 24.
Un objet gagne 8 octets quand il a déjà au moins 4 octets de remplissage sans le flag. Long fait 24 octets sans le flag, avec 4 octets de remplissage entre l’en-tête et le long. Avec le flag, le long commence à l’offset 8 et l’objet fait 16 octets. Entry et HashMap.Node ont 4 octets de remplissage à la fin et passent de 32 à 24 octets.
Les tableaux changent aussi. La longueur passe de l’offset 12 à l’offset 8, et les éléments commencent à l’offset 12 au lieu de 16. Un byte[12] passe de 32 à 24 octets. Un byte[8] reste à 24 : 12 + 8 = 20, arrondis à 24. Un tableau vide reste à 16 octets. Pour long[] et double[], les éléments restent à l’offset 16, parce qu’ils doivent être alignés sur 8 octets.
La map de cinq millions d’entrées
L’histogramme de la map explique les 114 Mo gagnés sur la heap vivante. Les quatre premières lignes sans le 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)
Avec le 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)
Chaque entrée de la map coûte trois objets : un Node, un Entry et une clé Long. Sans le flag, ils font 32 + 32 + 24 = 88 octets. Avec le flag, 24 + 24 + 16 = 64 octets, soit 27 % de moins. Les trois objets ont au moins 4 octets de remplissage sans le flag. Les trois lignes perdent 120 millions d’octets en tout, ce qui fait les 114 Mo du log GC. La table de la map, le tableau Node[] de 32 Mo, ne change pas. Sa longueur est une puissance de deux, et les 4 octets gagnés dans l’en-tête sont perdus dans le remplissage.
Dans l’image Docker, chaque objet a exactement la même taille. La heap vivante passe de 485 Mo à 371 Mo.
Le GC sous charge
Le gain de heap vivante apparaît aussi sous charge. Un second programme construit la même map, puis remplace 1 % des entrées à chaque tour pendant 30 secondes, avec G1 et 2 Go de heap. Trois runs par mode.
| mode | heap après la charge et un System.gc() | Full GC pendant la charge |
|---|---|---|
| sans le flag | 453 Mo | 1 à 3 par run |
| avec le flag | 339 Mo | 0 |
Sans le flag, la heap se remplit jusqu’à 2048 Mo. G1 déclenche alors un Full GC, G1 Compaction Pause, une à trois fois par run. Avec le flag, il n’y a aucun Full GC pendant les trois runs. Le programme avec le flag a aussi fait environ deux fois plus de tours en médiane. Les pauses du young GC ne sont donc pas comparables entre les deux modes, parce que la quantité de travail n’est pas la même. Ce test montre deux choses : le gain de heap et l’absence de Full GC.
Les flags qui interagissent
Sur JDK 25, ce flag est de type product. Il ne demande plus -XX:+UnlockExperimentalVMOptions, et il est à false par défaut :
$ java -XX:+PrintFlagsFinal -version | grep UseCompactObjectHeaders
bool UseCompactObjectHeaders = false {product lp64_product} {default}
Serial, Parallel, G1, ZGC et Shenandoah acceptent le flag sur ce build, sans avertissement.
Le flag a besoin des pointeurs de classe compressés. Si un script les désactive, la JVM démarre, mais sans 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)
Le code de sortie est 0. Un script qui combine les deux flags tourne donc avec des en-têtes classiques, et seuls des avertissements l’indiquent. L’archive CDS par défaut est aussi désactivée.
Le flag ne dépend pas des oops compressés. Avec -XX:-UseCompressedOops, ou avec -Xmx40g qui les désactive, le flag reste actif et l’objet vide fait toujours 8 octets. Les références passent à 8 octets, comme sans le flag.
Le JDK fournit quatre archives CDS dans lib/server : classes.jsa, classes_coh.jsa, classes_nocoops.jsa et classes_nocoops_coh.jsa. Avec le flag, la JVM charge classes_coh.jsa automatiquement :
$ 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
Un cache AOT créé avec le flag est refusé sans le 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
Avec -XX:AOTMode=auto, la valeur par défaut, la JVM continue sans cache et le programme tourne. Avec -XX:AOTMode=on, elle s’arrête avec le code de sortie 1. Si vous changez le flag, reconstruisez aussi le cache AOT.
En résumé
Sur JDK 25, -XX:+UseCompactObjectHeaders est un flag product, à false par défaut. Il réduit l’en-tête de chaque objet de 12 à 8 octets, avec un pointeur de classe sur 22 bits dans le mark word.
Un objet gagne 8 octets ou rien, à cause de l’alignement sur 8 octets. Integer, un objet avec un seul int, String, byte[8] et byte[16] gardent la même taille. L’objet vide, Long, HashMap.Node, byte[12] et une classe à trois champs gagnent 8 octets.
Sur une HashMap<Long, Entry> de cinq millions d’entrées, chaque entrée passe de 88 à 64 octets et la heap vivante de 487 Mo à 373 Mo. Sous charge avec G1 et 2 Go de heap, aucun Full GC n’a eu lieu en mode compact. Sans le flag, il y en a eu un à trois par run.
Le flag a besoin des pointeurs de classe compressés. Sans eux, il se désactive avec un simple avertissement. Il ne dépend pas des oops compressés. Le JDK fournit une archive CDS pour chaque mode, mais un cache AOT créé avec le flag ne sert pas sans lui.
Et après ?
Pour lire les lignes Pause Full et les autres lignes d’un log GC, voir Lire les logs GC. Pour choisir le collecteur et la taille de heap une fois le gain mesuré, voir Régler le GC de la JVM.