Características de Java 21: Una mirada detallada a los cambios más importantes en la nueva versión LTS

Desde que la plataforma Java adoptó un ciclo de lanzamiento de seis meses, hemos superado las preguntas perennes como “¿Morirá Java este año?” o “¿Vale la pena migrar a la nueva versión?”. A pesar de los 28 años desde su primer lanzamiento, Java sigue prosperando y sigue siendo una elección popular como el lenguaje de programación principal para muchos nuevos proyectos.

Java 17 fue un hito significativo, pero Java 21 ha tomado ahora el lugar de 17 como la siguiente versión de soporte a largo plazo (LTS). Es esencial que los desarrolladores de Java se mantengan informados sobre los cambios y nuevas características que trae esta versión. Inspirado por mi colega Darek, quien detalló las características de Java 17 en su artículo, he decidido discutir el JDK 21 de manera similar (también he analizado las características de Java 23 en un artículo de seguimiento, así que échale un vistazo también).

El JDK 21 comprende un total de 15 JEPs (Propuestas de Mejora de JDK). Puedes revisar la lista completa en el sito oficial de Java. En este artículo, destacaré varios JEPs de Java 21 que considero particularmente notables. Específicamente:

  1. Plantillas de Cadenas
  2. Colecciones Secuenciadas
  3. Coincidencia de Patrones para switch y Patrones de Registro
  4. Hilos Virtuales

Sin más dilación, profundicemos en el código y exploremos estas actualizaciones.

Plantillas de Cadenas (Vista Previa)

La función de Plantillas de Spring aún está en modo de vista previa. Para usarla, debes agregar la bandera --enable-preview a los argumentos de tu compilador. Sin embargo, he decidido mencionarla a pesar de su estado de vista previa. ¿Por qué? Porque me irrita mucho cada vez que tengo que escribir un mensaje de registro o una declaración SQL que contenga muchos argumentos o descifrar qué marcador de posición será reemplazado por un argumento dado. Y las Plantillas de Spring prometen ayudarme (y a ti) con eso.

Como dice la documentación de JEP, el propósito de las Plantillas de Spring es “simplificar la escritura de programas Java al facilitar la expresión de cadenas que incluyen valores calculados en tiempo de ejecución.” 

Comprobemos si realmente es más simple.

La “vieja manera” sería usar el método formatted() en un objeto String:

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

Ahora, con StringTemplate.Processor (STR), se ve así:

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

Con un texto corto como el anterior, la ganancia puede no ser tan visible, pero créeme, cuando se trata de bloques de texto grandes (JSONs, declaraciones SQL, etc.), los parámetros nombrados te ayudarán mucho.

Colecciones Secuenciadas

Java 21 introdujo una nueva jerarquía de colecciones de Java. Mira el diagrama a continuación y compáralo con lo que probablemente has aprendido durante tus clases de programación. Notarás que se han agregado tres nuevas estructuras (destacadas por el color verde).

Fuente de la imagen: JEP 431

Las colecciones secuenciadas introducen una nueva API incorporada de Java, mejorando las operaciones sobre conjuntos de datos ordenados. Esta API permite no solo un acceso conveniente a los primeros y últimos elementos de una colección, sino que también habilita un recorrido eficiente, la inserción en posiciones específicas y la recuperación de subsecuencias. Estas mejoras simplifican y hacen más intuitivas las operaciones que dependen del orden de los elementos, mejorando tanto el rendimiento como la legibilidad del código al trabajar con listas y estructuras de datos similares.

Esta es la lista completa de la interfaz 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;
   }
}

Entonces, ahora, en lugar de:

Java

 

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

Podemos escribir simplemente:

Java

 

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

Este es un pequeño cambio, pero en mi humilde opinión, es una característica tan conveniente y usable.

Coincidencia de patrones y patrones de registros

Debido a la similitud entre el Emparejamiento de Patrones para switch y los Patrones de Registro, los describiré juntos. Los patrones de registro son una característica nueva: se han introducido en Java 19 (como una vista previa). Por otro lado, el Emparejamiento de Patrones para switch es en cierto modo una continuación de la  expresión instanceof. Introduce una nueva sintaxis posible para las declaraciones switch, lo que te permite expresar consultas complejas orientadas a datos más fácilmente.

Olvidemos los fundamentos de la OOP por el bien de este ejemplo y deconstruyamos el objeto empleado manualmente (employee es una clase POJO).

Antes de Java 21, se veía así:

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);
}

¿Qué pasaría si pudiéramos deshacernos del feo instanceof? Bueno, ahora podemos, gracias al poder del Emparejamiento de Patrones de 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);
}

Al hablar sobre la declaración switch, también podemos discutir la característica de Patrones de Registro. Al tratar con un Registro de Java, nos permite hacer mucho más que con una clase Java estándar:

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);
}

Como muestra el código, con esa sintaxis, los campos del registro son fácilmente accesibles. Además, podemos agregar algo de lógica adicional a nuestras declaraciones de caso:

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");
}

Podemos usar una sintaxis similar para las declaraciones if. Además, en el ejemplo a continuación, podemos ver que los Patrones de Registro también funcionan para registros anidados:

Java

 

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

Hilos Virtuales

