Salta al contenuto principale

Java 21 per Spring Boot developer: Virtual Threads, Pattern Matching, Records e String Templates

Java 21 per Spring Boot: virtual thread misurati, switch su tipi sigillati, record e Sequenced Collections, con listati eseguiti su JDK 21.0.13.

Cosa ne pensi di questo articolo?

Java 21 per Spring Boot developer: Virtual Threads, Pattern Matching, Records e String Templates
Clean ArchitectureFoto:RDNE Stock projectPexels

In Breve

Java 21 per Spring Boot: virtual thread misurati, switch su tipi sigillati, record e Sequenced Collections, con listati eseguiti su JDK 21.0.13.

Hai un servizio Spring Boot che passa quasi tutto il tempo ad aspettare: un database, un servizio remoto, un client lento. Con i thread di piattaforma ogni richiesta in attesa tiene occupato un thread del sistema operativo, e il tetto di thread del server diventa il tetto delle richieste concorrenti. Java 21 offre un'altra strada, i virtual thread, e porta a versione definitiva altre funzioni che cambiano il modo di scrivere il dominio: pattern matching per switch, record patterns, sealed classes e Sequenced Collections. La risposta breve è che i virtual thread aiutano solo se il carico è dominato dall'attesa e solo se li abiliti, mentre le altre funzioni ti servono subito perché rendono il modello di dominio più corto e più sicuro. In questo articolo ogni listato è stato compilato ed eseguito su OpenJDK 21.0.13.

Che cosa è definitivo in Java 21 e che cosa no

Java 21 è una versione LTS, cioè con supporto esteso: le date esatte dipendono dal fornitore della distribuzione (Oracle, Temurin, Corretto) e vanno lette sul suo sito. Lo stato delle singole funzioni, invece, è scritto nei JEP, le proposte di miglioramento di OpenJDK, e si può controllare. La tabella riporta quelle che usiamo, con la versione in cui sono diventate definitive.

FunzioneJEPStato in Java 21
Virtual thread444definitivi (release 21)
Pattern matching per switch441definitivo (release 21)
Record patterns440definitivi (release 21)
Sequenced Collections431definitive (release 21)
Sealed classes409definitive dalla release 17
Record395definitivi dalla release 16
String Templates430solo anteprima in 21 (--enable-preview), poi ritirate

La lettura che conta è l'ultima riga: le string templates non sono una funzione su cui costruire codice di produzione. Dopo una seconda anteprima in Java 22 (JEP 459), la terza (JEP 465) risulta ritirata (Withdrawn) sulla pagina del progetto: se in un articolo trovi la sintassi STR."...", sai che è codice che non compilerà senza anteprima. Per formattare testo usa i text block con formatted(), che vediamo più sotto. Spring Boot 3.4 richiede almeno Java 17, mentre i virtual thread richiedono Java 21 e Spring Boot 3.2 o successivo.

Il passaggio da Java 17 a 21 è di solito poco costoso, ma non gratuito. Controlla le librerie che manipolano il bytecode o usano la reflection (Lombok, per esempio, aggiunge il supporto iniziale a JDK 21 con la versione 1.18.30, come riporta il suo changelog), le versioni di Mockito e ByteBuddy e gli agenti come JaCoCo. Un build che passa su 17 e si rompe su 21 quasi sempre si rompe lì.

Virtual thread: aspettare costa poco

Il problema: il thread resta occupato mentre aspetta

