Block Image

Spring Boot è una delle piattaforme più popolari per lo sviluppo di applicazioni Java, ma uno dei suoi principali svantaggi è il tempo di avvio. Project Leyden è un progetto open source che punta a ridurlo senza rinunciare al modello di esecuzione della JVM.

In questo articolo vedremo brevemente le differenze tra GraalVM e Project Leyden, per poi passare alla pratica e capire come usare Project Leyden per migliorare i tempi di avvio di un'applicazione Spring Boot che integra servizi AWS.

Metriche JVM

Startup Time
È il tempo necessario per completare il primo compito (task) dell'applicazione. Include il caricamento delle classi, il linking e l'esecuzione dei blocchi di inizializzazione statica (). In Java tradizionale, questo tempo è spesso elevato perché il classloader deve caricare, verificare e inizializzare molte classi prima che l'applicazione possa rispondere alla prima richiesta.

Time to Peak
Java non raggiunge la velocità massima istantaneamente. Il Peak Performance è il livello massimo di throughput che l'app può sostenere. Il "Time to Peak" è il tempo necessario affinché la maggior parte del codice critico sia stata compilata in Tier 4, il livello di ottimizzazione massima del compilatore C2.

Nota: Spesso si definisce il picco come il raggiungimento del 95% delle prestazioni massime.

Speculazione e De-ottimizzazione
Questa è la vera "magia" della JVM: il compilatore JIT fa delle scommesse (speculazioni). Ad esempio, se vede che un'interfaccia ha una sola implementazione durante il profiling, compila il codice assumendo che sarà sempre così. Se in seguito carichi una nuova classe che rompe questa assunzione, la JVM esegue una de-ottimizzazione: scarta il codice veloce e torna all'interprete per ricalcolare la strategia.

Funzionamento standard di una app JVM

