Java 8 salió en marzo de 2014 y Java 21 en septiembre de 2023. Entre una y otra hay nueve años y trece versiones, y en ese tiempo Java cambió más que en toda la década anterior: llegaron var, los records, los bloques de texto, el switch con patrones y los hilos virtuales, y se fueron partes enteras de la plataforma. Muchas empresas siguen en Java 8, así que la pregunta es práctica: qué ganas al pasar a 21 y qué se rompe por el camino. Cada ejemplo de este artículo se compiló y se ejecutó en los dos JDK.
Las dos son versiones LTS (de soporte largo). Desde 2017, Java publica una versión cada seis meses y, desde la 17, marca como LTS una de cada cuatro, es decir, una cada dos años. Por eso la mayoría de proyectos saltan de una LTS a otra: de 8 a 11, a 17, a 21 y, hoy, a 25, la LTS más reciente, de septiembre de 2025. Todo lo que cuenta este artículo sigue valiendo en Java 25.
Menos código para lo mismo
Colecciones: var, List.of, toList y las secuenciadas
En Java 8, crear una lista que no se pueda modificar y quedarse con su primer y último elemento cuesta bastante ceremonia:
import java.util.*;
import java.util.stream.*;
public class Main {
public static void main(String[] args) {
List<String> lenguajes = Collections.unmodifiableList(
Arrays.asList("Java", "Kotlin", "Scala", "Groovy"));
Map<String, Integer> anios = new HashMap<>();
anios.put("Java", 1995);
anios.put("Kotlin", 2011);
List<String> cortos = lenguajes.stream()
.filter(l -> l.length() <= 5)
.collect(Collectors.toList());
System.out.println(cortos);
System.out.println(lenguajes.get(0) + " ... " + lenguajes.get(lenguajes.size() - 1));
System.out.println(anios.get("Kotlin"));
}
}java 8[Java, Scala] Java ... Groovy 2011
En Java 21, lo mismo:
import java.util.*;
public class Main {
public static void main(String[] args) {
var lenguajes = List.of("Java", "Kotlin", "Scala", "Groovy");
var anios = Map.of("Java", 1995, "Kotlin", 2011);
var cortos = lenguajes.stream()
.filter(l -> l.length() <= 5)
.toList();
System.out.println(cortos);
System.out.println(lenguajes.getFirst() + " ... " + lenguajes.getLast());
System.out.println(lenguajes.reversed());
System.out.println(anios.get("Kotlin"));
}
}java 21[Java, Scala] Java ... Groovy [Groovy, Scala, Kotlin, Java] 2011
var(Java 10) deja que el compilador deduzca el tipo de una variable local. Sigue siendo un tipo fijo: no es elvarde JavaScript.List.of,Set.ofyMap.of(Java 9) crean colecciones inmutables en una línea. No admitennull, yMap.ofllega hasta diez pares; para más,Map.ofEntries..toList()(Java 16) sustituye a.collect(Collectors.toList())y devuelve una lista que no se puede modificar.getFirst(),getLast()yreversed()(Java 21) llegan con las colecciones secuenciadas, una interfaz común para todo lo que tiene orden: listas,LinkedHashSet,LinkedHashMap,Deque…
Texto: bloques de texto y los métodos que faltaban
public class Main {
public static void main(String[] args) {
String json = "{\n" +
" \"nombre\": \"Ana\",\n" +
" \"lenguaje\": \"Java\"\n" +
"}";
String entrada = " \t ";
System.out.println(json);
System.out.println(entrada.trim().isEmpty());
StringBuilder linea = new StringBuilder();
for (int i = 0; i < 20; i++) linea.append('-');
System.out.println(linea);
System.out.println(String.format("%s tiene %d años", "Java", 2023 - 1995));
}
}java 8{
"nombre": "Ana",
"lenguaje": "Java"
}
true
--------------------
Java tiene 28 añospublic class Main {
public static void main(String[] args) {
String json = """
{
"nombre": "Ana",
"lenguaje": "Java"
}""";
String entrada = " \t ";
System.out.println(json);
System.out.println(entrada.isBlank());
System.out.println("-".repeat(20));
System.out.println("%s tiene %d años".formatted("Java", 2023 - 1995));
"uno\ndos\ntres".lines().map(String::strip).forEach(System.out::println);
}
}java 21{
"nombre": "Ana",
"lenguaje": "Java"
}
true
--------------------
Java tiene 28 años
uno
dos
tresLos bloques de texto (Java 15), entre triples comillas, permiten escribir JSON, SQL o HTML tal cual, sin escapar comillas ni concatenar líneas; la sangría común se quita sola. Java 11 añadió a String isBlank(), strip() (que, a diferencia de trim(), entiende los espacios de Unicode), lines() y repeat(), y Java 15, formatted().
Records: clases de datos en una línea
Una clase que solo guarda datos, en Java 8, necesita constructor, getters, equals, hashCode y toString, unas cuarenta líneas que casi siempre se generaban con el IDE o con Lombok:
import java.util.Objects;
public class Main {
static final class Punto {
private final int x;
private final int y;
Punto(int x, int y) {
this.x = x;
this.y = y;
}
int getX() { return x; }
int getY() { return y; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Punto)) return false;
Punto p = (Punto) o;
return x == p.x && y == p.y;
}
@Override
public int hashCode() { return Objects.hash(x, y); }
@Override
public String toString() { return "Punto[x=" + x + ", y=" + y + "]"; }
}
public static void main(String[] args) {
Punto a = new Punto(1, 2);
System.out.println(a + " " + a.equals(new Punto(1, 2)) + " " + a.getX());
}
}java 8Punto[x=1, y=2] true 1
public class Main {
record Punto(int x, int y) {
Punto {
if (x < 0 || y < 0) throw new IllegalArgumentException("Coordenadas negativas");
}
}
public static void main(String[] args) {
var a = new Punto(1, 2);
System.out.println(a + " " + a.equals(new Punto(1, 2)) + " " + a.x());
new Punto(-1, 0);
}
}java 21Punto[x=1, y=2] true 1 Exception in thread "main" java.lang.IllegalArgumentException: Coordenadas negativas at Main$Punto.<init>(Main.java:4) at Main.main(Main.java:11) código de salida: 1
Un record (Java 16) declara los campos una vez y el compilador escribe el constructor, los métodos de acceso (x(), no getX()), equals, hashCode y toString. Es inmutable, y el constructor compacto permite validar los datos, como aquí, donde la segunda línea lanza la excepción. Es el sustituto natural de buena parte de los usos de Lombok.
Decidir según el tipo: patrones y clases selladas
Este es el cambio más profundo del lenguaje. En Java 8, actuar según el tipo de un objeto es una cadena de instanceof con conversiones:
public class Main {
interface Forma {}
static final class Circulo implements Forma { final double r; Circulo(double r) { this.r = r; } }
static final class Rectangulo implements Forma { final double an, al; Rectangulo(double an, double al) { this.an = an; this.al = al; } }
static double area(Forma f) {
if (f instanceof Circulo) {
Circulo c = (Circulo) f;
return Math.PI * c.r * c.r;
} else if (f instanceof Rectangulo) {
Rectangulo r = (Rectangulo) f;
return r.an * r.al;
}
throw new IllegalArgumentException("Forma desconocida");
}
public static void main(String[] args) {
System.out.printf("%.2f %.2f%n", area(new Circulo(1)), area(new Rectangulo(2, 3)));
}
}java 83.14 6.00
public class Main {
sealed interface Forma permits Circulo, Rectangulo, Triangulo {}
record Circulo(double r) implements Forma {}
record Rectangulo(double an, double al) implements Forma {}
record Triangulo(double base, double alto) implements Forma {}
static double area(Forma f) {
return switch (f) {
case Circulo(double r) -> Math.PI * r * r;
case Rectangulo(double an, double al) when an == al -> an * an;
case Rectangulo(double an, double al) -> an * al;
case Triangulo(double b, double h) -> b * h / 2;
};
}
public static void main(String[] args) {
System.out.printf("%.2f %.2f %.2f%n", area(new Circulo(1)), area(new Rectangulo(2, 3)), area(new Triangulo(4, 5)));
Object o = "Java 21";
if (o instanceof String s && s.length() > 4) {
System.out.println(s.toUpperCase());
}
}
}java 213.14 6.00 10.00 JAVA 21
Cuatro novedades trabajan juntas:
switchcomo expresión (Java 14): devuelve un valor, usa flechas y no se cae al caso siguiente, así que se acabaron losbreakolvidados.- Patrones en el
switchy patrones de records (Java 21):case Circulo(double r)comprueba el tipo y saca sus componentes a la vez, ywhenañade una condición. - Clases selladas (Java 17):
sealed ... permitsfija la lista cerrada de implementaciones. Gracias a eso el compilador sabe que elswitchcubre todos los casos y no hace falta undefault. instanceofcon patrón (Java 16):o instanceof String scomprueba y declara la variable de una vez.
El efecto más útil es el que no se ve: si mañana alguien añade una forma nueva a la interfaz sellada, el switch de area deja de compilar hasta que se trate. Lo comprobamos quitando la línea del triángulo:
public class Main {
sealed interface Forma permits Circulo, Rectangulo, Triangulo {}
record Circulo(double r) implements Forma {}
record Rectangulo(double an, double al) implements Forma {}
record Triangulo(double base, double alto) implements Forma {}
static double area(Forma f) {
return switch (f) {
case Circulo(double r) -> Math.PI * r * r;
case Rectangulo(double an, double al) -> an * al;
};
}
public static void main(String[] args) {
System.out.println(area(new Circulo(1)));
}
}javac 21Main.java:8: error: the switch expression does not cover all possible input values
return switch (f) {
^
1 error
código de salida: 1Errores que se entienden
El mismo programa, con un null escondido, ejecutado en los dos JDK:
public class Main {
static class Direccion { String ciudad; }
static class Cliente { Direccion direccion; }
public static void main(String[] args) {
Cliente c = new Cliente();
c.direccion = new Direccion();
c.direccion.ciudad = "Lima";
Cliente[] clientes = { c, new Cliente() };
for (Cliente cl : clientes) {
System.out.println(cl.direccion.ciudad.length());
}
}
}java 84 Exception in thread "main" java.lang.NullPointerException at Main.main(Main.java:11) código de salida: 1
java 214 Exception in thread "main" java.lang.NullPointerException: Cannot read field "ciudad" because "<local6>.direccion" is null at Main.main(Main.java:11) código de salida: 1
El primer cliente funciona; el segundo no tiene dirección. En Java 8, el error dice la línea, pero no qué era null: ¿cl, direccion o ciudad? Desde Java 14, las NullPointerException detalladas lo dicen. El <local6> es la variable cl: su nombre solo aparece si se compila con javac -g, que es lo que hacen Maven y Gradle por omisión. Pequeño cambio, horas de depuración ahorradas.
Hilos virtuales: miles de tareas sin miles de hilos
La novedad más importante de Java 21 para los servidores. Un hilo normal de Java es un hilo del sistema operativo, caro de crear y limitado a unos pocos miles. Por eso, en Java 8, las tareas que pasan casi todo el tiempo esperando (a una base de datos, a una API) se reparten en un grupo fijo de hilos, y las que no caben esperan su turno:
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws Exception {
long inicio = System.nanoTime();
ExecutorService ejecutor = Executors.newFixedThreadPool(100);
for (int i = 0; i < 2_000; i++) {
ejecutor.submit(() -> { Thread.sleep(100); return null; });
}
ejecutor.shutdown();
ejecutor.awaitTermination(1, TimeUnit.MINUTES);
long ms = (System.nanoTime() - inicio) / 1_000_000;
System.out.println("2000 esperas de 100 ms con 100 hilos: unos " + Math.round(ms / 100.0) / 10.0 + " s");
}
}java 82000 esperas de 100 ms con 100 hilos: unos 2.0 s
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws Exception {
long inicio = System.nanoTime();
try (var ejecutor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 2_000; i++) {
ejecutor.submit(() -> { Thread.sleep(100); return null; });
}
}
long ms = (System.nanoTime() - inicio) / 1_000_000;
System.out.println("2000 esperas de 100 ms con hilos virtuales: unos " + Math.round(ms / 100.0) / 10.0 + " s");
}
}java 212000 esperas de 100 ms con hilos virtuales: unos 0.1 s
Con 100 hilos, 2,000 esperas de 100 ms tardan unos dos segundos: van de cien en cien. Con hilos virtuales (Java 21), cada tarea tiene su propio hilo, que la JVM gestiona y aparca mientras espera, sin ocupar un hilo del sistema: las 2,000 esperan a la vez. El código apenas cambia, y sigue siendo el estilo sencillo de «una petición, un hilo», sin programación reactiva. Hay más en el ejemplo de tareas en paralelo con hilos virtuales. Los tiempos exactos varían en cada ejecución; la diferencia de orden de magnitud, no.
Lo que cambia sin tocar el código
UTF-8 por omisión
import java.nio.charset.Charset;
public class Main {
public static void main(String[] args) {
System.out.println(Charset.defaultCharset());
}
}LANG=C java Main (Java 8)US-ASCII
LANG=C java Main (Java 21)UTF-8
En Java 8, el juego de caracteres por omisión depende del sistema: en un Linux sin idioma configurado (LANG=C) es ASCII (US-ASCII), y en un Windows en español suele ser windows-1252. Desde Java 18 es UTF-8 en todas partes. Un programa que leía archivos sin indicar la codificación puede empezar a mostrar las tildes de otra manera al migrar; lo correcto, en las dos versiones, es indicarla siempre (Files.readString(ruta, StandardCharsets.UTF_8)).
El recolector de basura
java -XX:+PrintCommandLineFlags -version (Java 8)-XX:InitialHeapSize=373306944 -XX:MaxHeapSize=5972911104 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
java -XX:+PrintCommandLineFlags -version (Java 21)-XX:ConcGCThreads=3 -XX:G1ConcRefinementThreads=13 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=373306944 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=5972911104 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:-THPStackMitigation -XX:+UseCompressedOops -XX:+UseG1GC
Java 8 usa por omisión el recolector Parallel, pensado para el rendimiento total con pausas largas; desde Java 9 es G1, que reparte el trabajo para que las pausas sean cortas. Java 21 añade además ZGC generacional para pausas de menos de un milisegundo con montones enormes. Si tu aplicación fija el recolector o sus opciones con parámetros de la JVM heredados de Java 8, revísalos: algunos ya no existen y la JVM se niega a arrancar.
Ejecutar un archivo suelto
java Hola.java (Java 8)Error: Could not find or load main class Hola.java código de salida: 1
java Hola.java (Java 21)Hola desde un solo archivo código de salida: 0
Desde Java 11, java Hola.java compila y ejecuta un archivo sin pasar por javac, útil para guiones y para aprender. Y desde Java 9 existe JShell, una consola para probar código línea a línea.
Lo que se rompe al migrar
Lo anterior son ventajas. Esto es lo que suele dar trabajo al pasar un proyecto de Java 8 a 21.
Java EE ya no viene con el JDK
import javax.xml.bind.DatatypeConverter;
public class Main {
public static void main(String[] args) {
System.out.println(DatatypeConverter.printBase64Binary("Hola".getBytes()));
}
}java 8SG9sYQ==
javac 21Main.java:1: error: package javax.xml.bind does not exist
import javax.xml.bind.DatatypeConverter;
^
Main.java:5: error: cannot find symbol
System.out.println(DatatypeConverter.printBase64Binary("Hola".getBytes()));
^
symbol: variable DatatypeConverter
location: class Main
2 errors
código de salida: 1Java 11 retiró del JDK los módulos de Java EE y CORBA: JAXB (javax.xml.bind), JAX-WS, javax.activation y otros. El código que los usa deja de compilar. La solución es añadirlos como dependencias de Maven o Gradle (hoy, en su versión de Jakarta, con el paquete jakarta.xml.bind) o usar la alternativa estándar, como aquí java.util.Base64.
Las tripas del JDK están cerradas
import java.lang.reflect.Field;
import java.util.ArrayList;
public class Main {
public static void main(String[] args) throws Exception {
ArrayList<String> lista = new ArrayList<>();
lista.add("a");
Field interno = ArrayList.class.getDeclaredField("elementData");
interno.setAccessible(true);
System.out.println("Capacidad interna: " + ((Object[]) interno.get(lista)).length);
}
}java 8Capacidad interna: 10
java 21Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make field transient java.lang.Object[] java.util.ArrayList.elementData accessible: module java.base does not "opens java.util" to unnamed module @2a139a55 at java.base/java.lang.reflect.AccessibleObject.throwInaccessibleObjectException(AccessibleObject.java:391) at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:367) at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:315) at java.base/java.lang.reflect.Field.checkCanSetAccessible(Field.java:183) at java.base/java.lang.reflect.Field.setAccessible(Field.java:177) at Main.main(Main.java:9) código de salida: 1
Con los módulos de Java 9, las clases internas del JDK pasaron a estar encapsuladas, y desde Java 17 ya no hay forma de abrirlas de manera global (la opción --illegal-access desapareció). Las bibliotecas antiguas que hurgaban con reflexión en clases del JDK fallan así; casi siempre basta con actualizarlas a una versión moderna. Como último recurso, --add-opens abre un paquete concreto.
Nashorn, sun.misc y compañía
import javax.script.*;
public class Main {
public static void main(String[] args) throws Exception {
ScriptEngine motor = new ScriptEngineManager().getEngineByName("nashorn");
System.out.println(motor == null ? "No hay motor de JavaScript" : "1 + 2 = " + motor.eval("1 + 2"));
}
}java 81 + 2 = 3
java 21No hay motor de JavaScript
El motor de JavaScript Nashorn se retiró en Java 15 (existe como biblioteca aparte), y con él otras piezas: los applets, javafx (que se distribuye por separado desde Java 11), el SecurityManager (obsoleto desde Java 17) y APIs internas como sun.misc.BASE64Encoder. Para encontrarlas en un proyecto existe jdeps, que viene con el JDK:
jdeps --jdk-internals Viejo.class (con el JDK 21, sobre una clase compilada con Java 8)Viejo.class -> JDK removed internal API Viejo -> sun.misc.BASE64Encoder JDK removed internal API Warning: JDK internal APIs are unsupported and private to JDK implementation that are subject to be removed or changed incompatibly and could break your application. Please modify your code to eliminate dependence on any JDK internal APIs. For the most recent update on JDK internal API replacements, please check: https://wiki.openjdk.org/display/JDK8/Java+Dependency+Analysis+Tool JDK Internal API Suggested Replacement ---------------- --------------------- sun.misc.BASE64Encoder Use java.util.Base64 @since 1.8
jdeps lista cada uso de una API interna y propone su sustituta. Su compañero jdeprscan busca lo que está marcado como obsoleto.
Cómo migrar de Java 8 a 21
- Actualiza primero las herramientas: Maven, Gradle y sus plugins, y las bibliotecas (Spring, Hibernate, Lombok, Mockito…), a versiones que admitan Java 21. Es la parte que más problemas resuelve sola.
- Pasa
jdeps --jdk-internalsyjdeprscansobre tus JAR y los de tus dependencias. - Añade las dependencias de Java EE que antes venían con el JDK, si las usas.
- Compila y prueba con Java 21, pero manteniendo de momento el nivel de lenguaje antiguo si quieres ir por partes:
--release 8. Después sube a--release 21y empieza a usar lo nuevo. - Revisa la configuración de la JVM: opciones de recolector, de memoria y de registro que ya no existen hacen que la JVM no arranque.
- Prueba en un entorno igual al de producción, con atención a la codificación de archivos, las fechas y la memoria.
¿Y si me quedo en Java 8?
Java 8 sigue recibiendo parches de seguridad en las distribuciones gratuitas de OpenJDK, como Eclipse Temurin, que promete mantenerla al menos hasta diciembre de 2030, más que a Java 11 y 17. Pero recibir parches no es lo mismo que estar al día: las bibliotecas nuevas ya no la admiten (Spring Boot 3, por ejemplo, exige Java 17), los desarrolladores la conocen cada vez menos y se pierde todo lo anterior. Y ojo con la licencia: las actualizaciones del Oracle JDK 8 para uso comercial son de pago desde 2019; con una distribución de OpenJDK como Temurin o Amazon Corretto no hay ese problema.
| Java 8 | Java 21 | |
|---|---|---|
| Salió | marzo de 2014 | septiembre de 2023 |
| Soporte en Temurin | al menos hasta dic. de 2030 | al menos hasta dic. de 2029 |
| Lenguaje | lambdas y streams | + var, records, bloques de texto, switch con patrones, clases selladas |
| Concurrencia | hilos del sistema | + hilos virtuales |
| Recolector por omisión | Parallel | G1 |
| Codificación por omisión | la del sistema | UTF-8 |
| Incluye Java EE y Nashorn | sí | no |
| Bibliotecas modernas | cada vez menos | sí (Spring Boot 3 exige 17) |
La conclusión es clara: Java 21 se escribe con menos código, avisa antes de los errores y aguanta mucha más carga con los hilos virtuales. La migración tiene un coste, sobre todo en dependencias antiguas, pero cada año que pasa en Java 8 ese coste crece. Si empiezas un proyecto hoy, la pregunta no es entre 8 y 21, sino entre 21 y 25.
Todo el código se compiló y ejecutó con Eclipse Temurin 1.8.0_504 y 21.0.12.1, en contenedores sin acceso a la red y sin idioma configurado; las salidas están copiadas de esa ejecución (los tiempos de los hilos varían ligeramente en cada una). Fechas de soporte: Eclipse Adoptium. Versión de cada novedad: sus propuestas de mejora (JEP) en openjdk.org.