Tomcat, il server integrato in Spring Boot, per impostazione predefinita gestisce le richieste con un pool di al massimo 200 thread (proprietà server.tomcat.threads.max, valore predefinito 200 secondo l'appendice delle proprietà di Spring Boot, che precisa che non ha effetto se i virtual thread sono abilitati). Se ogni richiesta resta 100 ms in attesa di una risposta, il thread rimane agganciato per tutto quel tempo senza fare nulla, e il duecentunesimo cliente aspetta in coda. Un thread di piattaforma è un thread del sistema operativo: costa memoria per lo stack e tempo per la creazione, quindi non puoi averne centomila.

Un virtual thread è un thread gestito dalla JVM. Quando si blocca su una chiamata di I/O, la JVM lo stacca dal thread del sistema operativo che lo ospitava (il carrier) e lo riprende quando la risposta è pronta; nel frattempo il carrier serve un altro virtual thread. La documentazione di riferimento è il JEP 444.

Una misura che puoi rifare

Il listato lancia 10.000 task che dormono 100 ms, simulando l'attesa di una risposta. Prima li affida a un pool fisso di 200 thread (il tetto predefinito di Tomcat), poi a un executor che crea un virtual thread per ogni task. Il blocco try (ex) sfrutta il fatto che ExecutorService è AutoCloseable da Java 19: alla chiusura attende la fine di tutti i task, così il tempo misurato è quello vero. Con il pool fisso il conto si fa a mente: 10.000 task divisi per 200 thread sono 50 ondate da 100 ms, cioè circa 5 secondi, ed è ciò che esce.


import java.time.Duration;
import java.util.concurrent.*;
import java.util.stream.IntStream;

public class Vt {
    static long run(ExecutorService ex, int tasks) throws Exception {
        long t0 = System.nanoTime();
        try (ex) {
            IntStream.range(0, tasks).forEach(i -> ex.submit(() -> {
                Thread.sleep(Duration.ofMillis(100)); // simula 100 ms di attesa I/O
                return i;
            }));
        } // close() attende la fine di tutti i task
        return (System.nanoTime() - t0) / 1_000_000;
    }

    public static void main(String[] a) throws Exception {
        int tasks = 10_000;
        System.out.println("pool fisso da 200 thread:  " + run(Executors.newFixedThreadPool(200), tasks) + " ms");
        System.out.println("un virtual thread per task: " + run(Executors.newVirtualThreadPerTaskExecutor(), tasks) + " ms");
    }
}

// pool fisso da 200 thread:  5024 ms
// un virtual thread per task: 144 ms
// (JDK 21.0.13, 12 core, cinque esecuzioni: pool 5024-5035 ms, virtual 141-152 ms)

Il risultato dice una cosa precisa e una che non dice. Dice che, quando il lavoro è attesa, i virtual thread eliminano il tetto dei 200: il tempo scende da circa 5 secondi a circa 150 ms, vicino ai 100 ms dell'attesa singola. Non dice che il tuo servizio diventerà 35 volte più veloce: qui il carico è sleep puro, senza CPU, senza database con un pool di connessioni limitato e senza serializzazione. Il rapporto dipende dal tuo rapporto fra attesa e calcolo. I tempi sono stati misurati su una macchina a 12 core, cinque esecuzioni, e variano da una macchina e da un'esecuzione all'altra.

Come si abilitano in Spring Boot

Spring Boot non usa i virtual thread per impostazione predefinita, nemmeno su Java 21. Dalla versione 3.2 si abilitano con una proprietà: spring.threads.virtual.enabled=true. Con la proprietà attiva Tomcat serve ogni richiesta su un virtual thread, e lo stesso vale per gli executor di @Async e dello scheduling. La documentazione di Spring Boot avverte che in alcuni casi la velocità può calare per i pinned virtual threads, e consiglia Java 24 o successivo per l'esperienza migliore.

Il pinning è il costo nascosto della versione 21: un virtual thread dentro un blocco synchronized che esegue I/O bloccante non libera il carrier, e se i carrier sono tutti bloccati così il vantaggio sparisce. Il JEP 491 ha rimosso la limitazione in Java 24. Su Java 21 si diagnostica con -Djdk.tracePinnedThreads=full e si evita sostituendo synchronized con ReentrantLock nelle sezioni che fanno I/O.

  • Carico dominato dall'I/O con molte richieste concorrenti: è il caso in cui i virtual thread danno il beneficio, senza riscrivere il codice in stile reattivo.
  • Carico dominato dalla CPU: nessun beneficio, perché un virtual thread non calcola più in fretta.
  • Pool di connessioni al database: resta un limite. Con 20 connessioni, anche 10.000 virtual thread eseguono al massimo 20 query alla volta; l'attesa si sposta sul pool.
  • Il metodo: test di carico con e senza la proprietà, sullo stesso codice e sulla stessa macchina, confrontando throughput e latenza p99.

L'elenco è in ordine di importanza decrescente per la decisione, ma le voci non sono alternative: sono le quattro domande da porsi prima di attivare la proprietà. Se la prima risposta è no (il carico non è attesa) o la terza è «il pool è piccolo e il database è già il collo di bottiglia», i virtual thread non cambiano il risultato. L'ultima voce è l'unica che produce un numero, e quel numero vale solo per il tuo servizio.

Pattern matching e sealed classes: il compilatore controlla i casi

Immagina un'API che riceve tre tipi di credenziali: un utente con email e piano, un token di API con un ambito, un token OAuth con provider e soggetto. Prima di Java 21 il codice che ne ricava il livello di accesso era una catena di instanceof con cast e un else finale che lanciava un'eccezione, e nessuno ti avvisava se aggiungevi un quarto tipo e dimenticavi un ramo. Con un'interfaccia sealed, il compilatore conosce l'elenco completo dei tipi ammessi (permits) e può verificare che lo switch li copra tutti.

Il listato mostra due metodi. accessLevel usa il pattern matching con le guardie when: i tre case User sono in ordine dal più specifico al più generale, perché il compilatore rifiuta un caso che sarebbe già coperto da uno precedente. subject usa i record patterns, che scompongono il record direttamente nel case. In Java 21 vanno dichiarati tutti i componenti (var email, var premium, var admin): il pattern anonimo _ arriva con il JEP 456 in Java 22. Non c'è default, ed è voluto: un default farebbe sparire il controllo di completezza.


public class Cred {
    sealed interface Credential permits User, ApiToken, OAuthToken {}
    record User(String email, boolean isPremium, boolean isAdmin) implements Credential {}
    record ApiToken(String scope, long expiresAt) implements Credential {}
    record OAuthToken(String provider, String subject) implements Credential {}

    static String accessLevel(Credential c) {
        return switch (c) {
            case User u when u.isPremium() -> "premium";
            case User u when u.isAdmin() -> "admin";
            case User u -> "free";
            case ApiToken t -> t.scope();
            case OAuthToken o -> "oauth-" + o.provider();
        };
    }

    static String subject(Credential c) {
        return switch (c) {
            case User(var email, var premium, var admin) -> email;
            case ApiToken(var scope, var expiresAt) -> "token-" + scope;
            case OAuthToken(var provider, var subject) -> subject;
        };
    }

    public static void main(String[] args) {
        Credential[] all = {
            new User("a@example.com", true, false),
            new User("b@example.com", false, true),
            new User("c@example.com", false, false),
            new ApiToken("read:articles", 1_800_000_000L),
            new OAuthToken("github", "octocat")
        };
        for (Credential c : all) System.out.println(accessLevel(c) + " | " + subject(c));
    }
}

// premium | a@example.com
// admin | b@example.com
// free | c@example.com
// read:articles | token-read:articles
// oauth-github | octocat

Il valore vero di questo codice si vede quando lo si rompe. Se togli il ramo OAuthToken da accessLevel, non succede nulla a runtime in un caso raro e difficile da testare: il file smette di compilare. Questo è l'errore che emette javac 21 su quella versione:


// javac Cred2.java  (Cred.java senza il ramo OAuthToken in accessLevel)

// $ javac Cred2.java
// Cred2.java:8: error: the switch expression does not cover all possible input values
//         return switch (c) {
//                ^
// 1 error

La differenza con una switch su un'interfaccia non sigillata è tutta qui: lì il compilatore deve fidarsi di un default, e il quarto tipo aggiunto fra sei mesi passa in silenzio dal default. Con i tipi sigillati, aggiungere permits un nuovo tipo rompe la compilazione in ogni switch da aggiornare, e quella rottura è il modo in cui il compilatore ti dice dove devi guardare. I riferimenti sono JEP 441 (switch), JEP 440 (record patterns) e JEP 409 (sealed classes).

Record: value object senza codice di servizio

Un value object come Email o Money è un valore, non un'entità: due email con lo stesso testo sono la stessa email. Scriverlo a mano significa campo finale, costruttore, getter, equals, hashCode e toString, circa trenta righe per un solo campo. Un record genera tutto questo dalla sola intestazione (JEP 395), e il compact constructor (senza lista di parametri) è il posto dove mettere la validazione: gira prima di assegnare i campi, quindi un Email non valido non esiste mai.


import java.math.BigDecimal;
import java.util.*;

public class Rec {
    public record Email(String value) {
        public Email {
            if (!value.contains("@")) throw new IllegalArgumentException("Invalid email: " + value);
        }
    }
    record Money(BigDecimal amount, Currency currency) {}

    public static void main(String[] args) {
        Email e = new Email("user@example.com");
        System.out.println(e);
        System.out.println(e.value());
        System.out.println(e.equals(new Email("user@example.com")));
        try { new Email("nessuna-chiocciola"); } catch (IllegalArgumentException ex) { System.out.println(ex.getMessage()); }
        Money m1 = new Money(BigDecimal.TEN, Currency.getInstance("EUR"));
        Money m2 = new Money(BigDecimal.TEN, Currency.getInstance("EUR"));
        System.out.println(m1.equals(m2) + " " + (m1.hashCode() == m2.hashCode()));
        Set<Money> set = new HashSet<>(List.of(m1));
        System.out.println(set.contains(m2));
        // BigDecimal: 10 e 10.00 non sono uguali per equals
        System.out.println(new Money(new BigDecimal("10"), Currency.getInstance("EUR")).equals(new Money(new BigDecimal("10.00"), Currency.getInstance("EUR"))));
    }
}

// Email[value=user@example.com]
// user@example.com
// true
// Invalid email: nessuna-chiocciola
// true true
// true
// false

Le ultime due righe dell'uscita contengono la trappola che il tutorial tipico salta. Money(BigDecimal.TEN, ...) e un altro identico sono uguali e hanno lo stesso hash, quindi funzionano in un HashSet. Ma new BigDecimal("10") e new BigDecimal("10.00") non sono uguali per equals, perché hanno scala diversa, e il record eredita questo comportamento: due importi che per un contabile sono lo stesso valore risultano diversi. Se il tuo dominio confronta importi, normalizza la scala nel compact constructor (con setScale) oppure confronta con compareTo.

Un record è final e immutabile per costruzione, ma l'immutabilità è superficiale: se un componente è una List modificabile, il record non la protegge. Nel compact constructor copiala con List.copyOf. Un record con più di cinque o sei componenti è di solito il segnale che dentro ci sono due concetti, da separare in record annidati. Rispetto a Lombok @Value, il record è una funzione del linguaggio e non un annotation processor: per i value object nuovi è la scelta più semplice, mentre Lombok resta utile dove ti servono builder o logging già diffusi nel codice.

Sequenced Collections: primo, ultimo, rovescio

Prima di Java 21 prendere l'ultimo elemento di una List richiedeva list.get(list.size() - 1), e l'ultimo di un LinkedHashSet richiedeva di iterare fino in fondo. Il JEP 431 introduce le interfacce SequencedCollection, SequencedSet e SequencedMap con un'API uniforme: getFirst, getLast, addFirst, reversed. La vista reversed() non copia: è un'altra prospettiva sulla stessa collezione.


import java.util.*;

public class Seq {
    public static void main(String[] args) {
        SequencedCollection<String> articoli = new ArrayList<>(List.of("primo", "secondo", "terzo"));
        System.out.println(articoli.getFirst() + " " + articoli.getLast());
        System.out.println(articoli.reversed());
        articoli.addFirst("zero");
        System.out.println(articoli);
        SequencedMap<Long, String> timeline = new LinkedHashMap<>();
        timeline.put(1L, "a1"); timeline.put(2L, "a2");
        System.out.println(timeline.firstEntry() + " " + timeline.lastEntry());
        System.out.println(timeline.reversed());
        // un HashSet non e' sequenced: non compila
        String testo = """
                Article ID: %d
                Title: %s
                """.formatted(7, "Titolo");
        System.out.print(testo);
    }
}

// primo terzo
// [terzo, secondo, primo]
// [zero, primo, secondo, terzo]
// 1=a1 2=a2
// {2=a2, 1=a1}
// Article ID: 7
// Title: Titolo

Nell'uscita vedi che reversed() restituisce l'ordine inverso e che firstEntry e lastEntry sulla mappa danno le coppie estreme. L'ultima parte del listato mostra anche l'alternativa corretta alle string templates: un text block con formatted() (Java 15), che non richiede anteprima. Il punto dove ci si fa male è un altro: HashSet e HashMap non sono sequenced, perché non garantiscono un ordine, e il compilatore lo rifiuta. Questo è l'errore reale con cui si scopre di aver scelto la struttura sbagliata per una timeline:


import java.util.*;

public class Bad {
    void f() {
        SequencedCollection<String> s = new HashSet<String>();
    }
}

// Bad.java:5: error: incompatible types: HashSet<String> cannot be converted to SequencedCollection<String>
//         SequencedCollection<String> s = new HashSet<String>();
//                                         ^
// 1 error

Per una timeline di articoli la scelta giusta è quindi LinkedHashMap o ArrayList, mai un HashMap con la speranza che l'ordine di inserimento si conservi. Se il tuo repository restituisce una lista ordinata dalla query (ORDER BY published_at DESC), non devi trasformarla in una LinkedList per ottenere getFirst: una List è già una SequencedCollection in Java 21.

Cosa fa davvero il backend di questo sito

Il backend di questo sito compila con <release>21</release> (letto nel pom.xml) e non imposta spring.threads.virtual.enabled: Tomcat gira su thread di piattaforma. I virtual thread sono usati in un punto solo, il controller DatasetStreamController che trasmette in streaming il risultato di una query sui dataset come server-sent events. È un esempio di adozione mirata, con i suoi costi. Il listato che segue è un estratto semplificato del sorgente, non eseguibile da solo: dipende da Spring MVC e dai servizi del progetto.


// DatasetStreamController (estratto semplificato, non eseguibile da solo)
private static final long SSE_TIMEOUT_MS = 60_000L;
private static final ExecutorService VIRTUAL_EXECUTOR =
        Executors.newVirtualThreadPerTaskExecutor();

@PostMapping(value = "/{slug}/query/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter queryStream(@PathVariable String slug, @RequestBody(required = false) Map<String, Object> body /* ... */) {
    SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
    // ... autenticazione risolta in modo sincrono, cosi' il 401 e' immediato ...

    CompletableFuture.runAsync(() -> {
        // carica il dataset, riserva 1 credito, esegue la query,
        // invia le righe una per evento, poi conferma o rimborsa il credito
    }, VIRTUAL_EXECUTOR);

    return emitter;        // il thread del servlet torna subito libero
}

Una sessione SSE dura fino a 60 secondi (SSE_TIMEOUT_MS) e passa quasi tutto il tempo a inviare righe a un client che può essere lento. Se la gestisse un thread del servlet, quel thread resterebbe occupato per tutta la durata, e poche sessioni lente saturerebbero il pool. Con runAsync sul virtual thread executor, il thread HTTP viene restituito subito, e l'invio avviene su un thread che costa poco restare in attesa. Il sorgente documenta anche l'alternativa scartata: SseEmitter invece di Flux di WebFlux, perché il progetto è su stack servlet e introdurre Reactor richiederebbe una migrazione sproporzionata alla scala attuale.

I virtual thread non aggiungono capacità al database. Il pool di connessioni è di 10 connessioni in sviluppo e 20 in produzione (maximum-pool-size in application.yml e application-prod.yml), con timeout di 20 secondi sulla richiesta di una connessione, e il servizio parte con -Xmx512m. Con migliaia di virtual thread in attesa, l'attesa si trasferisce sul pool e sulla memoria. Qui il rischio è contenuto da fattori che non c'entrano con l'executor: ogni sessione richiede autenticazione e riserva un credito, e ogni stream ha un limite predefinito di 500 righe (DEFAULT_STREAM_LIMIT).

  • Nessun arresto ordinato: l'executor è un campo statico che nessuno chiude, quindi allo spegnimento le sessioni in corso cadono con il processo.
  • Nessun tetto alle sessioni concorrenti: newVirtualThreadPerTaskExecutor() non limita i thread; il limite reale viene da autenticazione, crediti e pool, non da un semaforo. Per un tetto duro serve un Semaphore acquisito prima di runAsync.
  • Il contesto del thread non si propaga: l'identificativo di correlazione del log (MDC) è legato al thread della richiesta, e il codice in runAsync logga senza. Il rimedio è copiare l'MDC a mano, come spiegato in Java nel mondo reale.

Sono i tre prezzi dell'adozione mirata, e vanno letti come promemoria: nessuno è un difetto del virtual thread in sé, tutti derivano dall'aver scelto un executor statico invece di quello gestito da Spring. Chi abilita la proprietà globale ottiene l'integrazione con lo shutdown, ma paga l'estensione del comportamento a ogni richiesta. Prima di farlo servono un test di carico sul codice reale, la verifica del pinning con -Djdk.tracePinnedThreads=full e un controllo delle librerie che sincronizzano attorno all'I/O.

Domande frequenti

Devo migrare a Java 21 oggi?

Se sei su Java 17 con un'applicazione enterprise, di norma sì: la 21 è una LTS e la migrazione è modesta, a patto di verificare le librerie che usano reflection o bytecode. I virtual thread sono un motivo in più solo se il tuo carico è dominato da attese di I/O, e vanno abilitati e misurati. Da Java 8 o 11 il salto è più grande e conviene valutarne costi e benefici.

I virtual thread funzionano con tutti i driver di database?

Un driver JDBC blocca il thread durante la query, e con un virtual thread il runtime può staccarlo dal carrier mentre attende. Ci sono però due eccezioni. In Java 21 un virtual thread dentro un blocco synchronized resta agganciato al carrier (pinning), e un driver o un pool che sincronizza attorno all'I/O ne risente; il problema è risolto in Java 24 col JEP 491. Inoltre il pool di connessioni resta un limite: 20 connessioni significano 20 query alla volta.

Posso usare Spring 5 con Java 21?

Il supporto a Java 21 e ai virtual thread arriva con Spring Framework 6.1 e Spring Boot 3.2. Spring Framework 5 non è pensato per Java 21 e non li supporta: se sei su Spring 5, la migrazione a Spring Boot 3, con il passaggio al namespace jakarta.*, viene prima di quella a Java 21.

Le string templates sono utilizzabili?

No, non per il codice di produzione. In Java 21 erano un'anteprima (JEP 430) che richiede --enable-preview, e il linguaggio non le ha rese definitive. Per il testo formattato usa un text block con formatted(), e per il JSON lascia serializzare i record a Jackson invece di costruirlo a mano: un titolo con virgolette produce JSON non valido.

Che cosa sai fare adesso

Sai decidere se abilitare i virtual thread sul tuo servizio con una misura, non con un'impressione, e sai dove si nasconde il costo (pool di connessioni, pinning, contesto del thread). Sai modellare credenziali o pagamenti con interfacce sigillate e record, e lasciare al compilatore il compito di segnalarti il ramo dimenticato. E sai scegliere la struttura giusta quando l'ordine conta. Il passo successivo, per una Clean Architecture, è portare questi tipi nel domain layer, e per la concorrenza a monte vedi Concorrenza e multithreading e Virtual thread e concorrenza strutturata.

Fonti

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti