Caratteristiche di Java 21: Uno Sguardo Dettagliato ai Cambiamenti più Importanti nella Nuova Release LTS

Dal momento che la piattaforma Java ha adottato un ciclo di rilascio di sei mesi, abbiamo superato le domande perenni come “Java morirà quest’anno?” o “Vale la pena migrare alla nuova versione?”. Nonostante siano passati 28 anni dalla sua prima versione, Java continua a prosperare e rimane una scelta popolare come linguaggio di programmazione principale per molti nuovi progetti.

Java 17 è stata una tappa significativa, ma Java 21 ha ora preso il posto di 17 come il prossimo rilascio di supporto a lungo termine (LTS). È essenziale per gli sviluppatori Java rimanere informati sui cambiamenti e sulle nuove funzionalità che questa versione porta. Ispirato dal mio collega Darek, che ha dettagliato le funzionalità di Java 17 nel suo articolo, ho deciso di discutere JDK 21 in modo simile (ho anche analizzato le funzionalità di Java 23 in un articolo di follow-up, quindi dai un’occhiata anche a quello).

JDK 21 comprende un totale di 15 JEP (JDK Enhancement Proposals). Puoi consultare l’elenco completo sul sito ufficiale di Java. In questo articolo, evidenzierò diversi JEP di Java 21 che ritengo particolarmente degni di nota. Vale a dire:

  1. String Templates
  2. Collezioni Sequenziate
  3. Corrispondenza dei modelli per switch e Modelli di Record
  4. Thread Virtuali

Senza ulteriori indugi, immergiamoci nel codice e esploriamo questi aggiornamenti.

String Templates (Anteprima)

La funzionalità String Templates è ancora in modalità anteprima. Per utilizzarla, è necessario aggiungere il flag --enable-preview agli argomenti del compilatore. Tuttavia, ho deciso di menzionarla nonostante lo stato di anteprima. Perché? Perché mi infastidisce molto ogni volta che devo scrivere un messaggio di log o una query SQL che contiene molti argomenti o decifrare quale segnaposto verrà sostituito con un determinato argomento. E String Templates promette di aiutarmi (e te) in questo.

Come dice la documentazione JEP, lo scopo delle String Templates è “semplificare la scrittura di programmi Java rendendo facile esprimere stringhe che includono valori calcolati a tempo di esecuzione.”

Verifichiamo se è davvero più semplice.

Il “vecchio modo” sarebbe utilizzare il metodo formatted() su un oggetto String:

var msg = "Log message param1: %s, pram2: %s".formatted(p1, p2);

Ora, con StringTemplate.Processor (STR), appare così:

var interpolated = STR."Log message param1: \{p1}, param2: \{p2}";

Con un testo breve come quello sopra, il profitto potrebbe non essere così evidente, ma credimi, quando si tratta di blocchi di testo grandi (JSON, istruzioni SQL, ecc.), i parametri nominati ti aiuteranno molto.

Collezioni Sequenziate

Java 21 ha introdotto una nuova gerarchia delle collezioni Java. Dai un’occhiata al diagramma qui sotto e confrontalo con ciò che probabilmente hai imparato durante le tue lezioni di programmazione. Noterai che sono state aggiunte tre nuove strutture (evidenziate dal colore verde).

Fonte dell’immagine: JEP 431

Le collezioni sequenziali introducono una nuova API Java integrata, migliorando le operazioni su set di dati ordinati. Questa API consente non solo un accesso conveniente ai primi e agli ultimi elementi di una collezione, ma permette anche una traversata efficiente, l’inserimento in posizioni specifiche e il recupero di sotto-sequenze. Questi miglioramenti semplificano e rendono più intuitive le operazioni che dipendono dall’ordine degli elementi, migliorando sia le prestazioni che la leggibilità del codice quando si lavora con liste e strutture di dati simili.

Questo è l’elenco completo dell’interfaccia SequencedCollection:

Java

 

public interface SequencedCollection<E> extends Collection<E> {
   SequencedCollection<E> reversed();
   default void addFirst(E e) {
       throw new UnsupportedOperationException();
   }
   default void addLast(E e) {
       throw new UnsupportedOperationException();
   }
   default E getFirst() {
       return this.iterator().next();
   }
   default E getLast() {
       return this.reversed().iterator().next();
   }
   default E removeFirst() {
       var it = this.iterator();
       E e = it.next();
       it.remove();
       return e;
   }
   default E removeLast() {
       var it = this.reversed().iterator();
       E e = it.next();
       it.remove();
       return e;
   }
}

Quindi, ora, invece di:

Java

 

var first = myList.stream().findFirst().get();
var anotherFirst = myList.get(0);
var last = myList.get(myList.size() - 1);

Possiamo semplicemente scrivere:

Java

 

var first = sequencedCollection.getFirst();
var last = sequencedCollection.getLast();
var reversed = sequencedCollection.reversed();

Questo è un piccolo cambiamento, ma IMHO, è una funzionalità così conveniente e utilizzabile.

Pattern Matching e Record Patterns

A causa della somiglianza del Pattern Matching per switch e dei Record Patterns, li descriverò insieme. I Record Patterns sono una novità: sono stati introdotti in Java 19 (come anteprima). D’altra parte, il Pattern Matching per switch è in un certo senso una continuazione dell’ espressione instanceof. Introduce una nuova sintassi possibile per le dichiarazioni switch che consente di esprimere query complesse orientate ai dati più facilmente.

Dimentichiamo i fondamenti dell’OOP per il bene di questo esempio e decostruiamo manualmente l’oggetto dipendente (employee è una classe POJO).

Prima di Java 21, appariva così:

Java

 

if (employee instanceof Manager e) {
   System.out.printf("I’m dealing with manager of %s department%n", e.department);
} else if (employee instanceof Engineer e) {
   System.out.printf("I’m dealing with %s engineer.%n", e.speciality);
} else {
   throw new IllegalStateException("Unexpected value: " + employee);
}

Cosa succederebbe se potessimo liberarci del brutto instanceof? Bene, ora possiamo, grazie al potere del Pattern Matching di Java 21:

Java

 

switch (employee) {
   case Manager m -> printf("Manager of %s department%n", m.department);
   case Engineer e -> printf("I%s engineer.%n", e.speciality);
   default -> throw new IllegalStateException("Unexpected value: " + employee);
}

Mentre parliamo della dichiarazione switch, possiamo anche discutere della funzionalità dei Record Patterns. Quando trattiamo con un Record Java, ci consente di fare molto di più rispetto a una classe Java standard:

Java

 

switch (shape) { // shape is a record
   case Rectangle(int a, int b) -> System.out.printf("Area of rectangle [%d, %d] is: %d.%n", a, b, shape.calculateArea());
   case Square(int a) -> System.out.printf("Area of square [%d] is: %d.%n", a, shape.calculateArea());
   default -> throw new IllegalStateException("Unexpected value: " + shape);
}

Come mostra il codice, con quella sintassi, i campi del record sono facilmente accessibili. Inoltre, possiamo aggiungere della logica supplementare alle nostre dichiarazioni case:

Java

 

switch (shape) {
   case Rectangle(int a, int b) when a < 0 || b < 0 -> System.out.printf("Incorrect values for rectangle [%d, %d].%n", a, b);
   case Square(int a) when a < 0 -> System.out.printf("Incorrect values for square [%d].%n", a);
   default -> System.out.println("Created shape is correct.%n");
}

Possiamo usare una sintassi simile per le dichiarazioni if. Inoltre, nell’esempio seguente, possiamo vedere che i Record Patterns funzionano anche per i record annidati:

Java

 

if (r instanceof Rectangle(ColoredPoint(Point p, Color c),
                          ColoredPoint lr)) {
   //sth
}

Thread Virtuali

La funzionalità dei Thread Virtuali è probabilmente la più interessante tra tutte le novità di Java 21 — o almeno è una delle più attese dagli sviluppatori Java. Come dice la documentazione JEP (linkata nella frase precedente), uno degli obiettivi dei thread virtuali era quello di “permettere alle applicazioni server scritte nello stile semplice thread-per-request di scalare con un utilizzo dell’hardware quasi ottimale”. Tuttavia, significa che dobbiamo migrare tutto il nostro codice che utilizza java.lang.Thread?

Innanzitutto, esaminiamo il problema con l’approccio esistente prima di Java 21 (in effetti, praticamente sin dal primo rilascio di Java). Possiamo approssimare che un java.lang.Thread consuma (a seconda del sistema operativo e della configurazione) circa 2-8 MB di memoria. Tuttavia, la cosa importante qui è che un Thread Java è mappato 1:1 a un thread del kernel. Per semplici applicazioni web che utilizzano un approccio “un thread per richiesta”, possiamo facilmente calcolare che o la nostra macchina verrà “uccisa” quando il traffico aumenta (non sarà in grado di gestire il carico) o saremo costretti ad acquistare un dispositivo con più RAM, e le nostre fatture AWS aumenteranno di conseguenza.

Certo, i thread virtuali non sono l’unico modo per affrontare questo problema. Abbiamo la programmazione asincrona (framework come WebFlux o API Java native come CompletableFuture). Tuttavia, per qualche motivo — forse a causa dell’“API poco amichevole” o dell’alto livello di ingresso — queste soluzioni non sono così popolari.

I thread virtuali non sono supervisionati né programmati dal sistema operativo. Piuttosto, la loro programmazione è gestita dalla JVM. Mentre i task reali devono essere eseguiti in un thread della piattaforma, la JVM utilizza i cosiddetti thread portatori — essenzialmente thread della piattaforma — per “trasportare” qualsiasi thread virtuale quando è pronto per l’esecuzione. I thread virtuali sono progettati per essere leggeri e utilizzare molta meno memoria rispetto ai thread standard della piattaforma.

Il diagramma sottostante mostra come i thread virtuali siano connessi ai thread della piattaforma e del sistema operativo:

Quindi, per vedere come i thread virtuali sono utilizzati dai thread della piattaforma, eseguiamo del codice che avvia (1 + numero di CPU che ha la macchina, nel mio caso 8 core) thread virtuali.

Java

 

