Torna al blog

02 / scrittura

L'evoluzione di Java: dalla 1.0 alla 27, e l'header degli oggetti che si è ristretto

Trent'anni di release di Java, e come Project Lilliput ha ridotto l'header degli oggetti di HotSpot da 12 a 8 byte senza toccare una riga di codice applicativo.

Java è arrivato alla versione 27 nel settembre 2026, trent’anni dopo il JDK 1.0, e uno dei cambiamenti più rilevanti di questa release non compare in nessuna riga di codice: ogni oggetto nella JVM ha ora, per impostazione predefinita, un header di 8 byte invece di 12.

Qui si incrociano due storie. Una è l’evoluzione del linguaggio e della piattaforma, release dopo release. L’altra è l’header degli oggetti: che cos’è, perché costava così tanto e come Project Lilliput è riuscito a ridurlo senza rompere la JVM. Ridurre l’header è stato possibile solo perché, nel corso di varie release, la piattaforma ha rimosso o riscritto vecchi componenti che occupavano quei bit.

Il problema

Ogni oggetto nell’heap di HotSpot inizia con un header che il programmatore non dichiara mai, e fino a Java 26 occupava 12 byte nella configurazione predefinita a 64 bit. È lì che la JVM conserva ciò che le serve sapere sull’oggetto, prima dei campi.

Sembra poco, ma gli oggetti Java sono piccoli. Secondo il JEP 450, in molti carichi di lavoro l’oggetto medio è di 32-64 byte, il che significa che oltre il 20% dei dati vivi può essere costituito dai soli header. Su un heap da 10 GB sono circa 2 GB spesi prima del primo campo.

C’è poi l’allineamento: la JVM arrotonda la dimensione di ogni oggetto a un multiplo di 8 byte. Un new Object() usa 12 byte di header più 4 di padding, per un totale di 16 byte per un oggetto senza alcun campo.

Che cos’è l’header degli oggetti

L’header tradizionale ha due parti:

  • Mark word (64 bit): contiene l’identity hash (31 bit, il valore di System.identityHashCode), l’età dell’oggetto per il garbage collector (4 bit) e i bit di stato del lock usati da synchronized (2 bit). Il resto era inutilizzato.
  • Class word (32 o 64 bit): il puntatore ai metadati della classe. Con i compressed class pointer, che sono il default, occupa 32 bit; senza compressione, 64.

In totale sono 96 bit (12 byte) nel caso comune e 128 bit (16 byte) senza compressione. Gli array aggiungono altri 4 byte per la lunghezza.

Il modello mentale: l’header è la contabilità interna della JVM, condivisa da tre sottosistemi che reclamano ciascuno qualche bit. Il garbage collector, il codice di locking e i metadati delle classi leggono e scrivono le stesse parole, ed è per questo che cambiare il layout significa cambiarli tutti e tre.

Contesto del sistema

C4Context
    title Chi legge e scrive l'header degli oggetti
    Person(dev, "Sviluppatore", "Dichiara i campi, mai l'header")
    System_Boundary(jvm, "JVM HotSpot") {
        System(heap, "Heap Java", "Oggetti: header, campi, padding")
        System(gc, "Garbage collector", "Traccia l'età, sposta gli oggetti")
        System(lock, "Locking", "Implementa synchronized")
        System(meta, "Metadati delle classi", "Destinazione del class pointer")
    }
    System_Ext(tools, "JOL, agent, librerie basate su Unsafe", "Ispezionano il layout dall'esterno")
    Rel(dev, heap, "Alloca oggetti")
    Rel(gc, heap, "Legge e aggiorna età e bit di forwarding")
    Rel(lock, heap, "Imposta i bit di tag del lock")
    Rel(heap, meta, "Class pointer in ogni header")
    Rel(tools, heap, "Leggono gli offset dell'header")

La vita di un oggetto nell’header

sequenceDiagram
    participant App as Applicazione
    participant H as Header dell'oggetto
    participant L as Locking
    participant GC as Garbage collector
    Note over App,H: 1. Allocazione
    App->>H: new Long(42)
    H-->>App: Class pointer scritto (22 bit)
    Note over App,H: 2. Identity hash
    App->>H: System.identityHashCode(o)
    H-->>App: Hash memorizzato (31 bit)
    Note over App,L: 3. synchronized
    App->>L: Ingresso nel monitor
    L->>H: Imposta solo i bit di tag (2 bit)
    Note over H,GC: 4. Collection
    GC->>H: Incrementa l'età (4 bit)
    GC->>H: Sposta l'oggetto, class pointer preservato
    GC->>H: Imposta il bit self-forwarded se lo spostamento fallisce

Compact Object Headers: tutto in 64 bit