Quando avviamo un'applicazione Java, inizia un processo a più stadi chiamato Tiered Compilation:

  • T0 - Interprete (Lento): La JVM legge il bytecode Java, istruzione per istruzione, e lo traduce "al volo" in istruzioni per la CPU. È la fase più lenta, ma permette all'app di partire immediatamente senza aspettare compilazioni.

  • T1-T3 - C1 JIT (Veloce): Appena un metodo diventa "caldo", il compilatore C1 lo trasforma in codice nativo con ottimizzazioni leggere. Mentre esegue questo codice, la JVM osserva contemporaneamente quali metodi vengono chiamati più spesso e quali tipi di dati fluiscono attraverso il codice: queste osservazioni sono chiamate "profili" e serviranno al passo successivo.

  • T4 - C2 JIT (Massima velocità): Quando il codice è molto usato, interviene il compilatore C2. Usa i dati di profiling raccolti da C1 per applicare ottimizzazioni estreme (come l'inlining o il loop unrolling).

Questo processo non è a senso unico: la JVM può tornare a stadi precedenti se le sue assunzioni si rivelano errate (speculazione e de-ottimizzazione).

AOT con GraalVM

GraalVM è una tecnologia che consente di compilare un'applicazione Java in un eseguibile nativo ahead-of-time (AOT), eliminando la necessità di una JVM a runtime e riducendo drasticamente i tempi di avvio.

Il cuore di questa tecnologia è Native Image: invece di compilare il codice "Just-In-Time" mentre l'app gira, GraalVM esegue tutto il lavoro pesante durante la fase di build:

  • Scansione: GraalVM analizza tutto il codice, le dipendenze e il codice della JDK stessa.
  • Analisi di raggiungibilità: Identifica solo il codice che viene effettivamente utilizzato e scarta tutto il resto.
  • Risultato: Viene generato un eseguibile specifico per il sistema operativo, che non richiede una JVM esterna per girare.

La filosofia alla base di questo approccio è la "Closed World Assumption": tutto il codice raggiungibile deve essere noto a compile-time. Questo significa che tutte le classi, i metodi e le risorse utilizzati dall'applicazione devono essere specificati esplicitamente, altrimenti potrebbero non essere inclusi nell'immagine nativa, causando errori a runtime.

Di conseguenza, funzionalità dinamiche come reflection, dynamic proxies e class loading dinamico non funzionano automaticamente e richiedono configurazione esplicita (ad esempio tramite il file reflection-config.json). Questo rappresenta una limitazione concreta per molte applicazioni Java che ne fanno uso intensivo, come quelle basate su Spring Boot.

Infine, spostare tutto il lavoro al momento della build comporta tempi di compilazione significativamente più lunghi rispetto all'approccio JIT. Questi compromessi sono però accettabili in contesti dove startup istantaneo e footprint minimo sono prioritari, come microservizi e funzioni serverless.

AOT con Project Leyden

Il problema della JVM standard è che tutto il lavoro di caricamento, profiling e compilazione ricomincia da zero a ogni riavvio. A differenza di GraalVM, che risolve questo problema eliminando la JVM a runtime, Project Leyden adotta un approccio diverso: ottimizzare la JVM stessa, preservando tutto il dinamismo che la rende potente. Se GraalVM si basa sulla "Closed World Assumption", Project Leyden segue invece una logica di Open World: l'applicazione continua a girare dentro una JVM completa, mantenendo caratteristiche come loading dinamico, e reflection.

Per farlo, Leyden introduce il concetto di Training Run: un'esecuzione preliminare dell'applicazione in cui la JVM raccoglie dati di profiling e compila il codice critico. Il risultato viene salvato in un'AOT Cache: un archivio che contiene classi pre-caricate, metodi già compilati in codice nativo e lo stato dell'heap pre-inizializzato. Ad ogni avvio successivo, la JVM attinge direttamente da questa cache invece di ricominciare da zero. Il processo si articola in tre fasi:

  • Spostare il calcolo (Shifting): Il profiling e la compilazione JIT vengono eseguiti durante il Training Run, invece che a ogni avvio.
  • Catturare lo stato: Il codice già compilato e le classi già linkate vengono salvati nell'AOT Cache.
  • Deployment fulmineo: Al riavvio (Deployment Run), l'app carica direttamente il codice ottimizzato dall'AOT Cache, riducendo drasticamente il Time to Peak.

Possiamo vedere la JVM standard come un atleta che deve fare riscaldamento ogni mattina, Leyden permette all'atleta di "salvare" il riscaldamento del giorno prima e iniziare subito a correre alla massima velocità.

Nota: Le funzionalità descritte sono disponibili a partire da Java 24 (JEP 483). L'AOT Cache si basa su CDS (Class Data Sharing), una tecnologia integrata nel JDK che consente di pre-caricare i dati delle classi evitando di ricaricarle ad ogni avvio, riducendo sia il tempo di caricamento che il consumo di memoria. Il CDS nella sua forma base è presente dal JDK 5, ma è stato esteso anche alle classi dell'applicazione (non solo del JDK) a partire da Java 10. Project Leyden è ancora in sviluppo attivo e le sue funzionalità verranno ulteriormente ampliate nelle versioni future.
Project Leyden in pratica

Per testare Project Leyden con un'applicazione Spring Boot useremo uno dei miei progetti GitHub (https://github.com/vincenzo-racca/localstack), in cui Spring Boot si integra con servizi AWS come SQS e DynamoDB.

La versione utilizzata di Spring Boot è la 3.5.13; la macchina usata per i test è una macchina virtuale Ubuntu Server 25 con 1 vCPU e 4 GB di RAM.

Per ogni test è stata generata l'immagine container dell'applicazione con il comando: ./mvnw clean spring-boot:build-image

Successivamente è stato eseguito docker compose up -d per avviare i container di Localstack (servizi AWS in locale) e l'applicazione Spring Boot containerizzata.

Primo test: JVM standard con Java 21

Applicazione avviata in 4,136 secondi:

Block Image

Secondo test: CDS con Java 21

Per abilitare CDS, basta aggiungere questa configurazione al pom.xml:

<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <configuration>
    <image>
      <env>
        <BP_JVM_CDS_ENABLED>true</BP_JVM_CDS_ENABLED>
      </env>
    </image>
  </configuration>
</plugin>

Inoltre, dato che la generazione dell'immagine include automaticamente la fase di training, è fondamentale che l'app riesca ad avviarsi in ambiente containerizzato; per questo è stato aggiunto il prefisso optional: alla property nel file application.properties:

spring.config.import=optional:aws-parameterstore:/config/localstack/?prefix=localstack.

Applicazione avviata in 1,119 secondi:

Block Image

Terzo test: Project Leyden con Java 25

Per beneficiare di Project Leyden, è necessario aggiornare Java alla versione 24 o successiva (in questo caso ho usato la 25, attuale LTS). Di seguito i benefici ottenuti con Project Leyden:

FunzionalitàJava 21 + CDSJava 24+ + Leyden
Classi pre-caricate
Codice JIT salvato
Heap pre-inizializzato
Riduzione startup
Riduzione time to peak

Applicazione avviata in 0,776 secondi:

Block Image

Conclusioni

Nei test di questo articolo, il salto più interessante è già visibile con CDS: si passa da 4,136 secondi di startup con la JVM standard a 1,119 secondi su Java 21. Con Project Leyden su Java 25 si scende ulteriormente a 0,776 secondi, ottenendo quindi un miglioramento molto concreto senza dover riscrivere l'applicazione e senza rinunciare al dinamismo tipico della JVM.

In pratica, GraalVM resta la scelta più aggressiva quando l'obiettivo principale è minimizzare startup e footprint, ma Project Leyden si presenta come l'opzione più pragmatica per molte applicazioni Spring Boot: zero cambiamenti, integrazione naturale con il runtime Java e vantaggi sia sul tempo di avvio sia sul time to peak. È una tecnologia ancora in evoluzione, ma i risultati mostrano già chiaramente che vale la pena iniziare a sperimentarla.

Startup comparison chart

Riferimenti

Altri articoli su Spring: Spring.

Mio libro Spring Boot 3 API Mastery su Amazon: https://amzn.to/4bU4BNS