var numberOfCores = 8; //
final ThreadFactory factory = Thread.ofVirtual().name("vt-", 0).factory();
try (var executor = Executors.newThreadPerTaskExecutor(factory)) {
   IntStream.range(0, numberOfCores + 1)
           .forEach(i -> executor.submit(() -> {
               var thread = Thread.currentThread();
               System.out.println(STR."[\{thread}]  VT number: \{i}");
               try {
                   sleep(Duration.ofSeconds(1L));
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
           }));
}

L’output appare così:

Plain Text

 

[VirtualThread[#29,vt-6]/runnable@ForkJoinPool-1-worker-7]  VT number: 6
[VirtualThread[#26,vt-4]/runnable@ForkJoinPool-1-worker-5]  VT number: 4
[VirtualThread[#30,vt-7]/runnable@ForkJoinPool-1-worker-8]  VT number: 7
[VirtualThread[#24,vt-2]/runnable@ForkJoinPool-1-worker-3]  VT number: 2
[VirtualThread[#23,vt-1]/runnable@ForkJoinPool-1-worker-2]  VT number: 1
[VirtualThread[#27,vt-5]/runnable@ForkJoinPool-1-worker-6]  VT number: 5
[VirtualThread[#31,vt-8]/runnable@ForkJoinPool-1-worker-6]  VT number: 8
[VirtualThread[#25,vt-3]/runnable@ForkJoinPool-1-worker-4]  VT number: 3
[VirtualThread[#21,vt-0]/runnable@ForkJoinPool-1-worker-1]  VT number: 0

Quindi, ForkJonPool-1-worker-X I thread della piattaforma sono i nostri thread portatori che gestiscono i nostri thread virtuali. Osserviamo che i thread virtuali numero 5 e 8 stanno utilizzando lo stesso thread portatore numero 6.

L’ultima cosa sui thread virtuali che voglio mostrarti è come possano aiutarti con le operazioni di I/O bloccante.

Ogni volta che un thread virtuale incontra un’operazione bloccante, come i task di I/O, la JVM lo stacca in modo efficiente dal thread fisico sottostante (il thread portatore). Questo distacco è fondamentale perché libera il thread portatore per eseguire altri thread virtuali invece di rimanere inattivo, aspettando che l’operazione bloccante sia completata. Di conseguenza, un singolo thread portatore può multiplexare molti thread virtuali, che potrebbero arrivare a migliaia o addirittura milioni, a seconda della memoria disponibile e della natura dei task eseguiti.

Proviamo a simulare questo comportamento. Per fare ciò, costringeremo il nostro codice a utilizzare solo un core CPU, con solo 2 thread virtuali — per maggiore chiarezza.

Java

 

System.setProperty("jdk.virtualThreadScheduler.parallelism", "1");
System.setProperty("jdk.virtualThreadScheduler.maxPoolSize", "1");
System.setProperty("jdk.virtualThreadScheduler.minRunnable", "1");

Thread 1:

Java

 

Thread v1 = Thread.ofVirtual().name("long-running-thread").start(
       () -> {
           var thread = Thread.currentThread();
           while (true) {
               try {
                   Thread.sleep(250L);
                   System.out.println(STR."[\{thread}] - Handling http request ....");
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
           }
       }
);

Thread 2:

Java

 

Thread v2 = Thread.ofVirtual().name("entertainment-thread").start(
       () -> {
           try {
               Thread.sleep(1000L);
           } catch (InterruptedException e) {
               throw new RuntimeException(e);
           }
           var thread = Thread.currentThread();
           System.out.println(STR."[\{thread}] - Executing when 'http-thread' hit 'sleep' function");
       }
);

Esecuzione:

Java

 

v1.join(); v2.join();

Risultato:

Plain Text

 

[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#23,entertainment-thread]/runnable@ForkJoinPool-1-worker-1] - Executing when 'http-thread' hit 'sleep' function
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....

Osserviamo che entrambi i Thread Virtuali (long-running-thread e entertainment-thread) sono gestiti da un solo Thread della Piattaforma, che è ForkJoinPool-1-worker-1.

Per riassumere, questo modello consente alle applicazioni Java di raggiungere alti livelli di concorrenza e scalabilità con un overhead molto inferiore rispetto ai modelli di thread tradizionali, dove ogni thread si mappa direttamente a un singolo thread del sistema operativo. Vale la pena notare che i thread virtuali sono un argomento vasto, e ciò che ho descritto è solo una piccola frazione. Ti incoraggio vivamente a saperne di più sulla pianificazione, i thread bloccati e gli interni dei VirtualThreads.

Riepilogo: Il Futuro del Linguaggio di Programmazione Java

Le funzionalità descritte sopra sono quelle che considero le più importanti in Java 21. La maggior parte di esse non è così innovativa come alcune delle cose introdotte in JDK 17, ma sono comunque molto utili e rappresentano piacevoli cambiamenti QOL (Qualità della Vita).

Tuttavia, non dovresti trascurare nemmeno gli altri miglioramenti di JDK 21 — ti invito vivamente ad analizzare l’elenco completo e ad esplorare ulteriormente tutte le funzionalità. Ad esempio, una cosa che considero particolarmente degna di nota è l’API Vector, che consente calcoli vettoriali su alcune architetture CPU supportate — cosa non possibile prima. Attualmente, è ancora in fase di incubazione/stato sperimentale (ed è per questo che non l’ho evidenziata in modo più dettagliato qui), ma ha grandi promesse per il futuro di Java.

In generale, i progressi che Java ha fatto in vari ambiti segnalano il continuo impegno del team nel migliorare l’efficienza e le prestazioni nelle applicazioni ad alta richiesta.

Source:
https://dzone.com/articles/java-21-features-a-detailed-look