L’idea di Project Lilliput è semplice da enunciare: spostare il class pointer dentro la mark word, usando i bit rimasti inutilizzati, ed eliminare la class word. L’header scende da 12 a 8 byte.

Per starci, il compressed class pointer è passato da 32 a 22 bit, che consentono comunque circa 4 milioni di classi caricate. L’identity hash ha mantenuto i suoi 31 bit e 4 bit sono stati riservati a Project Valhalla.

Prima, 96 bit (12 byte): una mark word da 64 bit seguita da una class word da 32 bit.

packet-beta
    title Prima: mark word (0-63) e class word (64-95)
    0-26: "inutilizzati (27 bit)"
    27-57: "identity hash (31 bit)"
    58-61: "età GC"
    62-63: "tag"
    64-95: "class pointer (32 bit)"

Dopo, 64 bit (8 byte): il class pointer vive dentro la mark word.

packet-beta
    title Dopo: un'unica parola da 64 bit
    0-21: "class pointer (22 bit)"
    22-52: "identity hash (31 bit)"
    53-56: "Valhalla"
    57-60: "età GC"
    61: "F"
    62-63: "tag"

Layout semplificato dell’header in HotSpot a 64 bit. tag sono i 2 bit di tag del lock, F il bit self-forwarded, Valhalla i 4 bit riservati a Project Valhalla.

I 27 bit inutilizzati della vecchia mark word assorbono il class pointer ridotto, i 4 bit di Valhalla e il bit di self-forwarding; la class word scompare.

La parte difficile era che la vecchia JVM sovrascriveva la mark word in due situazioni, e ora questo cancellerebbe la classe dell’oggetto:

  • Locking: il vecchio schema di synchronized sostituiva la mark word con un puntatore nello stack del thread. La soluzione è stata il lightweight locking, che usa solo i bit di tag, con i monitor tenuti in una tabella separata.
  • Garbage collection: quando spostavano un oggetto, i collector scrivevano il nuovo indirizzo sopra l’header. Ora un bit dedicato marca un oggetto che non è stato possibile spostare (self-forwarded), e la compattazione usa una codifica che preserva il class pointer. ZGC non ha avuto bisogno di modifiche, perché usa già tabelle separate.

La consegna è avvenuta in tre passi, uno per JEP:

JEPVersioneStatoCome si usa
JEP 450Java 24Sperimentale-XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders
JEP 519Java 25 (LTS)Funzionalità di prodotto, disattivata di default-XX:+UseCompactObjectHeaders
JEP 534Java 27Attiva di defaultNiente; per disattivarla, -XX:-UseCompactObjectHeaders

Tra l’esperimento e il default, Oracle ha eseguito l’intera suite di test del JDK con la funzionalità attiva, e Amazon l’ha messa in produzione su centinaia di servizi, la maggior parte con backport su JDK 17 e 21.

Il guadagno in pratica

Il JEP 450 riporta una riduzione tipica dal 10% al 20% dei dati vivi nell’heap, senza alcuna modifica al codice applicativo. Meno memoria per oggetto significa anche più oggetti per linea di cache e meno lavoro per il collector.

I numeri pubblicati nei JEP 519 e 534:

  • SPECjbb2015: 22% di heap in meno e 8% di tempo CPU in meno.
  • In un’altra configurazione dello stesso benchmark: 15% di garbage collection in meno, sia con G1 sia con Parallel.
  • Un benchmark di parsing JSON altamente parallelo: 10% di tempo di esecuzione in meno.

Non tutti gli oggetti si restringono

A causa dell’allineamento a 8 byte, il guadagno dipende dalla dimensione dei campi. I valori qui sotto derivano dall’aritmetica header + campi + allineamento nella configurazione predefinita a 64 bit; JOL mostra il layout reale su qualsiasi JVM.

OggettoHeader da 12 byteHeader da 8 byte
new Object()16 byte8 byte
Long24 byte16 byte
Integer16 byte16 byte
String (senza l’array interno)24 byte24 byte
Header di un array16 byte12 byte

Un Integer resta a 16 byte perché 8 di header più 4 per l’int fanno 12, che l’allineamento arrotonda a 16. Il guadagno reale di un’applicazione è la media su tutti i suoi oggetti.

Come misurare

JOL (Java Object Layout), uno strumento dello stesso OpenJDK, mostra il layout di qualsiasi classe. Conviene usare una versione recente e confrontare con la funzionalità attiva e disattiva:

java -XX:+UseCompactObjectHeaders -jar jol-cli.jar internals java.lang.Long
java -XX:-UseCompactObjectHeaders -jar jol-cli.jar internals java.lang.Long

Per l’effetto sull’intera applicazione, si confrontano l’occupazione dell’heap dopo un full GC e la frequenza delle collection nei log (-Xlog:gc) a parità di carico.

Limiti e avvertenze

  • Limite di classi: un class pointer a 22 bit consente circa 4 milioni di classi. Basta per praticamente qualsiasi sistema, ma chi genera classi dinamicamente su larga scala dovrebbe tenerlo d’occhio.
  • I compressed class pointer sono obbligatori: l’header compatto dipende da essi.
  • Heap giganti: al di fuori di ZGC, la codifica usata durante la compattazione indirizza fino a 8 TB di heap.
  • Codice che presuppone il layout: le librerie che usano sun.misc.Unsafe con offset fissi, o gli agent che leggono direttamente l’header, vanno aggiornati.
  • Nessun bit libero: l’header è pieno. Il JEP 534 osserva che, se nuove funzionalità avranno bisogno di bit, il class pointer e l’hash possono ridursi ulteriormente, con tecniche già prototipate in Lilliput.

Su Java 25 la funzionalità si può attivare oggi con un flag e misurare. Sulla 27 è già attiva.

Storia: trent’anni di release

Le origini (1996-2002): dalla 1.0 alla 1.4

Java è nato in Sun Microsystems con la promessa del «write once, run anywhere»: codice compilato in bytecode ed eseguito da una macchina virtuale. I primi anni sono serviti a costruire la libreria standard e la JVM stessa.

VersioneRilascioChe cosa ha portato
JDK 1.0gen 1996Linguaggio, JVM, applet, AWT
JDK 1.1feb 1997Inner class, JDBC, RMI, reflection, JavaBeans
J2SE 1.2dic 1998Collections Framework, Swing, compilatore JIT
J2SE 1.3mag 2000HotSpot come JVM predefinita, JNDI, proxy dinamici
J2SE 1.4feb 2002assert, NIO, espressioni regolari, API di logging, exception chaining

Due tappe di questo periodo contano ancora oggi. Il Collections Framework (1.2) ha reso List, Map e Set il vocabolario comune di tutto il codice Java. E HotSpot (1.3) è la stessa JVM il cui header degli oggetti è analizzato qui sopra.

Il linguaggio matura (2004-2014): dalla 5 alla 8

Quattro release in dieci anni, ma due di esse, la 5 e la 8, hanno cambiato il linguaggio più di tutte le precedenti messe insieme.

VersioneRilascioChe cosa ha portato
Java 5set 2004Generics, annotazioni, enum, autoboxing, varargs, for-each, java.util.concurrent
Java 6dic 2006API di scripting, API del compilatore, miglioramenti di prestazioni della JVM
Java 7lug 2011try-with-resources, operatore diamond, String nello switch, multi-catch, NIO.2, fork/join, invokedynamic
Java 8mar 2014Lambda, Stream, java.time, metodi di default nelle interfacce, Optional, fine del PermGen

Java 5 ha dato al linguaggio la type safety nelle collezioni e una libreria di concorrenza di alto livello. Java 7 è stata la prima release sotto Oracle, che ha acquisito Sun nel 2010, e l’invokedynamic che ha introdotto è diventato la base delle lambda tre anni dopo.

Java 8 ha portato lo stile funzionale nel codice di tutti i giorni:

List<String> names = people.stream()
    .filter(p -> p.getAge() >= 18)
    .map(Person::getName)
    .sorted()
    .collect(Collectors.toList());

È diventata la release più longeva nella storia della piattaforma, e molti sistemi enterprise ci girano ancora sopra.

La nuova cadenza (2017-2021): dalla 9 alla 17

A partire da Java 9 la piattaforma ha iniziato a pubblicare una release ogni sei mesi, a marzo e a settembre. Le funzionalità più grandi arrivano prima come preview, raccolgono feedback e solo dopo vengono finalizzate. Alcune release sono LTS (long-term support): 11, 17, 21 e 25.

VersioneRilascioChe cosa ha portato
Java 9set 2017Sistema di moduli (JPMS), JShell, G1 come collector predefinito, compact strings
Java 10mar 2018var per le variabili locali
Java 11 (LTS)set 2018Nuovo HttpClient, avvio diretto di un file .java, ZGC sperimentale, rimozione dei moduli Java EE e CORBA
Java 12mar 2019Espressioni switch (preview), Shenandoah sperimentale
Java 13set 2019Text block (preview)
Java 14mar 2020Espressioni switch definitive, record e pattern matching per instanceof (preview), messaggi utili per le NullPointerException
Java 15set 2020Text block definitivi, sealed class (preview), ZGC e Shenandoah pronti per la produzione, biased locking deprecato
Java 16mar 2021Record e pattern matching per instanceof definitivi, incapsulamento forte degli internals del JDK
Java 17 (LTS)set 2021Sealed class definitive, port macOS/AArch64

Record, sealed class e pattern matching insieme hanno cambiato il modo di modellare i dati:

sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}

Nella tabella ci sono due voci discrete da notare. Le compact strings di Java 9 erano già un risparmio di memoria invisibile al programmatore, nello stesso spirito dell’header compatto. E la deprecazione del biased locking in Java 15 ha liberato bit nell’header degli oggetti, uno dei passi che hanno aperto la strada alla riduzione.

Java moderno (2022-2026): dalla 18 alla 27

Negli ultimi quattro anni i grandi progetti di lungo corso di OpenJDK hanno iniziato a consegnare: Loom (virtual thread), Amber (pattern matching e sintassi più snella), Panama (interoperabilità con il codice nativo) e Lilliput (header compatti).

VersioneRilascioChe cosa ha portato
Java 18mar 2022UTF-8 come charset predefinito, simple web server
Java 19set 2022Virtual thread e record pattern (preview)
Java 20mar 2023Ulteriori cicli di preview, scoped values in incubazione
Java 21 (LTS)set 2023Virtual thread definitivi, record pattern, pattern matching per switch, sequenced collections, ZGC generazionale
Java 22mar 2024API Foreign Function & Memory (FFM) definitiva, variabili e pattern senza nome (_)
Java 23set 2024Commenti di documentazione in Markdown, ZGC generazionale di default
Java 24mar 2025Stream gatherers, class-file API, virtual thread senza pinning su synchronized, header compatti sperimentali (JEP 450)
Java 25 (LTS)set 2025Scoped values definitivi, flexible constructor bodies, compact source files e main di istanza, header compatti come funzionalità di prodotto (JEP 519), Shenandoah generazionale
Java 26mar 2026HTTP/3 in HttpClient, rimozione dell’API Applet, AOT object caching con qualsiasi GC, «prepare to make final mean final»
Java 27set 2026Header compatti di default (JEP 534), G1 come collector predefinito in tutti gli ambienti, scambio di chiavi ibrido post-quantum per TLS 1.3

I virtual thread di Java 21 permettono di scrivere semplice codice bloccante che scala a centinaia di migliaia di task concorrenti:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    orders.forEach(o -> executor.submit(() -> process(o)));
}

E il pattern matching per switch chiude il cerchio aperto da record e sealed class:

double area = switch (shape) {
    case Circle c -> Math.PI * c.radius() * c.radius();
    case Rectangle r -> r.width() * r.height();
};

Java 25 ha semplificato perfino il primo programma di chi sta imparando:

void main() {
    IO.println("Hello, world!");
}

Cosa rende buona un’ottimizzazione di piattaforma

  1. Invisibile al programmatore. Nessuna API, nessuna annotazione, nessuna modifica al codice: si aggiorna la JVM e il guadagno c’è.
  2. Prima si salda il vecchio debito. Il biased locking doveva sparire, e locking e collection dovevano smettere di sovrascrivere la mark word, prima che i bit potessero essere riutilizzati.
  3. Consegnata per gradi. Sperimentale nella 24, funzionalità di prodotto nella 25, default nella 27, ogni passo sostenuto da più evidenze del precedente.
  4. Provata su carichi reali. L’intera suite di test del JDK e centinaia di servizi in produzione sono venuti prima del default.
  5. Resta un interruttore. -XX:-UseCompactObjectHeaders ripristina il vecchio layout per il codice che ancora lo presuppone.

Dove va da qui

Java 27 è la release corrente e non è LTS; chi ha bisogno di supporto a lungo termine è sulla 25. Ancora in preview nella 27: lazy constants, structured concurrency e tipi primitivi nei pattern. Nell’header non restano bit liberi, quindi le prossime funzionalità che ne avranno bisogno, Valhalla per prima, li prenderanno dai bit riservati o da un class pointer e un hash che si riducono ancora.

In trent’anni Java si è evoluto su due fronti contemporaneamente. Il linguaggio ha acquisito generics, lambda, record e pattern matching, diventando più espressivo a ogni ciclo. La piattaforma sottostante ha continuato a diventare più efficiente senza chiedere nulla in cambio al programmatore. L’header compatto è il miglior esempio di questo secondo fronte: anni di lavoro in Project Lilliput, tre JEP e modifiche profonde a locking e garbage collection, perché il risultato finale fosse questo: aggiornare la JVM e usare dal 10% al 20% di memoria in meno.

Approfondimenti


Ivens Signorini è un Senior Backend Engineer specializzato in sistemi distribuiti, infrastruttura AI e API ad alte prestazioni. Lavora principalmente in Go e TypeScript, costruendo sistemi che operano su larga scala. I suoi interessi tecnici includono il design dei protocolli, i pattern di concorrenza e l’architettura delle applicazioni AI-native. Scrive su signorini.cloud.

Tweaks

Accent
Theme
Headline font