La función de Hilos Virtuales es probablemente la más destacada entre todas las características de Java 21, o al menos una de las que los desarrolladores de Java han esperado con más ansias. Como dice la documentación de JEP (enlazada en la frase anterior), uno de los objetivos de los hilos virtuales era “permitir que las aplicaciones de servidor escritas en el estilo simple de un hilo por solicitud escalen con una utilización de hardware casi óptima”. Sin embargo, ¿significa esto que debemos migrar todo nuestro código que usa java.lang.Thread?

Primero, examinemos el problema con el enfoque que existía antes de Java 21 (de hecho, prácticamente desde el primer lanzamiento de Java). Podemos aproximar que un java.lang.Thread consume (dependiendo del sistema operativo y la configuración) entre 2 y 8 MB de memoria. Sin embargo, lo importante aquí es que un Hilo de Java está mapeado 1:1 a un hilo del kernel. Para aplicaciones web simples que utilizan un enfoque de “un hilo por solicitud”, podemos calcular fácilmente que o nuestra máquina será “muerta” cuando el tráfico aumente (no podrá manejar la carga) o nos veremos obligados a comprar un dispositivo con más RAM, y nuestras facturas de AWS aumentarán como resultado.

Por supuesto, los hilos virtuales no son la única manera de manejar este problema. Tenemos la programación asíncrona (frameworks como WebFlux o API nativa de Java como CompletableFuture). Sin embargo, por alguna razón —quizás debido a la “API poco amigable” o al alto umbral de entrada— estas soluciones no son tan populares.

Los hilos virtuales no son supervisados ni programados por el sistema operativo. Más bien, su programación es gestionada por la JVM. Mientras que las tareas reales deben ejecutarse en un hilo de plataforma, la JVM emplea lo que se llaman hilos portadores —esencialmente hilos de plataforma— para “transportar” cualquier hilo virtual cuando está listo para la ejecución. Los hilos virtuales están diseñados para ser ligeros y utilizan mucho menos memoria que los hilos de plataforma estándar.

El diagrama a continuación muestra cómo se conectan los hilos virtuales con los hilos de plataforma y del sistema operativo:

Entonces, para ver cómo los hilos virtuales son utilizados por los hilos de plataforma, ejecutemos un código que inicia (1 + número de CPUs que tiene la máquina, en mi caso 8 núcleos) hilos virtuales.

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);
               }
           }));
}

La salida se ve así:

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

Así que, ForkJonPool-1-worker-X Los hilos de plataforma son nuestros hilos portadores que gestionan nuestros hilos virtuales. Observamos que los hilos virtuales número 5 y 8 están utilizando el mismo hilo portador número 6.

Lo último que quiero mostrarte sobre los hilos virtuales es cómo pueden ayudarte con las operaciones de E/S bloqueantes.

Siempre que un hilo virtual se encuentra con una operación bloqueante, como las tareas de E/S, la JVM lo desacopla eficientemente del hilo físico subyacente (el hilo portador). Este desacoplamiento es crítico porque libera el hilo portador para ejecutar otros hilos virtuales en lugar de estar inactivo, esperando a que la operación bloqueante se complete. Como resultado, un solo hilo portador puede multiplexar muchos hilos virtuales, que podrían ser miles o incluso millones, dependiendo de la memoria disponible y la naturaleza de las tareas realizadas.

Intentemos simular este comportamiento. Para hacer esto, forzaremos nuestro código a usar solo un núcleo de CPU, con solo 2 hilos virtuales — para mayor claridad.

Java

 

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

Hilo 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);
               }
           }
       }
);

Hilo 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");
       }
);

Ejecutando:

Java

 

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

Resultado:

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 ....

Observamos que ambos Hilos Virtuales (long-running-thread y entertainment-thread) están siendo gestionados por solo un Hilo de Plataforma, que es ForkJoinPool-1-worker-1.

Para resumir, este modelo permite que las aplicaciones de Java logren altos niveles de concurrencia y escalabilidad con mucho menos sobrecarga que los modelos de hilos tradicionales, donde cada hilo se mapea directamente a un solo hilo del sistema operativo. Vale la pena mencionar que los hilos virtuales son un tema vasto, y lo que he descrito es solo una pequeña fracción. Te animo encarecidamente a que aprendas más sobre la programación, los hilos fijos y los detalles internos de los Hilos Virtuales.

Resumen: El Futuro del Lenguaje de Programación Java

Las características descritas anteriormente son las que considero más importantes en Java 21. La mayoría de ellas no son tan innovadoras como algunas de las cosas introducidas en JDK 17, pero todavía son muy útiles y agradables de tener cambios de QOL (Calidad de Vida).

Sin embargo, tampoco debes descartar otras mejoras de JDK 21; te animo a analizar la lista completa y explorar todas las características más a fondo. Por ejemplo, una cosa que considero particularmente notable es la API de Vectores, que permite cálculos vectoriales en algunas arquitecturas de CPU soportadas, lo cual no era posible antes. Actualmente, todavía está en estado de incubación/fase experimental (por eso no lo destaqué en más detalle aquí), pero tiene un gran potencial para el futuro de Java.

En general, el avance que Java ha logrado en varias áreas señala el compromiso continuo del equipo para mejorar la eficiencia y el rendimiento en aplicaciones de alta demanda.

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