Cada 6 meses Java lanza una nueva versión. Haga falta o no, como en el chiste. Sin embargo, esta vez la versión 21 es por fin una actualización importante. Como siempre, hay características definitivas, otras en preview y otras directamente en beta. 15 en total y, al menos una de ellas, sin discusión, supone una gran novedad para la plataforma.
Vamos a verlas...
☕ ¿Quieres aprender Java en serio, y no con "recetas", sin entender nada en realidad? Pues tenemos el curso que necesitas 👉🏻 Desarrollo de aplicaciones con la plataforma Java.
Hilos virtuales (Proyecto Loom)
Esta es sin duda la gran novedad de Java 21.
Tras estar en vista previa durante las dos versiones anteriores (19, y 20), y sin apenas cambios entre ellas, los hilos virtuales (virtual threads) son ya por fin definitivos en Java 21. Y van a cambiar el rendimiento de las aplicaciones web Java para siempre.
A la hora de escalar una aplicación web, el principal cuello de botella suelen ser los hilos nativos. El número de hilos disponibles en el sistema es limitado, y si tienen que esperar (que termine una llamada a una API o la obtención de datos desde una base de datos) se van reservando y dejan de estar disponibles, por lo que la escalabilidad está muy limitada.
Las soluciones que se habían buscado hasta ahora eran complejas y difíciles de mantener. Pero ahora, con los hilos virtuales de Java 21, es posible ejecutar millones de hilos virtuales utilizando por debajo tan solo unos cuantos hilos del sistema operativo. Y ni siquiera es necesario modificar el código Java existente.
Ya se ha pueden utilizar en producción porque son una característica definitiva.
Hemos hecho un artículo específico sobre esto para que los veas con mayor detalle: "Hilos virtuales en Java: la revolución del rendimiento en la plataforma Java".
Colecciones secuenciadas
Se han definido tres nuevas interfaces para colecciones secuenciadas (SequencedCollection
, SequencedSet
y SequencedMap
), que se utilizan para representar colecciones con un orden definido. Estas interfaces proporcionan una API uniforme para acceder a los elementos de una colección en secuencia directa o inversa.
Para entenderlo veamos un ejemplo. ¿Cuál es la forma más fácil de acceder al último elemento de una lista en versiones de Java anteriores a la 21?
Pues así:
var ultimoElto = lista.get(lista.size() - 1);
No muy complicado, pero un poco tedioso, aparte de largo.
Vale, pues gracias a las nuevas interfaces para acceso secuenciado a colecciones, podemos escribir simplemente:
var ultimoElto = lista.getLast();
¡Mucho más fácil!
Y lo mismo pasa con otros tipos de colecciones. Por ejemplo, para obtener el primer elemento de un conjunto de elementos mapeados mediante hashes (una colección LinkedHashSet
de Java 8) teníamos que hacer esto:
var primerElto = linkedHashSet.iterator().next();
¡Buff! Con estas nuevas colecciones, ahora es también directo:
var primerElto = linkedHashSet.getFirst();
Aparte de estos dos que acabamos de ver, la interfaz SequencedCollection
define también los siguientes métodos: void addFirst(E)
, void addLast(E)
, E RemoveFirst()
, E RemoveLast()
y SequencedCollection reversed()
, donde E
es un elemento del tipo de la colección. Su función y modo de utilizarlas es obvia a partir de su firma y de su nombre. El último método cambia el orden a la colección y devuelve una nueva del mismo tipo.
La interfaz SequencedSet
hereda de la anterior y no añade método alguno pero modifica ligeramente el comportamiento de algunos de los anteriores. En concreto, addFirst
y addLast
, en el caso de que el elemento ya existiese en la colección, no lo añaden de nuevo sino que lo mueven de donde estuviese al primero o último lugar respectivamente. Y, claro está, reversed()
devuelve un SequencedSet
y no un SequencedCollection
, como es fácil suponer.
El siguiente diagrama que hemos elaborado, muestra las tres nuevas interfaces (marcadas en verde) y las interfaces y clases de las que derivan o que derivan de ellas (solo están las más habituales, que se usan al menos 100 veces en el código fuente del JDK):
Puedes ver los detalles de SequencedMap
en el JEP correspondiente.
Nuevos métodos de colecciones
Relacionado con lo anterior, en Java 21 la clases de utilidad Collections
se ha ampliado con varios métodos estáticos orientados a las colecciones secuenciadas:
newSequencedSetFromMap(SequencedMap map)
: similar a Collections.setFromMap(…)
, este método devuelve un SequencedSet
con las propiedades del mapa subyacente.
unmodifiableSequencedCollection(SequencedCollection c)
: análogo al método Collections.unmodifiableCollection(…)
devuelve una vista inmutable de la colección secuenciada subyacente. Es decir, que si llamamos a cualquier método de esta que intente modificar los datos recibiremos una excepción, y al ser una vista, cualquier cambio posterior en la colección original se ve reflejado en la devuelta.
Collections.unmodifiableSequencedMap(SequencedMap m)
: similar a la anterior pero la colección devuelta es un SequencedMap
. Es análogo al método Collections.unmodifiableMap(…)
.
Collections.unmodifiableSequencedSet(SequencedSet s)
: como ya habrás adivinado, obtiene una vista inmutable de un SequencedSet
, como con Collections.unmodifiableSet(…)
.
Patrones para objetos Record
Los objetos de la clase Record
en Java son portadores transparentes e inmutables de datos (muy similares a un objeto básico de Java o POJO). Creamos un objeto Record
registro así:
record Point(int x, int y) {}
Hasta ahora, si necesitábamos acceder a los componentes de un objeto Record
, teníamos que desestructurarlo:
int x = punto.x();
int y = punto.y();
En Java 21 podemos hacerlo de manera más concisa, utilizando una sintaxis que se llama patrón de objeto Records
:
if (obj instanceof Point(int x, int y)) {
System.out.println(x+y);
}
Coincidencia de patrones para sentencias switch
Ahora con Java 21, podemos utilizar los patrones de registro que acabamos de ver dentro de las sentencias switch
. Esto quiere decir que en el case
correspondiente podemos usar el patrón que acabamos de ver para desestructurar automáticamente los valores:
public void print(Object o) {
switch (o) {
case Point(int x, int y) -> System.out.printf("o es una posición: %d/%d%n", x, y);
case String s -> System.out.printf("o es una cadena: %s%n", s);
default -> System.out.printf("o es otra cosa: %s%n", o);
}
}
Otras novedades estables
Emojis
La clase Character
incluye ahora algunos métodos estáticos para identificar emojis. La más importante isEmoji
:
var codePoint = Character.codePointAt("😃", 0);
var isEmoji = Character.isEmoji(codePoint);
System.out.println("😃 is an emoji: " + isEmoji); //true
Además, ahora se soporta también la búsqueda de emojis en expresiones regulares a través de expresiones de tipo \p{isXXX}
:
Pattern.compile("\\p{IsEmoji}").matcher("🉐").matches()
Math
La clase Math
ahora tiene un método estático Clamp
que toma un valor, un mínimo y un máximo y devuelve un valor dentro del intervalo mínimo y máximo (incluidos) para asegurar que queda dentro. Hay cuatro sobrecargas para las cuatro primitivas numéricas.
Métodos de repetición de caracteres
Las clases StringBuilder
y StringBuffer
ahora tienen métodos de repetición que le permiten añadir una secuencia de caracteres varias veces a una cadena que se esté construyendo:
var builder = new StringBuilder();
builder.append("Hola");
builder.append(", ");
builder.repeat("Mundo", 3); //Este es
builder.append("!");
//Muestra "Hola, MundoMundoMundo!"
System.out.println(builder);
String
El método indexOf
de la clase String
gana varias sobrecargas para tomar un parámetro adicional maxIndex
, como se muestra a continuación:
var hola = "Hola, Mundo";
var primeraComa = hola.indexOf(",", 0, 3);
//Muestra -1 ya que no la encuentra antes de la posición 3
System.out.println(primeraComa);
También tiene un método nuevo llamado splitWithDelimiters()
que funciona igual que el clásico split()
pero además devuelve los delimitadores en el array del resultado. Por ejemplo:
var hello = "Hola; Mundo";
var semiColonSplit = hello.splitWithDelimiters(";", 0);
//Muestra [Hola, ;, Mundo]
System.out.println(Arrays.toString(semiColonSplit));
Un pequeño paso para Java pero un gran paso para la humanidad 😉
Aleatorizar una lista
¿Necesitas barajar una lista in situ con un RandomGenerator
para aleatorizarla? Pues con Java 21 puedes pasar la lista y un RandomGenerator
a Collections::shuffle
, y ya lo tienes:
var palabras = new ArrayList<>(List.of("Hola", "nuevo", "método", "shuffle", "de", "las", "colecciones"));
var randomizer = RandomGenerator.getDefault();
// Usar esta API tiene más sentido si no usas el generador aleatorio por defecto, claro
Collections.shuffle(palabras, randomizer);
// Imprime las palabras de la lista pero en un orden aleatorio
System.out.println(palabras);
Cliente HTTP
Ahora, se puede autocerrar un objeto de la clase HttpClient
. Se le han añadido varios métodos a la clase para esto:
close()
: cierra el cliente esperando a que todas las conexiones se hayan completado.
shutdown()
: inicia un apagado suave del cliente cuya llamada termina de inmediato, pero por detrás espera a que se cierren todas las conexiones.
shutdownNow()
: inicia el apagado en el momento, intentando interrumpir las conexiones actuales aunque no hayan terminado, pero no espera tampoco a que se cierre el cliente para seguir la ejecución.
awaitTermination(Duration duration)
: espera a que se apague el cliente durante como máximo el tiempo indicado. Si al terminar la duración indicada se consiguió cerrar devuelve true
, o false
en caso contrario.
isTerminated()
: devuelve true
si el cliente está apagado.
Características en preview
Además de las características estables que hemos visto hasta ahora, esta versión incluye algunas características que están en fase de preview. Es decir, que no son definitivas, pero que lo serán seguramente en la próxima versión o en la siguiente. De momento podemos probarlas usando el flag --enable-preview
del runtime ($ java --enable-preview --source 21 miapp.java
), pero no se recomienda su uso en producción.
Cadenas plantilla
Algo que lleva años en otras plataformas y que es muy útil: nos permite utilizar expresiones de código entre llaves dentro de las cadenas de texto, que se evaluarán a la hora de utilizarlas. Son literales de cadena prefijados con STR.
, que es el procesador de plantillas:
String message = STR."Hola \{ nombre }!";
Fíjate en que la expresión a evaluar va entre llaves, y la primera de ellas está escapeada con \
, pero no la segunda. Es una forma extraña de implementarlo. Puedes ver todos los detalles en el JEP correspondiente.
Variables y patrones no utilizados (desechados)
Este es un patrón común en muchos otros lenguajes, como C# o Python, pero no existía en Java.
Existen ocasiones en las que se define una variable que realmente no queremos utilizar para nada. Por ejemplo, al declarar un manejador de excepciones (en el catch
) o al desestructurar una clase.
Un ejemplo típico en Java son las excepciones donde no las podemos declarar sin variable, como sí ocurre en otros lenguajes:
String s = "....";
try {
int i = Integer.parseInt(s);
...
} catch (NumberFormatException ex) {
System.out.println("No es un número: " + s);
}
En este caso se declara el tipo de la excepción que queremos capturar con su correspondiente variable, pero esta no se utiliza para nada. En otros lenguajes se puede usar solo el tipo, sin la variable, y en otros, como ahora Java con esta característica en preview, se puede indicar que se desecha escribiendo un guion bajo en lugar de un nombre de variable:
String s = "....";
try {
int i = Integer.parseInt(s);
...
} catch (NumberFormatException _) {
System.out.println("No es un número: " + s);
}
En la línea 6 se usa -
en lugar del nombre de la variable.
Es azúcar sintáctico, pero que puede ser útil para otros casos, como la desestructuración de valores o dentro de un switch
, como vimos antes.
Clases anónimas y main
sin clase
Persiguiendo el mismo objetivo que otras plataformas, como Node.js o .NET, en las que la sintaxis básica para empezar a trabajar es mínima, sin necesidad de crear una clase y ni siquiera un método main
. En Java 21, con esta característica podemos librarnos al menos de la primera parte: crear una clase, aunque no de la segunda.
Ahora, para comenzar un programa escribiendo tan solo esto:
void main(String[] args) {
System.out.println("¡Hola mundo!");
}
String saluda(String nombre) {
...
}
Todavía hay que declarar el main
, pero nos quitamos del medio la clase estática y pública que metíamos antes.
Eso sí, desde clases convencionales, con nombre, no es posible llamar a nada que definamos dentro de una clase sin nombre como esta. Así, en el ejemplo anterior, el método saluda()
solo se podría llamar desde la propia clase.
Valores de ámbito
Los valores de ámbito son una forma moderna de compartir datos dentro y entre hilos. Los valores de ámbito permiten que un objeto se almacene durante un tiempo limitado, de tal manera que solo el subproceso que escribió el valor pueda leerlo.
Los valores de ámbito generalmente se crean como campos estáticos y públicos para que podamos acceder a ellos directamente sin pasarlos como parámetro a ningún método. Sin embargo, es importante tener en cuenta que, si el valor se accede desde varios métodos, el valor en cada momento dependerá del tiempo de ejecución y del estado del hilo.
Los valores de ámbito se crean con el método ScopedValue.newInstance()
:
public final static ScopedValue<USER> LOGGED_IN_USER = ScopedValue.newInstance();
Con el método ScopedValue.where()
enlazamos el valor de ámbito a la instancia de una clase, y ejecutamos un método durante cuya ejecución seguirá siendo válido el valor de ámbito:
class LoginUtil {
public final static ScopedValue<USER> LOGGED_IN_USER = ScopedValue.newInstance();
...
//Dentro de otro método
User loggedInUser = authenticateUser(request);
ScopedValue.where(LOGGED_IN_USER, loggedInUser).run(() -> service.getData());
}
public void getData() {
User loggedInUser = LoginUtil.LOGGED_IN_USER.get();
...
}
Fíjate que dentro del método que se ejecuta el valor establecido LOGGED_IN_USER
es inmutable y el método no lo puede cambiar. Observa también que se obtiene el valor del objeto de ámbito dentro de las funciones utilizadas dentro del código (en este caso desde getData()
) con el método get()
del objeto de ámbito.
Concurrencia estructurada
Esta característica está orientada a simplificar la programación concurrente mediante una nueva API para concurrencia estructurada. La concurrencia estructurada trata a los grupos de tareas relacionadas que se ejecutan en diferentes subprocesos como una sola unidad de trabajo, lo que agiliza el manejo y la cancelación de errores, mejora la confiabilidad y mejora la observabilidad.
Es un tema complejo sobre el que, si tienes interés, puedes leer detenidamente en la propuesta de mejora correspondiente.
Otras cosas
Dentro de esta versión se han incluido otras características en preview más temprana, algunas mejoras de rendimiento, se han quitado cosas (poco utilizadas) y se han marcado como obsoletas otras (también poco utilizadas).
La verdad es que para ser una versión de Java trae muchísimas más novedades que de costumbre, y más grandes y con impacto, sobre todo lo relativo a los hilos virtuales.
En este artículo hemos repasado las más importantes. Puedes ver todos los detalles de la versión en las notas de la versión de Java 21.