Java 8 → 25: qué aporta cada versión, features modernas y migración
Hay dos tipos de programadores Java: los que escriben Java 8 en un JDK 21 y los que escriben Java moderno. La diferencia no es coquetería sintáctica: es menos código, menos bugs, menos memoria y una forma distinta de modelar el dominio. Este módulo recorre versión por versión qué se añadió, por qué se añadió, qué se rompió al añadirlo y cómo se migra un sistema real de Java 8 a Java 17 o 21 sin parar el negocio.
java.time),
que es donde más gente falla en entrevistas. Las secciones imprescindibles son
7 (Java 17), 8 (Java 21) y
11 (migración): son las que te preguntarán y las que usarás el lunes.
Todos los ejemplos se pueden probar con jshell o con java Fichero.java.
1. El modelo de releases de Java: cadencia, LTS y previews
1.1 De “una versión cada 3 años” a “una cada 6 meses”
Hasta Java 8 el modelo era feature driven: se anunciaba una lista de novedades y la versión salía cuando la última estuviera lista. El resultado fue desastroso en plazos: Java 7 se retrasó unos cinco años (los lambdas, prometidos para 7, llegaron en 8) y Java 8 se retrasó otros ocho meses por una revisión de seguridad. Una feature grande y con problemas bloqueaba a todas las demás.
A partir de Java 9 (2017) se adoptó un modelo time driven, formalizado en el JEP 322:
- Una versión cada 6 meses, en marzo y septiembre, sin excepciones. La fecha es inamovible; lo que se mueve es el contenido. Si una feature no llega, sale en la siguiente.
- Versionado por fecha:
$FEATURE.$INTERIM.$UPDATE.$PATCH. En la práctica ves21.0.5, donde21es la versión de features y0.5son parches de seguridad trimestrales. - Ramas de estabilización: la rama se corta unos tres meses antes (rampdown) y ya solo entran correcciones.
- Versiones LTS (Long-Term Support): reciben actualizaciones de seguridad y correcciones durante años. Las no-LTS solo viven 6 meses, hasta que sale la siguiente.
EL TREN DE RELEASES DE JAVA (marzo / septiembre de cada año)
2018 2019 2020 2021 2022 2023 2024 2025 2026
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│10 11│ │12 13│ │14 15│ │16 17│ │18 19│ │20 21│ │22 23│ │24 25│ │26 …│
└──▲─┘ └────┘ └────┘ └──▲─┘ └────┘ └──▲─┘ └────┘ └──▲─┘ └────┘
│ │ │ │
LTS 11 LTS 17 LTS 21 LTS 25
│◄──────── 3 años ─────────►│◄──── 2 años ────►│◄──── 2 años ────►│
· Las versiones NO-LTS (12,13,14,15,16,18,19,20,22,23,24,26…) se mantienen 6 meses.
· Sirven para PROBAR features en preview y dar feedback, no para producción de larga vida.
· Desde Java 21 la cadencia de LTS bajó de 3 años a 2 años.
CICLO DE VIDA DE UNA FEATURE GRANDE (ejemplo real: pattern matching for switch)
JEP 406 JEP 420 JEP 427 JEP 433 JEP 441
Java 17 Java 18 Java 19 Java 20 Java 21
preview 1 ──► preview 2 ──► preview 3 ──► preview 4 ──► ESTÁNDAR
(feedback) (ajustes) (ajustes) (ajustes) (para siempre)
1.2 Preview, incubator y experimental: tres cosas distintas
Java no puede permitirse equivocarse con una feature del lenguaje: una vez estándar, hay que mantenerla
para siempre (véase la serialización nativa o Date). De ahí los tres mecanismos de
prueba, que conviene no confundir:
| Mecanismo | Qué es | Cómo se activa | Garantías |
|---|---|---|---|
| Preview feature lenguaje o API del JDK |
Feature completa y con calidad de producción, pero cuyo diseño aún puede cambiar según el feedback. Ej.: records en 14–15, pattern matching for switch en 17–20. | javac --release 21 --enable-preview y java --enable-preview |
Puede cambiar o desaparecer en la siguiente versión. El .class queda
atado a esa versión exacta del JDK. |
| Incubator module solo APIs |
API nueva y grande que se distribuye en un módulo aparte con nombre jdk.incubator.*.
Ej.: HttpClient en 9–10, Vector API. |
--add-modules jdk.incubator.vector |
Puede cambiar radicalmente. Al estabilizarse cambia de paquete, así que hay que
reescribir los import. |
| Experimental (VM) opciones de la JVM |
Funcionalidad del runtime (GC, JIT) todavía no lista. Ej.: ZGC en 11–14, compact object headers en 24. | -XX:+UnlockExperimentalVMOptions -XX:+UseXxx |
Puede tener bugs o desaparecer. No hay compromiso de compatibilidad. |
| Deprecated for removal | El camino de salida: @Deprecated(forRemoval = true) avisa de que la API va a
desaparecer. Ej.: finalize(), SecurityManager. |
Se detecta con javac -Xlint:removal y jdeprscan |
Se eliminará. Es una orden, no una sugerencia. |
# Compilar y ejecutar código con features en preview (ejemplo con Java 21)
javac --release 21 --enable-preview Ejemplo.java
java --enable-preview Ejemplo
# Un solo fichero fuente, sin compilar aparte
java --enable-preview --source 21 Ejemplo.java
# Módulo en incubación (Vector API)
javac --add-modules jdk.incubator.vector Vectorial.java
java --add-modules jdk.incubator.vector Vectorial
# Ver qué APIs deprecadas usa tu jar
jdeprscan --release 21 mi-aplicacion.jar
jdeprscan --for-removal --release 21 mi-aplicacion.jar
.class compilado con
--enable-preview en Java 21 no arranca en Java 22. La JVM lanza
UnsupportedClassVersionError: … was compiled with preview features … which is not supported by
this version. Es deliberado: impide que código atado a un diseño provisional se cuele en
librerías de terceros. Consecuencia práctica: nunca publiques un artefacto en un repositorio
Maven compilado con preview.
1.3 Tabla de versiones: fechas, tipo y soporte
Las fechas de lanzamiento son hechos; las de fin de soporte dependen del proveedor (Oracle, Adoptium, Amazon, Azul y Red Hat publican calendarios distintos), así que trátalas como orientación y confirma siempre en la web de tu distribución antes de comprometerte con un cliente.
| Versión | Fecha | Tipo | Titular | Soporte |
|---|---|---|---|---|
| 8 | mar 2014 | LTS | Lambdas, Streams, Optional, java.time | Extendido por varios proveedores hasta finales de esta década; Oracle pide suscripción para uso comercial desde 2019 |
| 9 | sep 2017 | — | JPMS (módulos), jshell, List.of, compact strings | Terminado (6 meses) |
| 10 | mar 2018 | — | var, List.copyOf, conciencia de contenedores | Terminado |
| 11 | sep 2018 | LTS | HttpClient estándar, salida de javax.* EE, TLS 1.3, JFR libre | Actualizaciones de seguridad de la comunidad y proveedores durante años; Oracle ofrece soporte extendido de pago |
| 12 | mar 2019 | — | switch expressions (preview), Shenandoah | Terminado |
| 13 | sep 2019 | — | Text blocks (preview), yield | Terminado |
| 14 | mar 2020 | — | switch expressions estándar, records (preview), NPE útiles | Terminado |
| 15 | sep 2020 | — | Text blocks estándar, sealed (preview), ZGC en producción | Terminado |
| 16 | mar 2021 | — | Records estándar, instanceof con patrón estándar, Stream.toList() | Terminado |
| 17 | sep 2021 | LTS | Sealed classes, encapsulación fuerte por defecto, RandomGenerator | Mínimo de Spring Boot 3; soporte amplio de todos los proveedores |
| 18 | mar 2022 | — | UTF-8 por defecto, servidor web simple | Terminado |
| 19 | sep 2022 | — | Virtual threads (preview), record patterns (preview) | Terminado |
| 20 | mar 2023 | — | Iteraciones de Loom y de pattern matching | Terminado |
| 21 | sep 2023 | LTS | Virtual threads, pattern matching completo, sequenced collections | El objetivo razonable para migrar hoy |
| 22 | mar 2024 | — | Foreign Function & Memory estable, patrones _ | Terminado |
| 23 | sep 2024 | — | ZGC generacional por defecto, Javadoc en Markdown | Terminado |
| 24 | mar 2025 | — | Stream gatherers y Class-File API estables, AOT class loading | Terminado |
| 25 | sep 2025 | LTS | Ficheros fuente compactos, import module, scoped values, mejoras de arranque | El LTS más reciente; destino natural de los proyectos nuevos |
| 26 y siguientes | mar 2026 → | — | Continúa la cadencia de 6 meses; siguiente LTS previsto dos años después de 25 | Consultar el calendario oficial de OpenJDK |
1.4 Qué distribución elegir (y qué licencia estás firmando)
Aquí hay una confusión que cuesta dinero de verdad. OpenJDK es el proyecto de código abierto donde se desarrolla Java, bajo licencia GPLv2 con Classpath Exception. Esa excepción es la clave: te permite enlazar tu código propietario con la librería estándar sin que tu aplicación tenga que ser GPL. Lo que descargas de cada proveedor es una build (compilación, empaquetado y parches) de ese mismo código, certificada contra el TCK.
| Distribución | Quién la hace | Licencia / coste | Cuándo la elijo |
|---|---|---|---|
| Eclipse Temurin (Adoptium) | Eclipse Foundation (con Red Hat, Azul, IBM, Microsoft…) | GPLv2+CPE. Gratis, sin registro, sin condiciones | Opción por defecto y la respuesta correcta si te preguntan «¿qué JDK instalas?». Neutral, sin vendedor detrás |
| Amazon Corretto | Amazon | GPLv2+CPE. Gratis | Si vives en AWS (Lambda, Beanstalk, ECS). Compromiso público de soporte largo para 8, 11, 17, 21… |
| Azul Zulu | Azul Systems | Builds gratis GPLv2+CPE; soporte y Azul Platform Prime de pago | Cuando necesitas soporte comercial sin Oracle, o builds de versiones antiguas/arquitecturas raras. Prime aporta un JIT y un GC propios para latencias extremas |
| Oracle JDK | Oracle | ⚠️ Dos licencias: NFTC (gratis, incluso en producción, hasta un año después del siguiente LTS) y OTN (requiere suscripción) | Solo si ya pagas la Java SE Universal Subscription (facturada por empleado, no por servidor). Técnicamente es casi idéntico a Temurin |
| GraalVM | Oracle (Oracle GraalVM) y comunidad (GraalVM CE) | CE: GPLv2+CPE. Oracle GraalVM: condiciones propias sin coste para muchos usos, revísalas | Cuando quieres native-image (binario nativo, arranque en milisegundos, poca RAM) o el JIT Graal. Ver módulo 11 |
| Red Hat build of OpenJDK | Red Hat | Incluido en la suscripción de RHEL/OpenShift | Si tu plataforma ya es Red Hat y quieres un único proveedor de soporte |
| Microsoft Build of OpenJDK | Microsoft | GPLv2+CPE. Gratis | Azure, y builds para Windows/ARM |
| BellSoft Liberica | BellSoft | Gratis; soporte de pago | Builds “Lite” muy pequeñas para contenedores y builds con JavaFX incluido |
| IBM Semeru (OpenJ9) | IBM | Gratis | Cuando importa la huella de memoria: la VM OpenJ9 suele consumir bastante menos que HotSpot, a cambio de otro perfil de rendimiento |
java.com u
oracle.com y ponerlo en 300 servidores. Desde enero de 2019 el Oracle JDK 8
requiere suscripción para uso comercial en producción, y las auditorías existen. Desde Java 17 Oracle
publica bajo NFTC (gratis, incluso comercialmente) pero solo hasta un año después de
que salga el siguiente LTS: pasado ese plazo, seguir recibiendo parches de esa versión exige pagar.
La forma de no pensar nunca más en esto: usa Temurin o Corretto.
# Saber exactamente qué JDK tienes delante (hazlo en producción, no en tu portátil)
java -version # versión y vendedor
java -XshowSettings:properties -version 2>&1 | grep -E 'java.vendor|java.version|java.home'
jcmd <pid> VM.version # sobre un proceso ya en marcha
# Gestionar varias versiones en local sin volverse loco
sdk list java # SDKMAN! (recomendado en Linux/macOS)
sdk install java 21.0.5-tem # Temurin 21
sdk use java 21.0.5-tem
# En Docker: imagen concreta, nunca "latest"
# FROM eclipse-temurin:21-jre-alpine
# FROM amazoncorretto:21-alpine
# Ver módulo 09 para multi-stage builds y jlink
Checklist — modelo de releases
2. Java 8 (2014): la base sobre la que todo el mundo sigue apoyado
Java 8 no fue una versión más: fue un cambio cultural. Introdujo programación funcional en un lenguaje profundamente imperativo y orientado a objetos, y lo hizo con una restricción brutal: no romper nada de los 18 años anteriores. Casi todo lo que hoy consideras «Java normal» viene de aquí.
2.1 Lambdas: por qué se implementaron como se implementaron
Antes de Java 8, pasar comportamiento a un método requería una clase anónima: cuatro líneas de ceremonia
para expresar una idea de media línea. Las lambdas eliminan ese ruido, pero no son «clases anónimas con
mejor sintaxis»: se compilan con invokedynamic y LambdaMetafactory, de modo que
la clase de la lambda se genera en tiempo de ejecución y las lambdas sin captura de estado se
reutilizan como instancia única.
// ❌ Java 7 y anterior: 5 líneas para decir "compara por nombre"
Collections.sort(personas, new Comparator<Persona>() {
@Override
public int compare(Persona a, Persona b) { // ruido: firma completa obligatoria
return a.getNombre().compareTo(b.getNombre()); // la única línea que importa
}
});
// ✅ Java 8: lambda
personas.sort((a, b) -> a.getNombre().compareTo(b.getNombre()));
// ✅ Mejor todavía: referencia a método + comparador compuesto (declarativo y sin errores de signo)
personas.sort(Comparator.comparing(Persona::getNombre)
.thenComparing(Persona::getEdad, Comparator.reverseOrder()));
// Consecuencia práctica de invokedynamic: una lambda SIN captura no crea objeto en cada llamada
Runnable sinCaptura = () -> System.out.println("hola"); // instancia única reutilizada
int n = calcular();
Runnable conCaptura = () -> System.out.println(n); // sí crea objeto: captura 'n'
// Regla del "effectively final": una lambda solo captura variables que no cambian
int contador = 0;
List.of(1, 2, 3).forEach(x -> contador += x); // ❌ NO COMPILA: contador no es effectively final
// ✅ Alternativas correctas
int suma = List.of(1, 2, 3).stream().mapToInt(Integer::intValue).sum();
var acumulador = new java.util.concurrent.atomic.AtomicInteger(); // si de verdad necesitas mutar
effectively final: una lambda puede sobrevivir al método que
la creó (guardarse en un campo, ejecutarse en otro hilo). Si capturase la variable local por
referencia, apuntaría a un marco de pila que ya no existe. Java copia el valor, y para que la copia no
mienta exige que el original no cambie. Es la misma razón por la que las clases anónimas exigían
final explícito antes de Java 8.
2.2 Streams: qué añaden y qué no
Un Stream no es una colección: es una tubería de operaciones perezosas sobre
una fuente. No almacena datos, no se puede recorrer dos veces y solo hace trabajo cuando llega una
operación terminal. Su valor es la expresividad: describes el qué y no el cómo.
El detalle completo está en Java Core §12; aquí lo que importa es
su papel histórico y las trampas que aún se preguntan.
record Pedido(String id, String region, String cliente, BigDecimal total, LocalDate fecha) {}
// Agrupar y agregar en una sola pasada declarativa
Map<String, BigDecimal> facturacionPorRegion = pedidos.stream()
.filter(p -> p.fecha().getYear() == 2026)
.collect(Collectors.groupingBy(
Pedido::region,
TreeMap::new, // mapa ordenado, resultado determinista
Collectors.reducing(BigDecimal.ZERO, Pedido::total, BigDecimal::add)));
// Top 3 clientes por importe
List<String> top3 = pedidos.stream()
.collect(Collectors.groupingBy(Pedido::cliente,
Collectors.reducing(BigDecimal.ZERO, Pedido::total, BigDecimal::add)))
.entrySet().stream()
.sorted(Map.Entry.<String, BigDecimal>comparingByValue().reversed())
.limit(3)
.map(Map.Entry::getKey)
.toList(); // Java 16+; en Java 8 era .collect(Collectors.toList())
// ❌ Trampas clásicas
pedidos.stream().forEach(p -> total = total.add(p.total())); // efecto lateral sobre estado externo
pedidos.stream().map(p -> { guardar(p); return p; }); // map con efectos: no se ejecuta si no hay terminal
Stream<Pedido> s = pedidos.stream();
s.count(); s.count(); // IllegalStateException: stream ya consumido
pedidos.parallelStream().forEach(this::llamarApiRemota); // bloqueo en el commonPool: mata la app
// ✅ Las mismas ideas, bien hechas
BigDecimal total = pedidos.stream().map(Pedido::total).reduce(BigDecimal.ZERO, BigDecimal::add);
pedidos.forEach(this::guardar); // si solo quieres iterar, usa forEach de la colección
try (var ejecutor = Executors.newVirtualThreadPerTaskExecutor()) { // Java 21: E/S concurrente de verdad
pedidos.forEach(p -> ejecutor.submit(() -> llamarApiRemota(p)));
}
2.3 Optional: para lo que se diseñó y para lo que no
Optional nació con un propósito muy concreto: ser el tipo de retorno de
métodos que pueden no encontrar nada, de forma que el compilador te recuerde el caso vacío. No es un
sustituto universal de null ni un contenedor de propósito general.
// ❌ Antipatrones que verás en código real
Optional<Usuario> u = repo.buscar(id);
if (u.isPresent()) { return u.get(); } else { throw new NotFound(); } // if/get = el null de siempre
public void guardar(Optional<String> nombre) { } // ❌ nunca como parámetro
private Optional<String> apellido; // ❌ nunca como campo (ni serializable)
Optional<List<Pedido>> pedidos(); // ❌ devuelve List.of() vacía, no Optional
if (opt != null) { } // ❌ un Optional nunca debe ser null
// ✅ Uso idiomático: encadenar y resolver en un solo sitio
String ciudad = repo.buscar(id)
.map(Usuario::direccion)
.map(Direccion::ciudad)
.filter(c -> !c.isBlank())
.orElse("desconocida");
Usuario usuario = repo.buscar(id)
.orElseThrow(() -> new UsuarioNoEncontrado(id)); // Java 10 añadió orElseThrow() sin argumentos
// ✅ Añadidos posteriores que lo hacen mucho más usable (Java 9 y 11)
repo.buscar(id).ifPresentOrElse(this::procesar, () -> log.warn("sin usuario {}", id)); // Java 9
Optional<Usuario> resuelto = repo.buscar(id).or(() -> repoSecundario.buscar(id)); // Java 9
List<Usuario> encontrados = ids.stream()
.map(repo::buscar)
.flatMap(Optional::stream) // Java 9: descarta vacíos sin filter+get
.toList();
boolean nada = repo.buscar(id).isEmpty(); // Java 11
// ⚠️ orElse vs orElseGet: orElse EVALÚA SIEMPRE su argumento
String v1 = opt.orElse(consultaCara()); // ❌ llama a consultaCara() aunque haya valor
String v2 = opt.orElseGet(this::consultaCara); // ✅ perezoso
2.4 java.time en profundidad: el tema que más se falla
Antes de Java 8, el tiempo en Java era una vergüenza documentada: Date es mutable y en
realidad representa un instante (no una fecha), Calendar numera los meses desde 0, y
SimpleDateFormat no es thread-safe, lo que produce fechas corruptas de forma
intermitente e imposible de reproducir. java.time (JSR 310, diseñado por el autor de
Joda-Time) lo sustituye con tipos inmutables, thread-safe y semánticamente explícitos.
La clave para no equivocarse es entender que hay dos formas distintas de hablar del tiempo: el tiempo de máquina (un punto absoluto en la línea temporal, un número) y el tiempo humano/civil (año, mes, día, hora tal como los usa una persona en un lugar). Confundirlas es el origen del 90% de los bugs de fechas.
┌──────────────────────────────────────────────────────────────────────────────────┐
│ ÁRBOL DE DECISIÓN: ¿QUÉ TIPO DE java.time USO? │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ¿Represento un PUNTO EXACTO en la línea temporal universal? │
│ │ │
│ ├─ SÍ ──► ¿me interesa además el offset/zona de quien lo observó? │
│ │ ├─ NO ──► Instant "cuándo ocurrió" (logs, métricas, │
│ │ │ created_at, eventos, caducidades) │
│ │ ├─ offset fijo ──► OffsetDateTime instante + "+02:00" │
│ │ │ (APIs REST, columnas TIMESTAMP │
│ │ │ WITH TIME ZONE, ISO-8601) │
│ │ └─ región ──────► ZonedDateTime instante + "Europe/Madrid", │
│ │ con reglas de horario de verano │
│ │ (agendas, "todos los lunes a las │
│ │ 9:00 en Madrid") │
│ │ │
│ └─ NO ──► descripción civil SIN punto absoluto │
│ ├─ solo fecha ──► LocalDate cumpleaños, fecha de factura │
│ ├─ solo hora ──► LocalTime horario de apertura: 09:00 │
│ └─ ambas ──► LocalDateTime ⚠️ NO identifica un instante: │
│ "2026-03-29T02:30" puede no existir │
│ │
│ CANTIDADES DE TIEMPO │
│ Duration → basada en segundos/nanos (máquina): timeouts, latencias, "2 horas" │
│ Period → basada en calendario (humana): "1 mes", "3 años" ⚠️ dura distinto │
│ según el mes │
└──────────────────────────────────────────────────────────────────────────────────┘
REGLA MNEMOTÉCNICA
Local* = "sin ancla" → no puedes saber cuántos milisegundos son desde 1970
Instant = "solo el ancla" → no sabes qué hora marcaba el reloj de nadie
Offset/Zoned= "ancla + reloj" → lo único que sirve para agendar o mostrar
// ───── 1. Los cuatro tipos con fecha y hora, comparados ─────────────────────────
Instant ahoraUtc = Instant.now(); // 2026-07-31T15:25:00.123456Z
LocalDateTime local = LocalDateTime.now(); // 2026-07-31T17:25:00.123456 (¿en qué zona? nadie lo sabe)
ZonedDateTime zonificado = ZonedDateTime.now(ZoneId.of("Europe/Madrid"));
// 2026-07-31T17:25+02:00[Europe/Madrid]
OffsetDateTime conOffset = OffsetDateTime.now(ZoneOffset.UTC); // 2026-07-31T15:25Z
// LocalDateTime NO se puede convertir a Instant sin aportar una zona: falta información
Instant desdeLocal = local.atZone(ZoneId.of("Europe/Madrid")).toInstant(); // ✅ explícito
Instant peligroso = local.toInstant(ZoneOffset.UTC); // ⚠️ solo si SABES que es UTC
// De Instant a algo mostrable
ZonedDateTime paraElUsuario = ahoraUtc.atZone(ZoneId.of("Europe/Madrid"));
OffsetDateTime paraLaApi = ahoraUtc.atOffset(ZoneOffset.UTC);
// ───── 2. Duration vs Period: no son intercambiables ────────────────────────────
Duration timeout = Duration.ofSeconds(30);
Duration entre = Duration.between(inicio, fin); // segundos + nanos
long ms = entre.toMillis();
long horas = entre.toHours();
long minutosResto= entre.toMinutesPart(); // Java 9: partes sin aritmética manual
Duration parseada= Duration.parse("PT2H30M"); // ISO-8601: 2 h 30 min
Period contrato = Period.of(1, 6, 0); // 1 año y 6 meses
Period edad = Period.between(LocalDate.of(1990, 5, 17), LocalDate.now());
System.out.printf("%d años, %d meses, %d días%n", edad.getYears(), edad.getMonths(), edad.getDays());
// ⚠️ "Un mes" no es una cantidad fija de tiempo
LocalDate finEnero = LocalDate.of(2026, 1, 31);
finEnero.plus(Period.ofMonths(1)); // 2026-02-28 → se ajusta al último día válido
finEnero.plus(Duration.ofDays(30)); // ❌ UnsupportedTemporalTypeException: LocalDate no tiene horas
finEnero.plusDays(30); // ✅ 2026-03-02
// Para contar unidades enteras, ChronoUnit es más claro que Period
long dias = ChronoUnit.DAYS.between(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 7, 31));
// ───── 3. Formateo: inmutable y thread-safe (¡al fin!) ──────────────────────────
// ✅ Constantes estáticas: DateTimeFormatter SÍ se puede compartir entre hilos
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
private static final DateTimeFormatter ES =
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm", new Locale("es", "ES"));
private static final DateTimeFormatter HUMANO =
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(Locale.forLanguageTag("es-ES"));
String texto = conOffset.format(ISO);
LocalDate leida = LocalDate.parse("31/07/2026", DateTimeFormatter.ofPattern("dd/MM/yyyy"));
// ⚠️ 'y' es "year of era", 'u' es "year". Con fechas antes de Cristo o con 'G' cambian.
// Y el clásico: 'YYYY' (week-based-year) en lugar de 'yyyy' produce el bug de fin de año.
DateTimeFormatter mal = DateTimeFormatter.ofPattern("YYYY-MM-dd"); // ❌ 2026-12-31 → "2027-12-31"
DateTimeFormatter bien = DateTimeFormatter.ofPattern("yyyy-MM-dd"); // ✅
// ───── 4. Aritmética y ajustadores ──────────────────────────────────────────────
LocalDate proximoLunes = LocalDate.now().with(TemporalAdjusters.next(DayOfWeek.MONDAY));
LocalDate finDeMes = LocalDate.now().with(TemporalAdjusters.lastDayOfMonth());
LocalDate primerViernes = LocalDate.now().with(TemporalAdjusters.firstInMonth(DayOfWeek.FRIDAY));
LocalDateTime medianoche = LocalDate.now().atStartOfDay();
Instant caduca = Instant.now().plus(15, ChronoUnit.MINUTES);
boolean solapa = !fin1.isBefore(inicio2) && !fin2.isBefore(inicio1);
// ───── 5. El horario de verano, donde se rompe todo ─────────────────────────────
ZoneId madrid = ZoneId.of("Europe/Madrid");
// En 2026 el cambio a horario de verano en España fue el 29 de marzo: 02:00 → 03:00
ZonedDateTime inexistente = LocalDateTime.of(2026, 3, 29, 2, 30).atZone(madrid);
// → 2026-03-29T03:30+02:00 (java.time NO falla: adelanta al instante válido siguiente)
// En el cambio de octubre, las 02:30 ocurren DOS veces (ambigüedad)
ZonedDateTime ambigua = LocalDateTime.of(2026, 10, 25, 2, 30).atZone(madrid);
ambigua.withEarlierOffsetAtSameInstant(); // elige el primer pase (+02:00)
ambigua.withLaterOffsetAtSameInstant(); // elige el segundo (+01:00)
// ⚠️ "Sumar 24 horas" y "sumar 1 día" NO son lo mismo cerca de un cambio de hora
ZonedDateTime base = LocalDateTime.of(2026, 3, 28, 12, 0).atZone(madrid);
base.plusDays(1); // 2026-03-29T12:00+02:00 → misma hora de reloj (23 h reales)
base.plus(Duration.ofDays(1)); // 2026-03-29T13:00+02:00 → 24 h exactas de tiempo físico
// ───── 6. Tests deterministas: inyecta un Clock, nunca llames a now() dentro ────
public class ServicioCaducidad {
private final Clock reloj; // ✅ dependencia explícita
public ServicioCaducidad(Clock reloj) { this.reloj = reloj; }
public boolean haCaducado(Instant limite) { return reloj.instant().isAfter(limite); }
}
// En el test:
Clock fijo = Clock.fixed(Instant.parse("2026-07-31T10:00:00Z"), ZoneOffset.UTC);
var servicio = new ServicioCaducidad(fijo); // resultado siempre igual
Migración desde Date, Calendar y SimpleDateFormat
// Puentes de conversión que existen precisamente para migrar poco a poco
Date viejo = new Date();
Instant nuevo = viejo.toInstant(); // Date → Instant
Date otra = Date.from(Instant.now()); // Instant → Date
Calendar cal = Calendar.getInstance();
Instant i2 = cal.toInstant();
ZonedDateTime z2 = ((GregorianCalendar) cal).toZonedDateTime();
GregorianCalendar g= GregorianCalendar.from(ZonedDateTime.now());
TimeZone tz = TimeZone.getDefault();
ZoneId zona = tz.toZoneId();
// JDBC / JPA: usa directamente los tipos de java.time (JPA 2.2+ y JDBC 4.2+ los soportan)
java.sql.Date sqlDate = java.sql.Date.valueOf(LocalDate.now());
LocalDate ld = sqlDate.toLocalDate();
java.sql.Timestamp ts = java.sql.Timestamp.from(Instant.now());
Instant i3 = ts.toInstant();
// ✅ Preferible: rs.getObject("creado_en", OffsetDateTime.class) y ps.setObject(1, instant)
// ❌ El bug que produce fechas corruptas en producción una vez cada mil peticiones
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd"); // ¡NO es thread-safe!
// ✅ Su equivalente correcto
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
| API antigua | Equivalente moderno | Por qué cambiar |
|---|---|---|
new Date() | Instant.now() | Date es mutable y su nombre engaña: no es una fecha, es un instante |
new Date(y, m, d) | LocalDate.of(y, m, d) | Sin años base 1900 ni meses base 0 |
Calendar.add(...) | plusDays, minusMonths, with(...) | Inmutable: no muta el original ni se comparte por accidente |
SimpleDateFormat | DateTimeFormatter | Thread-safe y reutilizable como constante |
TimeZone | ZoneId / ZoneOffset | Separa «región con reglas» de «desplazamiento fijo» |
long millis para durar | Duration | Autodocumentado: nadie se pregunta si son segundos o milisegundos |
System.currentTimeMillis() | Instant.now(clock) | Testeable inyectando un Clock |
System.nanoTime() | sigue siendo correcto para medir | Es un reloj monótono, no una fecha: no lo formatees nunca |
Instant.now() tenía
precisión de milisegundos. Desde Java 9 el reloj del sistema ofrece
microsegundos (o más) en la mayoría de plataformas. Si guardas un Instant
en una columna con precisión de milisegundos y luego comparas con assertEquals, el test
empieza a fallar tras subir de versión. Solución: truncatedTo(ChronoUnit.MILLIS) antes de
persistir o comparar.
2.5 Métodos default y static en interfaces
Se añadieron por una necesidad práctica: para poder meter stream() y forEach() en
Collection e Iterable sin romper las miles de implementaciones
existentes en el mundo. Es evolución de interfaces sin ruptura binaria, no herencia múltiple por
la puerta de atrás.
public interface Notificador {
void enviar(String destino, String mensaje); // abstracto: lo implementa cada uno
// default: comportamiento heredable, añadido después sin romper a nadie
default void enviarAVarios(List<String> destinos, String mensaje) {
destinos.forEach(d -> enviar(d, mensaje));
}
// static: utilidad relacionada, sin necesidad de una clase *Utils
static Notificador noOperativo() { return (d, m) -> { }; }
// private (Java 9): factorizar código común entre defaults sin exponerlo
private static String normalizar(String s) { return s == null ? "" : s.strip(); }
}
// Conflicto de defaults: el compilador NO adivina, te obliga a decidir
interface A { default String saludo() { return "A"; } }
interface B { default String saludo() { return "B"; } }
class C implements A, B {
@Override public String saludo() { return A.super.saludo(); } // desambiguación explícita
}
// Reglas de resolución, por si te lo preguntan:
// 1. La clase gana sobre cualquier interfaz.
// 2. La subinterfaz más específica gana sobre la superinterfaz.
// 3. Si hay empate entre interfaces "hermanas", error de compilación → resuélvelo tú.
default: es una herramienta de compatibilidad, no de diseño. Una
interfaz con lógica de negocio en métodos default no puede tener estado, no se puede testear
aisladamente con facilidad y acaba siendo una clase abstracta peor. Si necesitas estado o plantillas de
algoritmo, usa una clase abstracta o composición (ver Java Core §8.2).
2.6 CompletableFuture (mención)
Java 8 sustituyó el inútil Future (solo get() bloqueante) por
CompletableFuture, que permite composición asíncrona: encadenar,
combinar, gestionar errores y aplicar timeouts sin bloquear hilos. Fue durante una década la única forma
seria de hacer concurrencia de E/S en Java… y la razón de que el código se volviera ilegible. Java 21 lo
hace en gran medida innecesario con virtual threads.
// El estilo Java 8: potente pero difícil de leer y de depurar (stack traces inútiles)
CompletableFuture<Perfil> futuro = CompletableFuture
.supplyAsync(() -> usuarioApi.buscar(id), ejecutor)
.thenCombine(CompletableFuture.supplyAsync(() -> pedidosApi.ultimos(id), ejecutor),
Perfil::new)
.orTimeout(3, TimeUnit.SECONDS) // Java 9
.exceptionally(e -> Perfil.vacio(id));
// El mismo caso en Java 21 con virtual threads: secuencial, depurable, con try/catch normal
try (var scope = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Usuario> u = scope.submit(() -> usuarioApi.buscar(id));
Future<List<Pedido>> p = scope.submit(() -> pedidosApi.ultimos(id));
return new Perfil(u.get(), p.get());
}
El tratamiento completo de CompletableFuture, virtual threads, structured
concurrency y el modelo de memoria de Java está en módulo 03.
2.7 Metaspace y otros cambios silenciosos de Java 8
- Adiós a PermGen, hola Metaspace. Los metadatos de clases dejaron de vivir en una zona
del heap de tamaño fijo (
-XX:MaxPermSize) y pasaron a memoria nativa que crece sola. Consecuencia: el míticoOutOfMemoryError: PermGen spacedesapareció, pero apareció otro más traicionero:OutOfMemoryError: Metaspace, que llega cuando fugas ClassLoaders (redespliegues) o generas clases dinámicamente sin control. En producción conviene poner un límite (-XX:MaxMetaspaceSize=256m) precisamente para que el problema se manifieste pronto y no se coma la RAM del nodo. java.util.Base64: por fin un codificador estándar, en vez desun.misc.BASE64Encoder(que además desapareció más tarde) o Apache Commons.ConcurrentHashMapreescrito (sin segmentos, con CAS y arbolización) y nuevosLongAdder,StampedLock,CompletableFuture.- Métodos
defaultenMap:getOrDefault,computeIfAbsent,merge,forEach,putIfAbsent. Son de los añadidos que más código eliminan a diario. - Anotaciones repetibles y anotaciones de tipo (base de
Checker Framework y de
@NonNullen posiciones genéricas). Arrays.parallelSort,StringJoiner,String.join,Comparator.comparing.- Nashorn como motor JavaScript (deprecado en 11 y eliminado en 15: si tu app «ejecuta scripts», esto te afecta).
// Los métodos default de Map: el ahorro de código menos glamuroso y más útil
Map<String, List<Pedido>> porCliente = new HashMap<>();
// ❌ Java 7
List<Pedido> lista = porCliente.get(cliente);
if (lista == null) { lista = new ArrayList<>(); porCliente.put(cliente, lista); }
lista.add(pedido);
// ✅ Java 8
porCliente.computeIfAbsent(cliente, k -> new ArrayList<>()).add(pedido);
Map<String, Integer> conteo = new HashMap<>();
conteo.merge(palabra, 1, Integer::sum); // contador en una línea
int visitas = conteo.getOrDefault(clave, 0);
conteo.forEach((k, v) -> log.info("{} = {}", k, v));
2.8 Por qué tantísimo código sigue en Java 8 (y qué se está perdiendo)
No es pereza: hay razones históricas concretas, y conviene conocerlas porque en una entrevista te preguntarán «¿por qué migrar?» y la respuesta debe demostrar que entiendes el coste, no solo el beneficio.
| Razón real por la que se quedaron en 8 | Qué pasó después |
|---|---|
| El miedo a JPMS. Java 9 rompió librerías que usaban reflexión sobre internos del JDK y el mensaje que llegó fue «Java 9 rompe todo». | Casi ninguna aplicación necesita modularizarse; el classpath sigue funcionando perfectamente. El susto fue mayor que el problema. |
| Java 9 y 10 duraban 6 meses. Nadie migra un ERP a una versión con soporte semestral, así que la práctica fue «esperamos al siguiente LTS»… durante cuatro años. | Java 11 (2018) fue el primer LTS del nuevo modelo, y 17 (2021) el que consolidó la migración masiva. |
| Servidores de aplicaciones certificados solo para 8 (WebSphere, WebLogic, JBoss antiguos) y frameworks anclados: Spring 4, Spring Boot 1.x, Hibernate 4/5. | Spring Boot 3 exige Java 17 mínimo y jakarta.*: hoy quedarse en 8 significa quedarse sin actualizaciones de Spring. |
| El cambio de licencia de Oracle en 2019 generó parálisis: «¿ahora hay que pagar por Java?». | La respuesta fue Temurin/Corretto/Zulu, gratuitos y certificados. Confusión resuelta, pero costó años. |
| Ausencia de incentivo de negocio. Migrar no añade ninguna funcionalidad visible para el cliente; es coste puro a corto plazo. | El incentivo llegó por seguridad (CVEs), por coste de infraestructura (memoria y arranque) y por contratación: nadie quiere mantener Java 8. |
Qué se pierde exactamente quedándose en Java 8 (esta lista es el argumentario de una migración):
Lenguaje y productividad
var, records, sealed classes, pattern matching, text blocks, switch expressions: menos código repetitivo y modelado de dominio muchísimo más claro.- NPE con mensaje útil («no se puede leer
ciudadporquedirecciones null»): minutos en lugar de horas depurando. Stream.toList(),takeWhile,Collectors.teeing, gatherers.- Compatibilidad con Spring Boot 3, Hibernate 6, JUnit 5 moderno, Micrometer, Testcontainers actuales.
Rendimiento, operación y seguridad
- Virtual threads: miles de peticiones concurrentes con código bloqueante sencillo.
- G1 por defecto, ZGC y Shenandoah: pausas de milisegundos frente a segundos.
- Compact strings (Java 9): hasta ~10–15% menos heap en aplicaciones con muchos
StringASCII, gratis. - CDS/AppCDS y AOT class loading: arranque notablemente más rápido, clave en Kubernetes y serverless.
- TLS 1.3, filtros de deserialización, criptografía moderna y parches de CVE al día.
- JFR y JMC gratis: perfilado en producción con impacto mínimo.
- Contenedores: respeto real de los límites de CPU y memoria del cgroup.
3. Java 9 (2017): módulos y una montaña de pequeñas mejoras
3.1 JPMS: el sistema de módulos que casi nadie adopta pero todos sufren
El problema que quería resolver el Java Platform Module System (JEP 261) era real y grave:
- El classpath es una lista plana y sin control. Dos versiones del mismo jar, y gana la primera que aparezca (jar hell). No hay declaración de dependencias en el propio artefacto.
publicsignificaba «público para todo el mundo». No existía forma de decir «esta clase es pública porque la necesito en otro paquete interno, pero no es API». Resultado: medio ecosistema usandosun.misc.Unsafeycom.sun.*, imposibles de cambiar.- El JDK era un monolito de ~200 MB aunque tu aplicación usase el 5%.
// module-info.java, en la raíz del directorio de fuentes
module com.ejemplo.facturacion {
requires java.sql; // dependencia obligatoria
requires transitive com.ejemplo.dominio; // quien me requiera, obtiene también dominio
requires static org.jetbrains.annotations; // solo en compilación (opcional en runtime)
exports com.ejemplo.facturacion.api; // API pública del módulo
exports com.ejemplo.facturacion.spi to com.ejemplo.plugins; // exportación cualificada
opens com.ejemplo.facturacion.dominio; // permite REFLEXIÓN profunda (Jackson, Hibernate, Spring)
opens com.ejemplo.facturacion.entidad to org.hibernate.orm.core;
uses com.ejemplo.facturacion.spi.Pasarela; // consumo de ServiceLoader
provides com.ejemplo.facturacion.spi.Pasarela
with com.ejemplo.facturacion.impl.PasarelaRedsys; // implementación aportada
}
| Directiva | Significado | Cuándo la necesitas de verdad |
|---|---|---|
requires | Necesito este módulo para compilar y ejecutar | Siempre |
requires transitive | Además lo re-exporto a quien me use | Si tu API pública devuelve tipos de ese módulo |
requires static | Obligatorio al compilar, opcional al ejecutar | Anotaciones, procesadores |
exports | Estos paquetes son visibles para otros módulos en compilación | Tu API |
exports … to | Visible solo para módulos concretos | SPI interno entre módulos propios |
opens | Permite reflexión sobre miembros no públicos en ejecución | Frameworks: Jackson, Hibernate, Spring, JAXB |
uses / provides…with | Declaración de servicios de ServiceLoader | Arquitecturas de plugins |
exports vs opens, la distinción que se pregunta: exports es
para acceso en tiempo de compilación a tipos públicos; opens es para
reflexión en tiempo de ejecución, incluidos miembros privados. Un framework que
deserializa JSON a tu DTO no necesita exports: necesita opens. Por eso
--add-opens es el parche habitual al migrar, no --add-exports.
Por qué casi nadie modulariza su aplicación
- El coste es alto y el beneficio, invisible. Modularizar una aplicación de negocio obliga a que todas tus dependencias sean módulos, y muchas siguen siendo jars planos.
- Los automatic modules son una muleta frágil. Un jar sin
module-infoen el module path se convierte en módulo automático con un nombre derivado del fichero (o deAutomatic-Module-Namesi el autor fue previsor). Ese nombre puede cambiar entre versiones y romper turequires. - Spring Boot no lo necesita: el fat jar con su propio ClassLoader ya resuelve el empaquetado, y Spring depende masivamente de reflexión.
- Maven/Gradle ya declaran dependencias: el valor de «declarar lo que necesito» estaba
cubierto. Lo que JPMS aporta de más (encapsulación fuerte) se logra mejor con
ArchUnito con módulos de build separados.
InaccessibleObjectException: Unable to make field private final … accessible: module java.base does
not "opens java.lang" to unnamed module y la desaparición de javax.xml.bind.
3.2 jlink, jdeps y runtimes a medida
La modularización del JDK sí tiene un beneficio que puedes cobrar hoy mismo sin modularizar tu código: generar un runtime que contenga solo los módulos del JDK que usas. En contenedores eso son decenas de megabytes menos por imagen.
# 1. ¿Qué módulos del JDK necesita realmente mi aplicación?
jdeps --print-module-deps --ignore-missing-deps --multi-release 21 \
--class-path 'libs/*' target/mi-app.jar
# → java.base,java.logging,java.naming,java.sql,java.xml
# 2. Construir un runtime mínimo con esos módulos
jlink --add-modules java.base,java.logging,java.naming,java.sql,java.xml \
--strip-debug --no-header-files --no-man-pages \
--compress=zip-6 \
--output runtime-minimo
du -sh runtime-minimo # ~45-60 MB frente a ~180-330 MB de un JDK completo
# 3. Ejecutar con él
runtime-minimo/bin/java -cp 'target/mi-app.jar:libs/*' com.ejemplo.Main
# Otros usos de jdeps que valen oro al migrar
jdeps --jdk-internals --multi-release 21 target/mi-app.jar # ¿uso APIs internas del JDK?
jdeps -R -summary --class-path 'libs/*' target/mi-app.jar # grafo de dependencias recursivo
jdeps --list-deps target/mi-app.jar # módulos requeridos, formato corto
jlink con una imagen Docker multi-stage: en la
primera etapa un JDK completo compila y genera el runtime; en la segunda, una imagen base mínima
(alpine, distroless) copia solo el runtime y el jar. Se reducen tamaño de imagen,
superficie de ataque y tiempo de pull. Detalles en módulo 09.
3.3 Novedades de API de Java 9: pequeñas y usadísimas
// ───── Factory methods de colecciones: inmutables, concisas ─────────────────────
List<String> l = List.of("a", "b", "c");
Set<Integer> s = Set.of(1, 2, 3);
Map<String, Integer> m = Map.of("a", 1, "b", 2); // hasta 10 pares
Map<String, Integer> grande = Map.ofEntries(Map.entry("a", 1), Map.entry("b", 2));
// ⚠️ Tres diferencias importantes con Arrays.asList / new ArrayList
l.add("d"); // UnsupportedOperationException: son INMUTABLES (no solo "unmodifiable view")
List.of("a", null); // NullPointerException: NO admiten null
Set.of(1, 1); // IllegalArgumentException: duplicados prohibidos
// Y el orden de iteración de Set.of/Map.of NO está especificado y varía entre ejecuciones
// de la JVM (hay una "sal" aleatoria). Si tu test depende del orden, se romperá. Es adrede.
// Comparación con lo anterior
Arrays.asList("a", "b").add("c"); // UnsupportedOperationException (tamaño fijo)
Arrays.asList("a", "b").set(0, "z"); // ✅ permitido: es una VISTA del array
List<String> mutable = new ArrayList<>(List.of("a", "b")); // ✅ si necesitas mutar
// ───── Stream: takeWhile, dropWhile, iterate con predicado, ofNullable ─────────
// takeWhile/dropWhile: como filter, pero se DETIENEN en el primer fallo (orden importa)
List<Integer> numeros = List.of(2, 4, 6, 7, 8, 10);
numeros.stream().takeWhile(n -> n % 2 == 0).toList(); // [2, 4, 6] → para en el 7
numeros.stream().dropWhile(n -> n % 2 == 0).toList(); // [7, 8, 10] → descarta hasta el 7
numeros.stream().filter(n -> n % 2 == 0).toList(); // [2, 4, 6, 8, 10] → recorre todo
// iterate con predicado: el "for" clásico como stream, con final propio
Stream.iterate(1, n -> n < 100, n -> n * 2).forEach(System.out::println); // 1 2 4 8 … 64
// En Java 8 había que escribir: Stream.iterate(1, n -> n * 2).limit(7) (infinito + limit)
Stream<String> seguro = Stream.ofNullable(puedeSerNull); // 0 o 1 elementos, sin if
// ───── Optional: stream, or, ifPresentOrElse ──────────────────────────────────
Optional<Config> c = deFichero().or(() -> deVariableEntorno()).or(ConfigDefecto::instancia);
c.ifPresentOrElse(this::aplicar, () -> log.warn("sin configuración"));
// ───── Otros añadidos de Java 9 que usas sin saberlo ──────────────────────────
" hola ".strip(); // (en realidad Java 11)
Objects.requireNonNullElse(valor, "por defecto");
Objects.requireNonNullElseGet(valor, this::calcular);
byte[] todo = entrada.readAllBytes(); // InputStream
entrada.transferTo(salida); // copiar streams sin bucle manual
LocalDate.of(2026, 1, 1).datesUntil(LocalDate.of(2026, 2, 1)).forEach(System.out::println);
Duration.ofMinutes(150).toHoursPart(); // 2 (partes sin dividir a mano)
ProcessHandle.current().pid(); // PID del proceso actual, al fin estándar
StackWalker.getInstance().walk(f -> f.limit(3).toList()); // stack trace perezoso y barato
// Colectores nuevos
Map<String, List<String>> nombresPorRegion = pedidos.stream().collect(
Collectors.groupingBy(Pedido::region,
Collectors.mapping(Pedido::cliente, Collectors.toList())));
Map<String, Long> grandesPorRegion = pedidos.stream().collect(
Collectors.groupingBy(Pedido::region,
Collectors.filtering(p -> p.total().compareTo(BigDecimal.TEN) > 0, Collectors.counting())));
3.4 jshell, HttpClient incubado, Multi-Release JARs y despedidas
jshell es, con diferencia, la herramienta más útil para estudiar este plan:
un REPL donde probar cualquier fragmento sin crear una clase, un main ni un proyecto.
$ jshell
| Bienvenido a JShell -- Versión 21
jshell> var pedidos = List.of("A", "B", "C")
pedidos ==> [A, B, C]
jshell> pedidos.stream().map(String::toLowerCase).toList()
$2 ==> [a, b, c]
jshell> /imports # ver imports activos (java.util, java.io… ya están)
jshell> /vars # variables declaradas
jshell> /edit # abrir un editor para métodos largos
jshell> /save sesion.jsh # guardar la sesión
jshell> /open sesion.jsh # recuperarla
jshell> /exit
# Con dependencias externas y features en preview
jshell --class-path libs/gson-2.11.0.jar --enable-preview
HttpClienten incubación (jdk.incubator.http): dos versiones después se estandarizó en Java 11 comojava.net.http. Ejemplo perfecto de para qué sirve la incubación: si lo hubieran estandarizado en 9, arrastraríamos su API provisional para siempre.- Multi-Release JARs (JEP 238): un mismo jar puede contener implementaciones distintas por versión de Java. Lo usan librerías que quieren aprovechar APIs nuevas sin abandonar Java 8.
- Compact strings (JEP 254):
Stringpasó dechar[](2 bytes por carácter) abyte[]con codificación Latin-1 cuando el texto lo permite. Ahorro de memoria real y gratuito, y la razón por la que cualquier código que use reflexión sobreString.valueexplota al salir de Java 8. - G1 pasa a ser el recolector por defecto (antes, Parallel). Cambia el perfil de pausas: mejor latencia, algo menos de throughput bruto y algo más de memoria.
- Logging unificado de la JVM (
-Xlog:gc*): un único mecanismo con etiquetas y niveles, en lugar de veinte flags distintos. java.util.concurrent.Flow: las interfaces de Reactive Streams entran en el JDK (Publisher,Subscriber,Subscription,Processor). Es lo que implementan Reactor y RxJava.- Applets deprecados (y eliminados en versiones posteriores); Java Web Start desapareció en Java 11. Si mantienes algo así, tu migración es un proyecto aparte.
@Deprecated(since=…, forRemoval=true)y la herramientajdeprscan: por fin hay diferencia entre «deprecado desde 1998 y ahí sigue» y «esto se va».
Estructura de un Multi-Release JAR
mi-libreria.jar
├── META-INF/
│ ├── MANIFEST.MF ← contiene: Multi-Release: true
│ └── versions/
│ ├── 11/com/ejemplo/Detector.class ← se usa si la JVM es 11..16
│ └── 17/com/ejemplo/Detector.class ← se usa si la JVM es 17 o superior
└── com/ejemplo/Detector.class ← base: se usa en Java 8..10
Al ejecutar, la JVM elige la clase de la carpeta de versión más alta que sea <= a su
propia versión. Así una librería aprovecha records o virtual threads sin romper Java 8.
4. Java 10 (2018): var y los contenedores
4.1 var: reglas exactas
var no convierte Java en un lenguaje de tipado dinámico. Es inferencia de
tipos para variables locales: el compilador deduce el tipo del inicializador y lo fija
para siempre. El bytecode generado es idéntico al de escribir el tipo a mano; en tiempo de ejecución
var no existe.
// ✅ Donde SÍ se puede usar
var lista = new ArrayList<String>(); // variable local con inicializador
var mapa = new HashMap<String, List<Pedido>>(); // aquí ahorra muchísimo ruido
for (var pedido : pedidos) { } // variable del for-each
for (var i = 0; i < 10; i++) { } // índice del for clásico
try (var conexion = dataSource.getConnection()) { } // try-with-resources
var resultado = switch (estado) { case A -> 1; default -> 0; }; // resultado de una expresión
// ❌ Donde NO se puede (error de compilación)
var sinValor; // sin inicializador: no hay nada que inferir
var nulo = null; // el tipo sería el "null type": prohibido
private var campo = 1; // campos de clase: NO
void metodo(var parametro) { } // parámetros de método: NO
var metodo() { return 1; } // tipo de retorno: NO
catch (var e) { } // parámetro de catch: NO
var[] array = new String[3]; // no se puede usar en tipos de array
var vacio = {1, 2, 3}; // inicializador abreviado de array: NO
var lambda = () -> System.out.println(); // el tipo de una lambda es "el destino": NO se infiere
var ref = String::valueOf; // igual: referencia a método sin tipo destino
// ⚠️ Sutilezas que sí importan
var numeros = new ArrayList<>(); // infiere ArrayList<Object>: casi nunca es lo que querías
var x = 1; // int
var y = 1L; // long
var z = 1.0; // double (¡no float!)
var c = 'a'; // char
var s = "a" + 1; // String
var d = new BigDecimal("1.0"); // BigDecimal, no Number
// var + tipo anónimo: el único sitio donde var da acceso a algo INEXPRESABLE de otro modo
var punto = new Object() { int x = 1; int y = 2; };
System.out.println(punto.x + punto.y); // funciona: el tipo inferido es el tipo anónimo
// var en lambdas (Java 11): solo útil para poner anotaciones o modificadores
lista.forEach((@Nonnull var elemento) -> procesar(elemento));
// Regla: si usas var en una lambda, DEBES usarlo en todos los parámetros
BiFunction<String, Integer, String> f = (var a, var b) -> a + b; // ✅
BiFunction<String, Integer, String> g = (var a, Integer b) -> a + b; // ❌ no compila
4.2 Cuándo var mejora el código y cuándo lo empeora
La regla de oro, tomada de las guías de estilo oficiales del proyecto: lo importante no es el tipo, es que el lector entienda qué contiene la variable. Si el nombre y el inicializador ya lo dicen, el tipo explícito es ruido. Si no, escríbelo.
// ✅ LEGIBLE: el tipo es evidente y era redundante
var repositorioPedidos = new JdbcPedidoRepository(dataSource);
var pedidosPorCliente = new HashMap<String, List<Pedido>>();
var entrada = new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
var ahora = Instant.now();
for (var entrada : mapa.entrySet()) { } // evita Map.Entry<String, List<Pedido>>
try (var flujo = Files.lines(ruta, UTF_8)) { }
// ❌ ILEGIBLE: el tipo era la única información útil
var x = obtener(); // ¿qué devuelve obtener()? Hay que ir a mirar
var resultado = servicio.procesar(dto); // ¿un boolean? ¿un DTO? ¿un Optional? ¿una lista?
var datos = leer(fichero); // ¿String? ¿byte[]? ¿List<Registro>?
var f = 0; // nombre inútil + tipo oculto = doble opacidad
var conexion = crear(); // ¿Connection? ¿HttpClient? ¿Socket?
// ✅ La misma línea, arreglada de dos formas distintas
Pedido resultado = servicio.procesar(dto); // el tipo aporta la información
var pedidoConfirmado = servicio.confirmar(dto); // el NOMBRE aporta la información
// ⚠️ var y las interfaces: pierdes la abstracción deliberada
List<String> lista = new ArrayList<>(); // declaras la INTERFAZ: puedes cambiar la impl. mañana
var lista2 = new ArrayList<String>(); // el tipo es ArrayList: si alguien usa ensureCapacity, te ataste
// En una variable local de 5 líneas es irrelevante. En una API pública sería un error grave
// (pero var no puede aparecer en APIs públicas, precisamente por eso).
var no reduce la seguridad de tipos: el tipado
sigue siendo estático y el compilador lo comprueba igual. Reduce ceremonia. Lo uso cuando el
inicializador hace obvio el tipo —típicamente un new con genéricos largos— y lo evito cuando
la única pista sobre el contenido era el propio tipo. Y nunca con new ArrayList<>() sin
parámetro de tipo».
4.3 Lo demás de Java 10: copias, orElseThrow y contenedores
// Copias inmutables reales (no vistas): capturan el contenido en ese momento
List<String> copia = List.copyOf(originalMutable); // si el original cambia, la copia NO
Set<String> cs = Set.copyOf(otro);
Map<String, Integer> cm = Map.copyOf(mapa);
// Diferencia con Collections.unmodifiableList: esa es una VISTA; si mutas el original, la ves cambiar
List<String> vista = Collections.unmodifiableList(originalMutable); // ⚠️ no es una copia
// Colectores a colecciones inmutables
var inmutable = pedidos.stream().map(Pedido::id).collect(Collectors.toUnmodifiableList());
// Optional.orElseThrow() sin argumentos: sinónimo explícito de get()
Usuario u = repo.buscar(id).orElseThrow(); // NoSuchElementException, pero se LEE la intención
// A partir de aquí, get() se considera un nombre desafortunado y se prefiere orElseThrow()
// Runtime.version(): parsear versiones sin expresiones regulares frágiles
Runtime.Version v = Runtime.version();
if (v.feature() >= 21) habilitarVirtualThreads();
var: es la
conciencia de contenedores (-XX:+UseContainerSupport, activada por defecto).
Antes, una JVM dentro de un contenedor con límite de 512 MB veía la RAM del host y calculaba un
heap por defecto absurdo → el kernel mataba el pod con OOMKilled y sin heap dump.
Desde Java 10 (y retroportado a 8u191) la JVM lee los cgroups. Consecuencia práctica: en
Kubernetes usa -XX:MaxRAMPercentage=75 en lugar de -Xmx fijo.
También llegó Application Class-Data Sharing, base de las mejoras de arranque
posteriores.
5. Java 11 LTS (2018): el primer LTS moderno
Java 11 fue el primer LTS del nuevo modelo y, durante años, «el Java moderno». Aportó poco al lenguaje
(solo var en lambdas) pero muchísimo a las librerías y a la operación… y quitó cosas, que es
lo que hace dolorosa la migración desde 8.
5.1 HttpClient estándar: adiós a HttpURLConnection
HttpURLConnection era de 1997: API incomprensible, sin HTTP/2, sin asincronía, sin WebSocket
y con timeouts a medias. Por eso todo el mundo añadía Apache HttpClient u OkHttp. Java 11 incorpora un
cliente moderno en java.net.http: HTTP/1.1 y HTTP/2, síncrono y asíncrono, WebSocket,
inmutable y reutilizable.
// ───── Cliente: créalo UNA VEZ y reutilízalo (tiene pool de conexiones y hilos) ─────
private static final HttpClient CLIENTE = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2) // negocia H2, cae a 1.1 si no hay
.connectTimeout(Duration.ofSeconds(2)) // timeout de CONEXIÓN
.followRedirects(HttpClient.Redirect.NORMAL) // no sigue https → http
.executor(Executors.newVirtualThreadPerTaskExecutor()) // Java 21: hilos virtuales
.build();
// ───── 1. Petición síncrona ─────────────────────────────────────────────────────
HttpRequest peticion = HttpRequest.newBuilder(URI.create("https://api.ejemplo.com/pedidos/42"))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + token)
.timeout(Duration.ofSeconds(5)) // timeout de la PETICIÓN completa
.GET()
.build();
HttpResponse<String> respuesta = CLIENTE.send(peticion, HttpResponse.BodyHandlers.ofString());
if (respuesta.statusCode() == 200) {
Pedido pedido = mapper.readValue(respuesta.body(), Pedido.class);
}
// ⚠️ HttpClient NO lanza excepción por 4xx/5xx: comprueba statusCode() siempre
// ───── 2. POST con cuerpo JSON ──────────────────────────────────────────────────
HttpRequest alta = HttpRequest.newBuilder(URI.create("https://api.ejemplo.com/pedidos"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json, UTF_8))
.build();
// Otros publishers útiles
HttpRequest.BodyPublishers.ofFile(Path.of("factura.pdf"));
HttpRequest.BodyPublishers.noBody();
HttpRequest.BodyPublishers.ofByteArray(bytes);
// ───── 3. Asíncrono: no bloquea el hilo llamante ────────────────────────────────
CompletableFuture<Pedido> futuro = CLIENTE
.sendAsync(peticion, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenApply(cuerpo -> mapper.readValue(cuerpo, Pedido.class))
.orTimeout(6, TimeUnit.SECONDS)
.exceptionally(e -> { log.error("fallo al consultar pedido", e); return Pedido.vacio(); });
// Varias peticiones en paralelo y espera conjunta
List<CompletableFuture<String>> futuros = urls.stream()
.map(url -> HttpRequest.newBuilder(URI.create(url)).build())
.map(p -> CLIENTE.sendAsync(p, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body))
.toList();
CompletableFuture.allOf(futuros.toArray(CompletableFuture[]::new)).join();
// ───── 4. Streaming: no cargar 2 GB en memoria ──────────────────────────────────
// Línea a línea (logs, NDJSON, Server-Sent Events)
try (Stream<String> lineas = CLIENTE.send(peticion, HttpResponse.BodyHandlers.ofLines()).body()) {
lineas.filter(l -> !l.isBlank()).forEach(this::procesarEvento);
}
// Directo a fichero, sin pasar por el heap
CLIENTE.send(peticion, HttpResponse.BodyHandlers.ofFile(Path.of("/tmp/informe.csv")));
// Como InputStream, para control total
try (InputStream in = CLIENTE.send(peticion, HttpResponse.BodyHandlers.ofInputStream()).body()) {
in.transferTo(salida);
}
// Descartar el cuerpo (health checks)
CLIENTE.send(peticion, HttpResponse.BodyHandlers.discarding());
// ───── 5. Reintentos con backoff (el cliente NO reintenta por ti) ───────────────
private HttpResponse<String> conReintentos(HttpRequest p, int intentos) throws Exception {
Exception ultima = null;
for (int i = 0; i < intentos; i++) {
try {
HttpResponse<String> r = CLIENTE.send(p, HttpResponse.BodyHandlers.ofString());
if (r.statusCode() < 500) return r; // 4xx no se reintenta: es culpa nuestra
} catch (IOException e) {
ultima = e; // fallo de red: sí se reintenta
}
Thread.sleep(Duration.ofMillis((long) (100 * Math.pow(2, i)))); // 100, 200, 400 ms
}
throw new IntegracionException("Agotados los reintentos", ultima);
}
// ───── 6. Java 21: HttpClient es AutoCloseable ──────────────────────────────────
try (HttpClient efimero = HttpClient.newHttpClient()) {
efimero.send(peticion, HttpResponse.BodyHandlers.ofString());
} // cierra ordenadamente sus hilos; antes había que confiar en el GC
HttpClient por petición. Cada instancia trae su
propio pool de conexiones y su selector de E/S; crear uno por llamada destruye el keep-alive,
multiplica los handshakes TLS y fuga hilos. Un cliente por destino (o uno global),
como constante estática o bean de Spring.
5.2 Métodos nuevos de String, Files y compañía
// ───── String ───────────────────────────────────────────────────────────────────
" ".isBlank(); // true → vacío o solo espacios (¡Unicode-aware!)
"".isEmpty(); // true → longitud 0 (existía antes)
" hola\u00A0".strip(); // "hola" → strip() entiende espacios Unicode; trim() NO
" hola ".stripLeading(); // "hola "
" hola ".stripTrailing(); // " hola"
"-".repeat(40); // separador de logs sin bucles ni StringUtils
"a\nb\r\nc".lines().toList(); // [a, b, c] → parte por saltos de línea, perezoso y multiplataforma
// ⚠️ trim() vs strip(): trim() solo quita caracteres <= U+0020. Con espacios no separables
// (U+00A0), tabulaciones exóticas o texto copiado de un PDF, trim() falla y strip() acierta.
// Recuento de líneas de un fichero grande, elegante
try (var lineas = Files.lines(ruta, UTF_8)) {
long noVacias = lineas.filter(Predicate.not(String::isBlank)).count(); // Predicate.not: Java 11
}
// ───── Files: leer y escribir texto en una línea ────────────────────────────────
String contenido = Files.readString(Path.of("config.json")); // UTF-8 por defecto
Files.writeString(Path.of("salida.txt"), contenido, UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
// ⚠️ readString carga el fichero COMPLETO en memoria. Para ficheros grandes: Files.lines()
// ───── Colecciones y varios ─────────────────────────────────────────────────────
String[] array = lista.toArray(String[]::new); // en vez de new String[0]
boolean vacio = opt.isEmpty(); // Optional.isEmpty()
char[] c = Character.toString(0x1F600).toCharArray(); // Character.toString(int codePoint)
5.3 Lo que Java 11 quitó: javax.* y el susto de la migración
El JEP 320 eliminó del JDK los módulos de Java EE y CORBA que se habían colado en Java 6 «por comodidad». Nunca debieron estar ahí: eran especificaciones de otro proyecto (Java EE, hoy Jakarta EE) con su propio ciclo de vida. Al quitarlos, cualquier aplicación que los usara sin declarar la dependencia deja de compilar o revienta en ejecución.
| Eliminado en 11 | Paquete | Qué añadir al pom.xml |
|---|---|---|
| JAX-B (XML ↔ objetos) | javax.xml.bind |
jakarta.xml.bind:jakarta.xml.bind-api + implementación org.glassfish.jaxb:jaxb-runtime |
| JAX-WS (SOAP) | javax.xml.ws, javax.jws |
jakarta.xml.ws:jakarta.xml.ws-api + com.sun.xml.ws:jaxws-rt |
| JAF / Activation | javax.activation |
jakarta.activation:jakarta.activation-api |
Common Annotations (@PostConstruct, @Resource) |
javax.annotation |
jakarta.annotation:jakarta.annotation-api |
JTA (@Transactional de la especificación) |
javax.transaction |
jakarta.transaction:jakarta.transaction-api |
CORBA y orb.idl |
javax.rmi.CORBA, org.omg.* |
Sin sustituto oficial. Hay implementaciones externas, pero lo correcto es reemplazar la integración por REST o gRPC. Si tienes CORBA, esto es un proyecto propio |
| Java Web Start y el plugin del navegador | — | Sin sustituto. Alternativas: jpackage (instalador nativo) o reescribir como web |
| JavaFX (separado, no eliminado) | javafx.* |
org.openjfx:javafx-* desde OpenJFX, o una distribución que lo incluya (Liberica full) |
<!-- Parche mínimo para que una app de Java 8 con JAX-B compile y funcione en 11+ -->
<dependency>
<groupId>jakarta.xml.bind</groupId>
<artifactId>jakarta.xml.bind-api</artifactId>
<version>4.0.2</version>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>4.0.5</version>
<scope>runtime</scope>
</dependency>
<!-- ⚠️ OJO CON LA VERSIÓN: la serie 2.3.x usa el paquete javax.xml.bind y la 3.x/4.x usa
jakarta.xml.bind. Elige según si tu código ya está migrado a jakarta o todavía no.
Mezclar las dos produce ClassNotFoundException o LinkageError. -->
javax.* desaparece
ni se renombra a jakarta.*. Los paquetes javax.* que forman parte del
JDK siguen ahí y ahí se quedan: javax.sql (DataSource),
javax.crypto, javax.naming (JNDI), javax.management (JMX),
javax.net.ssl, javax.swing, javax.imageio. Lo que cambia es
exclusivamente lo que pertenece a Jakarta EE: javax.servlet,
javax.persistence, javax.validation, javax.annotation,
javax.transaction, javax.ws.rs, javax.jms,
javax.mail, javax.xml.bind. Un «buscar y reemplazar javax →
jakarta» a ciegas rompe la aplicación entera.
5.4 TLS 1.3, Flight Recorder libre, Epsilon, nestmates y un solo fichero
- TLS 1.3 (JEP 332): menos round trips en el handshake (mejor latencia
en la primera conexión), suites de cifrado modernas y forward secrecy obligatoria. Nada que
programar: se activa solo. El efecto secundario al migrar es que los sistemas antiguos que solo
hablan TLS 1.0/1.1 dejan de conectar, porque esas versiones están deshabilitadas por defecto
(se controlan en
java.securityconjdk.tls.disabledAlgorithms). - JDK Flight Recorder y Mission Control liberados (JEP 328): antes eran producto comercial de Oracle. Ahora tienes un profiler de impacto muy bajo (típicamente < 2%) apto para producción, integrado en la JVM.
- Epsilon GC (JEP 318): un recolector que no recolecta. Suena absurdo y tiene dos usos legítimos: medir la tasa de asignación real de un programa (si el proceso muere tras X MB, has asignado X MB) y tareas efímeras donde liberar memoria es un desperdicio.
- Ejecución de un solo fichero fuente (JEP 330):
java Hola.javacompila en memoria y ejecuta. Convierte a Java en una herramienta razonable para scripts y, sobre todo, en la mejor forma de practicar sin montar un proyecto Maven. - Nest-based access control (JEP 181): las clases anidadas ya no necesitan los métodos
puente sintéticos (
access$000) que el compilador generaba para acceder a miembros privados de la clase que las contiene. Menos bytecode, menos sorpresas al usar reflexión y una regla de acceso que por fin coincide con lo que dice el lenguaje. - ZGC en experimental (JEP 333) y Nashorn deprecado (JEP 335).
# Ejecutar un fichero suelto, con argumentos y con dependencias
java Hola.java arg1 arg2
java -cp 'libs/*' Script.java # con classpath
java --source 21 --enable-preview Script.java # con features en preview
# Shebang: un script Java ejecutable de verdad (fichero SIN extensión .java)
cat > saludo <<'EOF'
#!/usr/bin/env java --source 21
public class Saludo {
public static void main(String[] args) {
System.out.println("Hola " + (args.length > 0 ? args[0] : "mundo"));
}
}
EOF
chmod +x saludo && ./saludo Ana
# Flight Recorder: grabar 2 minutos de un servicio en producción
java -XX:StartFlightRecording=duration=120s,filename=app.jfr,settings=profile -jar app.jar
jcmd <pid> JFR.start name=diagnostico settings=profile # sobre un proceso ya arrancado
jcmd <pid> JFR.dump name=diagnostico filename=/tmp/app.jfr
jfr summary /tmp/app.jfr # resumen por consola, sin abrir JMC
Checklist — Java 9, 10 y 11
6. Java 12–15: el laboratorio donde se cocinó el Java moderno
Estas cuatro versiones no-LTS son las grandes olvidadas, y sin embargo ahí nació todo: switch expressions, text blocks, records, sealed classes y pattern matching entraron aquí en modo preview y se estabilizaron entre 14 y 21. Conocer esta cronología es lo que te permite responder «desde qué versión puedo usar X» sin inventar.
6.1 Switch expressions: el switch que devuelve un valor
El switch heredado de C tenía cuatro defectos graves: fall-through por defecto (una
fuente inagotable de bugs por break olvidados), un solo ámbito de variables compartido entre
todos los casos, no era una expresión (había que asignar a una variable temporal) y no comprobaba
exhaustividad. Java 12 lo previsualizó y Java 14 lo estandarizó (JEP 361).
// ❌ ANTES: 12 líneas, una variable mutable, y un break olvidado = bug silencioso
int diasDelMes;
switch (mes) {
case FEBRERO:
diasDelMes = 28;
break; // si lo olvidas, cae al siguiente caso
case ABRIL:
case JUNIO:
case SEPTIEMBRE:
case NOVIEMBRE:
diasDelMes = 30;
break;
default:
diasDelMes = 31;
}
// ✅ AHORA: expresión, sin break, sin variable mutable, exhaustividad comprobada
int dias = switch (mes) {
case FEBRERO -> 28;
case ABRIL, JUNIO, SEPTIEMBRE, NOVIEMBRE -> 30; // varias etiquetas separadas por comas
default -> 31;
};
// Bloque con yield cuando hace falta más de una expresión
int puntuacion = switch (nivel) {
case BAJO -> 1;
case MEDIO -> 5;
case ALTO -> {
log.info("nivel alto detectado para {}", usuario);
int base = calcularBase(usuario);
yield base * 2; // yield DEVUELVE el valor del bloque (no es 'return')
}
};
// Exhaustividad: si el switch es una EXPRESIÓN sobre un enum, debe cubrir todos los casos
enum Estado { BORRADOR, CONFIRMADO, ENVIADO }
String texto = switch (estado) {
case BORRADOR -> "sin confirmar";
case CONFIRMADO -> "en preparación";
case ENVIADO -> "en camino";
}; // ✅ sin default: el compilador verifica que están todos
// Si mañana añades Estado.CANCELADO, esto DEJA DE COMPILAR. Eso es exactamente lo que quieres:
// un default silencioso te habría dado un bug en producción en lugar de un error de compilación.
// ⚠️ Detalle sutil: aunque el compilador compruebe la exhaustividad, genera un default
// implícito que lanza MatchException/IncompatibleClassChangeError si el enum cambia
// DESPUÉS de compilar tu clase (compilación separada). No es un problema en un build único.
// También funciona como sentencia (sin devolver valor): flechas sin fall-through
switch (comando) {
case "alta" -> servicio.crear();
case "baja" -> servicio.eliminar();
default -> throw new IllegalArgumentException("comando desconocido: " + comando);
}
switch no se pueden combinar etiquetas con
-> y con :. Es error de compilación, y está bien que lo sea: el
fall-through y las flechas son modelos mentales incompatibles.
6.2 Text blocks: cadenas multilínea con reglas precisas
Previsualizados en 13 y estándar en 15 (JEP 378). Resuelven un problema cotidiano: incrustar SQL, JSON,
HTML o XML sin convertir el código en una sopa de \n, \" y concatenaciones.
// ❌ ANTES: ilegible, imposible de copiar y pegar en un cliente SQL
String sql = "SELECT p.id, p.total, c.nombre\n" +
" FROM pedido p\n" +
" JOIN cliente c ON c.id = p.cliente_id\n" +
" WHERE p.estado = 'CONFIRMADO'\n" +
" AND p.total > ?\n" +
" ORDER BY p.total DESC";
// ✅ AHORA: se lee como el SQL que es
String sql = """
SELECT p.id, p.total, c.nombre
FROM pedido p
JOIN cliente c ON c.id = p.cliente_id
WHERE p.estado = 'CONFIRMADO'
AND p.total > ?
ORDER BY p.total DESC""";
// JSON sin escapar una sola comilla
String cuerpo = """
{
"cliente": "%s",
"lineas": [
{"sku": "ABC-1", "unidades": 2}
],
"urgente": true
}""".formatted(nombreCliente); // formatted(): Java 15
Las reglas exactas (aquí es donde se falla en las entrevistas):
- El delimitador de apertura es
"""seguido obligatoriamente de un salto de línea.String s = """hola""";no compila. - Se elimina el espaciado incidental: el compilador calcula la indentación mínima entre
todas las líneas no vacías y la línea del delimitador de cierre, y la quita de
todas. Por eso la posición del
"""final controla la sangría del resultado. - Se eliminan los espacios finales de cada línea (para que no dependa de tu editor).
- Si el
"""de cierre está en su propia línea, el texto acaba con\n. Si va pegado al último carácter, no. - Las secuencias de escape siguen funcionando (
\n,\t,\",\\), y hay dos nuevas:\al final de línea (une líneas, sin salto) y\s(un espacio que impide el borrado de espacios finales). - No hay interpolación de variables. Se usa
.formatted(...),String.formato concatenación.
// La indentación incidental, visualizada (· = espacio)
String a = """
········hola
··········mundo
········"""; // el cierre marca el margen → resultado: "hola\n mundo\n"
String b = """
········hola
··········mundo"""; // margen = mínimo de las líneas no vacías (8) → "hola\n mundo" (sin \n final)
String c = """
········hola
··········mundo
··"""; // el cierre está a 2 → margen = 2 → "······hola\n········mundo\n"
// \ al final de línea: una sola línea larga, escrita en varias
String url = """
https://api.ejemplo.com/v1/pedidos\
?estado=CONFIRMADO\
&desde=2026-01-01""";
// → "https://api.ejemplo.com/v1/pedidos?estado=CONFIRMADO&desde=2026-01-01"
// \s: preservar espacios finales significativos (p. ej. en un fichero de ancho fijo)
String tabla = """
NOMBRE \sEDAD
Ana \s34""";
// Métodos relacionados
" x ".stripIndent(); // quita la indentación incidental de un String normal (Java 15)
"a\\nb".translateEscapes(); // interpreta \n, \t… en un texto leído de fuera (Java 15)
"x".indent(4); // añade 4 espacios a cada línea (Java 12)
"abc".transform(String::toUpperCase); // aplicar una función en la cadena de llamadas (Java 12)
""" de cierre con el contenido y consigues un texto sin
sangría; alinéalo con el margen izquierdo del código y conservas la sangría. Si dudas, imprime el
resultado con System.out.println("[" + s + "]") y míralo. Y para SQL, pon el
""" de cierre pegado a la última palabra: así no metes un \n final que
algunos drivers arrastran al log.
6.3 Helpful NullPointerExceptions: horas de depuración recuperadas
Introducidos en Java 14 (JEP 358) y activados por defecto desde Java 15. El mensaje ya no
es solo un número de línea: la JVM analiza el bytecode y dice exactamente qué era
null y qué intentabas hacer con él.
// Código: pedido.getCliente().getDireccion().getCiudad().toUpperCase()
// ❌ Java 8: en una línea con 4 llamadas, ¿cuál era null? A depurar.
Exception in thread "main" java.lang.NullPointerException
at com.ejemplo.Informe.generar(Informe.java:42)
// ✅ Java 15+: te lo dice
Exception in thread "main" java.lang.NullPointerException:
Cannot invoke "com.ejemplo.Ciudad.toUpperCase()" because the return value of
"com.ejemplo.Direccion.getCiudad()" is null
at com.ejemplo.Informe.generar(Informe.java:42)
// En Java 14 había que activarlo a mano:
// java -XX:+ShowCodeDetailsInExceptionMessages ...
// Desde Java 15 está activo por defecto.
traceId y responde un error genérico. Es la misma regla del
módulo 10.
6.4 instanceof con patrón: adiós al cast redundante
Preview en 14 y 15, estándar en Java 16 (JEP 394). Elimina el patrón «pregunto el tipo, luego declaro
variable, luego caste o» que aparecía en todo equals() del mundo.
// ❌ ANTES: el tipo aparece tres veces y el cast puede desincronizarse del instanceof
if (objeto instanceof Pedido) {
Pedido pedido = (Pedido) objeto;
procesar(pedido);
}
// ✅ AHORA: la variable de patrón se declara y asigna sola, y solo existe si el test pasa
if (objeto instanceof Pedido pedido) {
procesar(pedido);
}
// Funciona con el cortocircuito de && (ámbito de flujo, "flow scoping")
if (objeto instanceof Pedido p && p.total().compareTo(BigDecimal.valueOf(1000)) > 0) {
aplicarDescuento(p);
}
// Y con la negación, invirtiendo el ámbito
if (!(objeto instanceof Pedido p)) return; // cláusula de guarda
procesar(p); // p está en ámbito en el resto del método
// El uso que más se ve: equals()
@Override public boolean equals(Object o) {
return o instanceof Cliente otro && nif.equals(otro.nif);
}
// Antes eran 5 líneas con getClass() != o.getClass(), cast y comparación.
6.5 GC, CDS y otras mejoras de la plataforma
- Shenandoah (Java 12, experimental; producción en 15): recolector de pausas ultracortas que hace la compactación de forma concurrente. Desarrollado por Red Hat.
- ZGC en producción (Java 15, JEP 377). Nació experimental en 11 y solo en Linux/x64; en 14 llegó a macOS y Windows. Objetivo: pausas de menos de 1 ms independientemente del tamaño del heap (funciona con cientos de GB).
- CMS eliminado (Java 14): si tu aplicación arrancaba con
-XX:+UseConcMarkSweepGC, en Java 14+ la JVM no arranca. Es uno de los fallos más habituales de la migración; la sustitución natural es G1. - CDS y AppCDS: Class-Data Sharing guarda en un fichero el resultado de cargar y verificar clases, y lo mapea en memoria al arrancar (compartido entre procesos). Un archivo por defecto del JDK viene activado desde Java 12; con AppCDS puedes incluir las clases de tu aplicación y de Spring, y desde Java 13 el archivo se puede generar automáticamente al salir del proceso. Mejora típica de arranque: entre un 10% y un 40% en aplicaciones grandes, sin tocar una línea de código.
- Punto flotante siempre estricto (Java 17, JEP 306): desaparece la distinción
strictfp; los resultados son reproducibles en todas las plataformas. jpackage(incubado en 14, estándar en 16): genera instaladores nativos (.deb,.rpm,.msi,.dmg) con el runtime embebido.- Clases ocultas (Java 15, JEP 371): mecanismo estándar para que los frameworks generen
clases en tiempo de ejecución sin ensuciar el ClassLoader ni fugar metaspace, sustituyendo el uso de
Unsafe.defineAnonymousClass.
# AppCDS en tres pasos: medible en minutos, mejora el arranque sin cambiar código
# 1) Grabar la lista de clases que se cargan en un arranque real
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar --spring.main.exit-on-completion=true
# 2) Arrancar usando el archivo
java -XX:SharedArchiveFile=app.jsa -jar app.jar
# 3) Comprobar que se está usando (y no cayendo en silencio)
java -Xshare:on -Xlog:class+load:file=carga.log -XX:SharedArchiveFile=app.jsa -jar app.jar
grep 'shared objects file' carga.log | head
# Mide SIEMPRE antes y después; en un Spring Boot mediano es habitual pasar de ~4,5 s a ~3,2 s.
7. Java 17 LTS (2021): records, sealed y el mínimo de Spring Boot 3
Java 17 es hoy el suelo de la industria: lo exige Spring Boot 3, Hibernate 6 y prácticamente todo el ecosistema moderno. Su aportación es sobre todo de modelado: por primera vez Java tiene herramientas de primera clase para expresar «datos» y «conjuntos cerrados de alternativas».
7.1 Records en profundidad
Un record es un portador transparente de datos inmutables. La palabra clave
es transparente: su API pública es su estado. Eso es lo que permite al compilador
generar todo y a las futuras versiones del lenguaje deconstruirlo con patrones.
// Una línea. Esto es todo.
public record Punto(int x, int y) { }
// Lo que el compilador genera por ti:
// · private final int x; private final int y; (campos, siempre final)
// · public Punto(int x, int y) { this.x = x; this.y = y; } (constructor canónico)
// · public int x(); public int y(); (accesores SIN prefijo get)
// · public boolean equals(Object o) (compara todos los componentes)
// · public int hashCode() (coherente con equals)
// · public String toString() → "Punto[x=1, y=2]"
// · extends java.lang.Record y es implícitamente FINAL
// Equivalente en Java 8: unas 40 líneas de código repetitivo que nadie quiere revisar.
// ───── Constructor compacto: validar y normalizar ───────────────────────────────
public record Rango(LocalDate desde, LocalDate hasta) {
// Sin lista de parámetros ni asignaciones: el compilador añade this.desde = desde; al final
public Rango {
Objects.requireNonNull(desde, "desde");
Objects.requireNonNull(hasta, "hasta");
if (hasta.isBefore(desde)) {
throw new IllegalArgumentException("rango invertido: " + desde + " > " + hasta);
}
}
// Métodos derivados: perfectamente idiomáticos
public long dias() { return ChronoUnit.DAYS.between(desde, hasta); }
public boolean contiene(LocalDate d) { return !d.isBefore(desde) && !d.isAfter(hasta); }
// Factoría estática con nombre: mucho más legible que un constructor más
public static Rango deHoyA(int dias) {
LocalDate hoy = LocalDate.now();
return new Rango(hoy, hoy.plusDays(dias));
}
// Constante estática: permitida (los campos de INSTANCIA extra, no)
public static final Rango VACIO = new Rango(LocalDate.EPOCH, LocalDate.EPOCH);
}
// ───── Normalización: reasignar el parámetro en el constructor compacto ─────────
public record Etiquetas(String nombre, List<String> valores) {
public Etiquetas {
nombre = nombre == null ? "" : nombre.strip().toLowerCase(); // normaliza
valores = List.copyOf(valores); // ✅ COPIA DEFENSIVA: sin esto el record NO es inmutable
}
}
// ⚠️ Sin ese List.copyOf, quien te pasó la lista puede seguir modificándola después.
// Un record garantiza que las REFERENCIAS no cambian, no que los objetos apuntados sean inmutables.
// ───── Records genéricos, anidados, locales y con interfaces ────────────────────
public record Resultado<T>(T valor, Duration tiempo) { } // genérico
public record Factura(String numero, Emisor emisor) { // anidado
public record Emisor(String nif, String nombre) { }
}
public record Dinero(BigDecimal importe, Currency moneda) implements Comparable<Dinero> {
@Override public int compareTo(Dinero otro) {
if (!moneda.equals(otro.moneda)) throw new IllegalArgumentException("monedas distintas");
return importe.compareTo(otro.importe);
}
}
// Record LOCAL (dentro de un método): ideal para tuplas intermedias en un stream (Java 16+)
public List<String> masVendidos(List<Pedido> pedidos) {
record Conteo(String sku, long unidades) { } // tipo con nombre en lugar de Object[]
return pedidos.stream()
.collect(Collectors.groupingBy(Pedido::sku, Collectors.counting()))
.entrySet().stream()
.map(e -> new Conteo(e.getKey(), e.getValue()))
.sorted(Comparator.comparingLong(Conteo::unidades).reversed())
.limit(10)
.map(Conteo::sku)
.toList();
}
// ───── Lo que un record NO puede hacer ──────────────────────────────────────────
// record Malo(int x) {
// private int contador; // ❌ campos de instancia adicionales prohibidos
// }
// public record Hijo(int x) extends Padre { } // ❌ no puede heredar de una clase
// non-final record ... // ❌ es implícitamente final: nadie lo extiende
// ⚠️ Componentes de tipo ARRAY: equals/hashCode usan identidad de referencia
public record Fichero(String nombre, byte[] contenido) { }
new Fichero("a", new byte[]{1}).equals(new Fichero("a", new byte[]{1})); // false ❗
// Si necesitas comparar contenido, envuelve el array o sobrescribe equals/hashCode con Arrays.equals.
Serialización de records: una mejora de seguridad silenciosa
La deserialización nativa de Java es peligrosa porque no llama al constructor: reconstruye
el objeto campo por campo y se salta todas tus validaciones (de ahí los gadget chains y CVEs
famosas). Los records son distintos: se deserializan invocando el constructor canónico,
por lo que tus invariantes se respetan siempre. Tampoco admiten writeObject,
readObject, readResolve ni serialPersistentFields: su forma
serializada es, por definición, la lista de componentes.
public record Cuenta(String iban, BigDecimal saldo) implements Serializable {
public Cuenta {
if (saldo.signum() < 0) throw new IllegalArgumentException("saldo negativo");
}
}
// Un atacante que manipule el flujo serializado para poner saldo = -1_000_000
// provoca la excepción del constructor: el objeto inválido NUNCA llega a existir.
// Con una clase normal, ese objeto se habría creado sin pasar por la validación.
7.2 Records vs Lombok vs clases tradicionales
| Criterio | record | Lombok | Clase a mano |
|---|---|---|---|
| Dependencias | Ninguna: es lenguaje | Librería + procesador de anotaciones + plugin del IDE | Ninguna |
| Mutabilidad | Inmutable, obligatorio | A elección (@Data mutable, @Value inmutable) | A elección |
| Herencia | No puede extender clases | Sí | Sí |
| Pattern matching / deconstrucción | Sí (record patterns) | No | No |
| Builder | No lo tiene (hazlo a mano o con una factoría) | @Builder, muy cómodo con 8+ campos | A mano |
| Riesgo con nuevas versiones del JDK | Cero | Real: manipula el AST del compilador con API interna; cada JDK nuevo suele requerir subir Lombok | Cero |
| Entidades JPA | No sirve (hace falta constructor sin argumentos y mutabilidad) | Sí, con cuidado (@Data en entidades es una mala idea por equals/hashCode y toString con relaciones) | Sí, es lo recomendado |
| Otros usos frecuentes | DTO, value object, evento, clave de mapa, resultado de consulta | @Slf4j, @RequiredArgsConstructor, @SneakyThrows | Lógica de dominio con estado |
@Builder en objetos con
muchos campos opcionales, @Slf4j y los constructores de inyección de las entidades y
servicios. Y evito @Data en entidades JPA, porque genera equals,
hashCode y toString que recorren relaciones perezosas y provocan
LazyInitializationException o consultas fantasma».
7.3 Cuándo usar records y cuándo no
✅ Casos ideales
- DTO de API (request/response): inmutable, con validación en el constructor compacto.
- Value objects del dominio:
Dinero,Email,Iban,Coordenada,Rango. - Claves compuestas de mapa:
equals/hashCodecorrectos gratis. - Eventos de dominio y mensajes de cola: inmutables por naturaleza.
- Tuplas locales en pipelines de streams (records locales).
- Resultados y proyecciones: Spring Data JPA puede proyectar directamente sobre records.
- Ramas de una jerarquía sellada (ADTs, ver §7.5).
- Configuración inmutable:
@ConfigurationPropertiescon constructor binding.
❌ Cuándo NO usarlos
- Entidades JPA: Hibernate necesita constructor sin argumentos, mutabilidad y proxies. (Como
@Embeddableo proyección DTO sí es viable en Hibernate 6, según el caso.) - Cuando quieres ocultar la representación interna. Un record es transparente por diseño: si mañana quieres cambiar dos campos por uno calculado, rompes la API.
- Objetos con estado que cambia: un carrito de la compra que se llena, una máquina de estados.
- Muchos campos opcionales: un constructor de 12 parámetros es ilegible; ahí gana un builder.
- Cuando necesitas herencia de implementación o una clase base con estado compartido.
- JavaBeans obligatorios: frameworks antiguos que exigen
getX()/setX()por convención reflexiva. - Componentes mutables sin copia defensiva: si expones una
Listmutable, la inmutabilidad es una mentira.
// Record como DTO validado en la frontera + entidad JPA aparte: el patrón recomendado
public record CrearPedidoRequest(
@NotBlank String clienteId,
@NotEmpty List<LineaRequest> lineas,
@Future LocalDate entregaPrevista) {
public CrearPedidoRequest {
lineas = List.copyOf(lineas); // inmutabilidad real
}
public record LineaRequest(@NotBlank String sku, @Positive int unidades) { }
}
// Record como clave compuesta de mapa: sin escribir equals/hashCode
record ClaveCache(String tenant, String idioma, String recurso) { }
Map<ClaveCache, String> cache = new ConcurrentHashMap<>();
cache.computeIfAbsent(new ClaveCache("acme", "es", "menu"), this::cargar);
// Record como value object con comportamiento (no es solo una bolsa de datos)
public record Email(String valor) {
private static final Pattern PATRON = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]{2,}$");
public Email {
valor = valor == null ? "" : valor.strip().toLowerCase();
if (!PATRON.matcher(valor).matches()) throw new IllegalArgumentException("email inválido");
}
public String dominio() { return valor.substring(valor.indexOf('@') + 1); }
}
// Ventaja enorme: a partir de aquí, un método que recibe Email NO PUEDE recibir basura.
// Es el patrón "parse, don't validate": valida una vez, en el constructor, y confía después.
7.4 Sealed classes e interfaces: jerarquías cerradas
Hasta Java 17, en el diseño de una jerarquía solo tenías dos extremos: final («nadie hereda»)
o abierto («cualquiera hereda, incluso desde otro jar»). Faltaba el término medio, que es el más común en
el dominio: «hay exactamente estas tres variantes y no habrá más».
// Declaración: yo decido quién puede implementarme
public sealed interface MetodoPago permits Tarjeta, Transferencia, Bizum { }
public record Tarjeta(String pan, YearMonth caducidad, String titular) implements MetodoPago { }
public record Transferencia(String iban, String concepto) implements MetodoPago { }
public record Bizum(String telefono) implements MetodoPago { }
// Reglas que impone el compilador:
// 1. Cada subtipo listado en 'permits' debe existir y extender/implementar DIRECTAMENTE al sellado.
// 2. Cada subtipo debe declararse final, sealed o non-sealed. No hay opción por defecto.
// 3. Todos deben estar en el mismo MÓDULO (o en el mismo paquete si no hay módulos).
// 4. 'permits' se puede OMITIR si todos los subtipos están en el mismo fichero fuente.
// Jerarquía a dos niveles: sellado dentro de sellado
public sealed interface Figura permits Poligono, Circulo { }
public sealed interface Poligono extends Figura permits Triangulo, Rectangulo { }
public record Triangulo(double base, double altura) implements Poligono { }
public record Rectangulo(double ancho, double alto) implements Poligono { }
public record Circulo(double radio) implements Figura { }
// 'non-sealed': reabrir deliberadamente una rama concreta
public sealed class Evento permits EventoSistema, EventoUsuario { }
public final class EventoSistema extends Evento { }
public non-sealed class EventoUsuario extends Evento { } // esta rama SÍ la puede extender otro
// Todo en un fichero, sin permits (muy cómodo para ADTs pequeños)
public sealed interface Resultado<T> {
record Exito<T>(T valor) implements Resultado<T> { }
record Fallo<T>(String mensaje, Throwable causa) implements Resultado<T> { }
}
El beneficio real: exhaustividad comprobada por el compilador.
// Con una jerarquía sellada, el switch NO necesita default y el compilador lo verifica
BigDecimal comision = switch (metodo) {
case Tarjeta t -> t.pan().startsWith("4") ? new BigDecimal("0.014")
: new BigDecimal("0.019");
case Transferencia tr -> BigDecimal.ZERO;
case Bizum b -> new BigDecimal("0.005");
}; // ✅ exhaustivo: no hace falta default
// Y aquí está el valor de verdad: si mañana alguien añade
// public record Paypal(String cuenta) implements MetodoPago { }
// y lo mete en 'permits', TODOS los switch del proyecto que no lo cubran DEJAN DE COMPILAR.
// Es el compilador haciendo de revisor: te lleva de la mano a cada sitio que hay que actualizar.
// Con un 'default -> throw' habrías tenido una excepción en producción a las tres semanas.
| Necesito… | Herramienta | Por qué |
|---|---|---|
| Un conjunto fijo de valores sin datos propios, o con la misma forma | enum |
Instancias únicas, comparables con ==, usables en EnumMap, con values(). Ej.: Estado, Moneda, DiaSemana |
| Un conjunto fijo de formas distintas de dato | sealed + record |
Cada variante lleva sus propios campos. Ej.: Tarjeta tiene PAN y caducidad; Bizum solo un teléfono. Un enum no puede modelar eso |
| Comportamiento distinto por variante, definido dentro de cada una | Polimorfismo (método abstracto) | Añadir una variante nueva no obliga a tocar nada más. Ideal si las operaciones son estables y las variantes crecen |
| Muchas operaciones distintas sobre un conjunto cerrado | sealed + switch con patrones |
Cada operación queda en un sitio (fácil de leer y testear) en lugar de repartida en N clases. Ideal si las variantes son estables y las operaciones crecen |
| Que terceros extiendan mi jerarquía | Interfaz abierta / clase abstracta pública | Plugins, SPI, puntos de extensión. Aquí sellar sería un error de diseño |
switch hacen lo contrario: añadir una operación es un método nuevo, pero añadir
una variante rompe (a propósito) todos los switch. Elige según qué eje crece más en tu
dominio: los métodos de pago son estables y las operaciones sobre ellos no dejan de crecer, así que
sellar es la decisión correcta.
7.5 Sealed + records = tipos algebraicos, y qué te dan
Juntando ambas features aparece algo que Java no tenía: los tipos de datos algebraicos
(«un valor es esto o aquello, y cada opción lleva estos datos»). Es la
forma correcta de modelar resultados, estados y errores esperados sin excepciones ni
null.
// Un resultado que no miente: o hay valor, o hay un error concreto y tipado
public sealed interface ResultadoPago {
record Aceptado(String idTransaccion, Instant momento) implements ResultadoPago { }
record Rechazado(String codigo, String motivo) implements ResultadoPago { }
record RequiereAutenticacion(URI urlRedireccion, Duration validez) implements ResultadoPago { }
record ErrorTecnico(String detalle, Throwable causa) implements ResultadoPago { }
}
// El consumidor está OBLIGADO a tratar los cuatro casos: no hay olvidos posibles
public ResponseEntity<?> responder(ResultadoPago r) {
return switch (r) {
case Aceptado(String id, Instant momento) ->
ResponseEntity.ok(Map.of("transaccion", id, "momento", momento));
case Rechazado(String codigo, String motivo) ->
ResponseEntity.status(402).body(Map.of("codigo", codigo, "motivo", motivo));
case RequiereAutenticacion(URI url, Duration v) ->
ResponseEntity.status(303).location(url).build();
case ErrorTecnico(String detalle, Throwable causa) -> {
log.error("fallo técnico en la pasarela: {}", detalle, causa);
yield ResponseEntity.status(502).body(Map.of("error", "pasarela no disponible"));
}
};
}
// Máquina de estados como ADT: los datos que existen dependen del estado
public sealed interface EstadoPedido {
record Borrador(List<Linea> lineas) implements EstadoPedido { }
record Confirmado(String id, Instant cuando, Dinero total) implements EstadoPedido { }
record Enviado(String id, String seguimiento, Instant salida) implements EstadoPedido { }
record Entregado(String id, Instant entrega, String receptor) implements EstadoPedido { }
record Cancelado(String id, String motivo, Instant cuando) implements EstadoPedido { }
}
// Ventaja sobre un enum + campos nullables: es IMPOSIBLE tener un "seguimiento" en un borrador.
// El compilador impide construir estados inválidos, en lugar de confiar en comentarios.
7.6 Lo demás de Java 17: aleatoriedad, encapsulación y despedidas
// ───── RandomGenerator: una jerarquía de generadores, al fin (JEP 356) ──────────
// Antes: Random (lento y con contención), ThreadLocalRandom, SecureRandom, SplittableRandom,
// sin interfaz común. Ahora hay una interfaz y una factoría.
RandomGenerator r = RandomGenerator.getDefault();
RandomGenerator xoshiro = RandomGenerator.of("Xoshiro256PlusPlus"); // rápido, buena calidad
RandomGenerator.SplittableGenerator divisible =
RandomGenerator.SplittableGenerator.of("L64X128MixRandom"); // para trabajo paralelo
r.ints(10, 1, 100).forEach(System.out::println);
r.doubles(5).forEach(System.out::println);
// Listar los algoritmos disponibles
RandomGeneratorFactory.all()
.map(RandomGeneratorFactory::name)
.sorted()
.forEach(System.out::println);
// ⚠️ Para tokens, contraseñas, identificadores de sesión o nonces: SecureRandom, SIEMPRE.
// RandomGenerator es para simulación y rendimiento, no para criptografía.
byte[] token = new byte[32];
SecureRandom.getInstanceStrong().nextBytes(token);
- Encapsulación fuerte por defecto (JEP 403): el interruptor
--illegal-accessdesaparece. Hasta Java 16 podías abrir todos los internos del JDK con una sola opción; desde 17 hay que enumerar cada apertura con--add-opens/--add-exports. Es el cambio que rompe migraciones, y es deliberado: la deuda de veinte años de reflexión sobre internos se cobra aquí. SecurityManagerdeprecado para eliminación (JEP 411): nunca funcionó bien como sandbox, casi nadie lo usaba correctamente y su coste de mantenimiento era enorme. Se ha ido desactivando en versiones posteriores (a partir de Java 24 ya no se puede habilitar). El aislamiento hoy se hace con contenedores, usuarios del sistema, políticas de red yseccomp/AppArmor.- Applet API deprecada para eliminación (y eliminada en una versión posterior de la serie 22–25). Junto a Java Web Start (fuera desde 11) cierra la era del Java en el navegador.
- Filtros de deserialización por contexto (JEP 415): permiten definir por punto de entrada qué clases se aceptan al deserializar. Mitigación importante si aún usas serialización nativa (y la recomendación sigue siendo no usarla).
- Punto flotante siempre estricto (JEP 306) y puerto macOS/AArch64 (JEP 391): Java nativo en los Mac con Apple Silicon.
- Se eliminan el compilador AOT experimental (
jaotc) y RMI Activation.
Checklist — Java 17
8. Java 21 LTS (2023): el salto más grande desde Java 8
Si Java 8 cambió cómo transformas datos, Java 21 cambia cómo modelas el dominio y cómo escalas la concurrencia. Es el objetivo razonable de cualquier migración que empiece hoy.
8.1 Virtual threads: visión general
El problema de fondo: un hilo de plataforma es un hilo del sistema operativo, cuesta ~1 MB de pila y su cambio de contexto lo gestiona el kernel. Por eso los pools tienen 200 hilos y no 200.000, y por eso las aplicaciones de E/S intensiva se atascan: los hilos están bloqueados esperando, no trabajando. La industria respondió con programación reactiva (WebFlux, RxJava), que escala magníficamente a cambio de código difícil de escribir, de leer y de depurar.
Los virtual threads (JEP 444, Proyecto Loom) son hilos gestionados por la JVM, no por el sistema operativo: cuestan cientos de bytes, se crean por millones y, cuando se bloquean en una operación de E/S, se desmontan del hilo portador dejándolo libre para otro. El resultado es que el estilo bloqueante —el que todo el mundo sabe leer, depurar y perfilar— vuelve a escalar.
// Crear un millón de hilos. Sí, un millón. En un portátil.
try (var ejecutor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 1_000_000).forEach(i ->
ejecutor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // bloquear ya NO es un pecado
return i;
}));
} // close() espera a que terminen todas las tareas
// API directa
Thread virtual = Thread.ofVirtual().name("pedido-", 1).start(() -> procesar());
Thread plataforma = Thread.ofPlatform().daemon().start(() -> tareaDeFondo());
Thread.startVirtualThread(() -> enviarCorreo());
// En Spring Boot 3.2+: una línea de configuración y Tomcat atiende cada petición
// en un hilo virtual. Sin reescribir nada.
// spring.threads.virtual.enabled=true
ForkJoinPool—; (2) los pools de hilos virtuales no tienen sentido:
se crea uno por tarea, y el control de carga se hace con semáforos o limitadores, no limitando hilos;
(3) ThreadLocal con hilos virtuales puede consumir mucha memoria (un millón de copias), y de
ahí nacen los scoped values. El desarrollo completo, con pinning, monitorización y
patrones, está en módulo 03.
8.2 Pattern matching for switch y record patterns
Aquí converge todo lo anterior. Tras cuatro rondas de preview (17, 18, 19, 20), Java 21 estabiliza el
switch con patrones (JEP 441) y los patrones de record (JEP 440), que permiten
deconstruir objetos.
// ───── Patrones de tipo en switch ──────────────────────────────────────────────
// ❌ ANTES: cadena de if/instanceof, casts, y ningún control de exhaustividad
static String formatearViejo(Object o) {
if (o instanceof Integer) return "int: " + ((Integer) o);
else if (o instanceof Long) return "long: " + ((Long) o);
else if (o instanceof String) return "texto: " + ((String) o).strip();
else if (o == null) return "nulo";
else return "desconocido";
}
// ✅ AHORA
static String formatear(Object o) {
return switch (o) {
case null -> "nulo"; // sin este caso, un switch con patrones lanza NPE
case Integer i -> "int: " + i;
case Long l -> "long: " + l;
case String s -> "texto: " + s.strip();
case int[] a -> "array de " + a.length;
default -> "desconocido: " + o.getClass().getSimpleName();
};
}
// ───── Guardas con 'when': condiciones sobre el patrón ─────────────────────────
static String clasificar(Object o) {
return switch (o) {
case String s when s.isBlank() -> "cadena vacía";
case String s when s.length() > 100 -> "cadena larga (" + s.length() + ")";
case String s -> "cadena: " + s;
case Integer i when i < 0 -> "entero negativo";
case Integer i -> "entero " + i;
default -> "otro";
};
}
// ⚠️ Regla de DOMINANCIA: los casos se prueban en orden y el compilador rechaza un caso
// que ya esté "cubierto" (dominado) por uno anterior. Si pones 'case String s' antes de
// 'case String s when s.isBlank()', no compila. Lo específico va primero.
// ───── Record patterns: deconstrucción ─────────────────────────────────────────
record Punto(int x, int y) { }
record Segmento(Punto inicio, Punto fin) { }
record Circulo(Punto centro, double radio) { }
// Deconstrucción de un nivel: los componentes se extraen a variables directamente
static double longitud(Object o) {
return switch (o) {
case Segmento(Punto a, Punto b) -> Math.hypot(b.x() - a.x(), b.y() - a.y());
case Circulo(Punto c, double r) -> 2 * Math.PI * r;
default -> 0;
};
}
// Deconstrucción ANIDADA: se leen los componentes de los componentes
static String describir(Object o) {
return switch (o) {
// patrón anidado + var (infiere el tipo del componente) + guarda
case Segmento(Punto(var x1, var y1), Punto(var x2, var y2)) when x1 == x2 ->
"segmento vertical en x=" + x1 + " de y=" + y1 + " a y=" + y2;
case Segmento(Punto(var x1, var y1), Punto(var x2, var y2)) when y1 == y2 ->
"segmento horizontal en y=" + y1;
// se puede mezclar: deconstruir un componente y quedarse el otro entero
case Segmento(Punto inicio, Punto(var x2, var y2)) ->
"segmento de " + inicio + " a (" + x2 + "," + y2 + ")";
// el origen, como constante: patrón anidado con valores concretos… no existe en Java,
// así que se expresa con una guarda:
case Circulo(Punto(var cx, var cy), var r) when cx == 0 && cy == 0 ->
"círculo centrado en el origen, radio " + r;
case Circulo c -> "círculo en " + c.centro();
case null -> "nada";
default -> "objeto: " + o;
};
}
// ───── Exhaustividad sobre jerarquías selladas anidadas ────────────────────────
sealed interface Json { }
record JNulo() implements Json { }
record JBool(boolean valor) implements Json { }
record JNumero(double valor) implements Json { }
record JTexto(String valor) implements Json { }
record JArray(List<Json> elementos) implements Json { }
record JObjeto(Map<String, Json> campos) implements Json { }
// Un serializador completo, sin default, verificado por el compilador
static String serializar(Json j) {
return switch (j) {
case JNulo n -> "null";
case JBool(boolean b) -> String.valueOf(b);
case JNumero(double d) -> d == Math.rint(d) ? String.valueOf((long) d) : String.valueOf(d);
case JTexto(String s) -> '"' + s.replace("\"", "\\\"") + '"';
case JArray(List<Json> e) -> e.stream().map(Ejemplo::serializar)
.collect(Collectors.joining(",", "[", "]"));
case JObjeto(Map<String, Json> c) -> c.entrySet().stream()
.map(en -> '"' + en.getKey() + "\":" + serializar(en.getValue()))
.collect(Collectors.joining(",", "{", "}"));
}; // ✅ exhaustivo: añadir un JFecha rompería la compilación aquí. Perfecto.
}
// ───── También funciona con instanceof ─────────────────────────────────────────
if (evento instanceof PedidoConfirmado(String id, Dinero(BigDecimal importe, var moneda))) {
metricas.registrar(id, importe, moneda);
}
8.3 Sequenced collections: una carencia de 25 años
Muchas colecciones de Java tienen un orden de encuentro bien definido
(List, LinkedHashSet, TreeSet, Deque,
LinkedHashMap), pero no existía ningún tipo común que lo expresara ni una
forma uniforme de pedir «el primero», «el último» o «recórrelo al revés». El resultado era una tabla
absurda de inconsistencias:
| Colección | Primer elemento (antes) | Último elemento (antes) | Con Java 21 |
|---|---|---|---|
List | list.get(0) | list.get(list.size()-1) | getFirst() / getLast() |
Deque | getFirst() | getLast() | igual (ya estaba) |
SortedSet | first() | last() | getFirst() / getLast() |
LinkedHashSet | iterator().next() | 😱 iterar toda la colección | getFirst() / getLast() |
LinkedHashMap | entrySet().iterator().next() | 😱 iterar todo | firstEntry() / lastEntry() |
NUEVA JERARQUÍA (Java 21)
Collection
│
SequencedCollection ─────────────┐
· addFirst / addLast │
· getFirst / getLast │
· removeFirst / removeLast │
· reversed() │
│ │
┌──────┴──────┐ │
List SequencedSet SequencedMap
Deque · reversed() · putFirst / putLast
│ · firstEntry / lastEntry
┌───────┴───────┐ · pollFirstEntry / pollLastEntry
LinkedHashSet SortedSet · sequencedKeySet / sequencedValues
│ · sequencedEntrySet · reversed()
TreeSet │
┌────────┴────────┐
LinkedHashMap SortedMap → TreeMap
Punto clave: reversed() devuelve una VISTA, no una copia. Es O(1) y refleja los cambios.
// ❌ ANTES: obtener el último elemento insertado en un LinkedHashSet era vergonzoso
Set<String> visitados = new LinkedHashSet<>(List.of("a", "b", "c"));
String ultimo = null;
for (String s : visitados) ultimo = s; // O(n) para leer el último 😱
// ✅ AHORA
SequencedSet<String> s = new LinkedHashSet<>(List.of("a", "b", "c"));
s.getFirst(); // "a"
s.getLast(); // "c"
s.reversed(); // vista [c, b, a] — sin copiar
s.addFirst("z"); // inserta al principio, respetando el orden de encuentro
// Listas
List<Integer> l = new ArrayList<>(List.of(1, 2, 3));
l.getFirst(); // 1 (en lugar de l.get(0))
l.getLast(); // 3 (en lugar de l.get(l.size() - 1))
l.removeLast(); // 3, y la lista queda [1, 2]
l.reversed().forEach(System.out::println); // 2, 1 — sin Collections.reverse (que MUTA)
// Mapas
SequencedMap<String, Integer> m = new LinkedHashMap<>();
m.put("a", 1); m.put("b", 2);
m.putFirst("z", 0); // pasa a ser la primera entrada
m.firstEntry(); // z=0
m.lastEntry(); // b=2
m.pollFirstEntry(); // extrae y devuelve z=0
m.reversed(); // vista invertida del mapa
m.sequencedKeySet().getLast(); // "b"
// Caché LRU en 6 líneas, mucho más legible que antes
var lru = new LinkedHashMap<String, byte[]>(16, 0.75f, true) {
@Override protected boolean removeEldestEntry(Map.Entry<String, byte[]> e) {
return size() > 100;
}
};
// ⚠️ Cuidado al migrar: List ya tenía métodos con nombres parecidos en algunas
// implementaciones (por ejemplo LinkedList tenía getFirst/getLast desde Java 1.6).
// Y si tenías una clase propia que implementa List con un método getFirst() de otra
// semántica, ahora choca con el default de la interfaz.
8.4 String templates y structured concurrency: el estado real
Aquí es donde muchos apuntes de internet mienten, así que vamos con precisión:
| Feature | Estado | Qué debes hacer hoy |
|---|---|---|
| String templates interpolación segura: STR."Hola \{nombre}" |
Preview en Java 21 y en 22, y después retirada del JDK para rediseñarla. No es estándar y la sintaxis actual no es definitiva. | No usarla. Para construir texto: "…".formatted(...),
String.format, StringBuilder o un motor de plantillas. Si te preguntan,
explica que su objetivo era la interpolación con validación (evitar inyección SQL/HTML
por construcción) y que se retiró porque el diseño no convencía. |
Structured concurrencyStructuredTaskScope |
Preview desde Java 21, con cambios de API entre versiones (en Java 25 se
rediseñó hacia factorías StructuredTaskScope.open(...) con joiners, en lugar
de las subclases ShutdownOnFailure/ShutdownOnSuccess iniciales). |
Estudia el concepto (un scope que trata varias tareas concurrentes como
una unidad: si una falla, se cancelan las demás; nadie sale del bloque hasta que todas terminan) y
usa ExecutorService con hilos virtuales en producción hasta que se estabilice. |
Scoped valuesScopedValue |
Preview desde Java 21; estable en Java 25. | Es el sustituto de ThreadLocal pensado para millones de hilos virtuales: valor
inmutable, con ámbito acotado y heredado por las tareas hijas. Ideal para el usuario autenticado o
el traceId de una petición. |
// Structured concurrency: el CONCEPTO, que es lo que hay que entender
// (la API exacta ha cambiado entre versiones; comprueba la de tu JDK antes de copiar)
//
// try (var scope = ...abrir un ámbito...) {
// var usuario = scope.fork(() -> usuarioApi.buscar(id)); // tarea hija 1
// var pedidos = scope.fork(() -> pedidosApi.ultimos(id)); // tarea hija 2
// scope.join(); // espera a las dos
// return new Perfil(usuario.get(), pedidos.get());
// }
//
// Garantías que aporta frente a un ExecutorService suelto:
// 1. NADIE sale del bloque try dejando tareas huérfanas corriendo.
// 2. Si una hija falla, las demás se CANCELAN automáticamente (no se desperdicia trabajo).
// 3. Si el hilo padre se interrumpe, la interrupción se propaga hacia abajo.
// 4. Las trazas de pila y el volcado de hilos muestran la RELACIÓN padre-hijo:
// depurar concurrencia deja de ser adivinar.
// Scoped values: alternativa a ThreadLocal para hilos virtuales (estable en Java 25)
private static final ScopedValue<Usuario> USUARIO = ScopedValue.newInstance();
void manejarPeticion(Peticion p) {
ScopedValue.where(USUARIO, autenticar(p)).run(() -> {
procesar(p); // cualquier método llamado aquí dentro puede leerlo…
}); // …y al salir del ámbito, el valor desaparece
}
void auditar() {
if (USUARIO.isBound()) log.info("acción de {}", USUARIO.get().email());
}
// Diferencias con ThreadLocal: inmutable (no hay set()), ámbito explícito (imposible olvidar
// remove() y fugar memoria) y herencia eficiente por las tareas hijas del scope.
8.5 ZGC generacional, KEM y otros cambios de API
- ZGC generacional (JEP 439): ZGC aprende la hipótesis generacional (la mayoría de los
objetos mueren jóvenes) y reduce muchísimo el uso de CPU y de memoria frente al ZGC original. En Java 21
se activaba con
-XX:+ZGenerational; en versiones posteriores pasó a ser el modo por defecto y el no generacional se retiró. Práctica: si usas ZGC en 21, actívalo; si migras a una versión más reciente, quita el flag porque ya es el comportamiento normal. - Key Encapsulation Mechanism API (JEP 452):
javax.crypto.KEM, la infraestructura estándar para algoritmos de encapsulado de claves. Es la base sobre la que después han entrado los algoritmos resistentes a computación cuántica (ML-KEM y ML-DSA en la serie 24–25). Relevante para el módulo 10. - Añadidos pequeños de API que se usan enseguida:
Math.clamp(valor, min, max),StringBuilder.repeat,String.splitWithDelimiters,Character.isEmoji,Thread.threadId()(sustituye al deprecadogetId()),HttpClientimplementandoAutoCloseable. - Deprecación del puerto x86 de 32 bits (retirado después): si construyes para arquitecturas antiguas, planifícalo.
- Vector API sigue en incubación (a la espera de Valhalla) y FFM llega a su última preview antes de estabilizarse en Java 22.
Checklist — Java 21
9. Java 22–25: qué se ha estabilizado y qué sigue cocinándose
9.1 Stream gatherers (estable en Java 24)
La Stream API tenía un agujero conocido: podías escribir colectores propios para la operación
terminal (Collector), pero no operaciones intermedias propias. Todo lo que
no fuera map/filter/flatMap obligaba a salir del stream. Los
gatherers abren ese punto de extensión: Stream.gather(Gatherer).
// Utilidades listas para usar en java.util.stream.Gatherers
// Ventanas fijas: agrupar de N en N (¡el clásico "particionar una lista en lotes"!)
List<List<Integer>> lotes = Stream.of(1,2,3,4,5,6,7)
.gather(Gatherers.windowFixed(3))
.toList(); // [[1,2,3], [4,5,6], [7]]
// Ventanas deslizantes: pares consecutivos, medias móviles…
List<List<Integer>> pares = Stream.of(1,2,3,4)
.gather(Gatherers.windowSliding(2))
.toList(); // [[1,2], [2,3], [3,4]]
// scan: acumulados intermedios (saldo tras cada movimiento)
List<Integer> acumulado = Stream.of(1,2,3,4)
.gather(Gatherers.scan(() -> 0, Integer::sum))
.toList(); // [1, 3, 6, 10]
// fold: reducción con estado, como operación intermedia
// mapConcurrent: aplicar una función con N tareas concurrentes (hilos virtuales)
List<Respuesta> respuestas = urls.stream()
.gather(Gatherers.mapConcurrent(10, this::llamarApi)) // máximo 10 en vuelo
.toList();
// Un gatherer propio: "distinctBy", que la API nunca tuvo
static <T, K> Gatherer<T, ?, T> distinctPor(Function<T, K> clave) {
return Gatherer.ofSequential(
HashSet::new, // estado inicial
(vistos, elemento, salida) -> { // integrador
if (((Set<K>) vistos).add(clave.apply(elemento))) salida.push(elemento);
return true; // true = seguir consumiendo
});
}
List<Pedido> unoPorCliente = pedidos.stream().gather(distinctPor(Pedido::cliente)).toList();
// Antes esto se hacía con un truco horrible: filter con un Set externo mutable (con efectos
// laterales, roto en paralelo) o pasando por un Map intermedio.
9.2 Foreign Function & Memory API (estable en Java 22)
Sustituye a JNI (que exigía escribir y compilar C, y era una fuente inagotable de fallos) y a los
hacks con sun.misc.Unsafe y DirectByteBuffer. Permite llamar a
bibliotecas nativas y manejar memoria fuera del heap con seguridad de tipos y liberación
determinista, todo desde Java.
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
// Llamar a strlen(3) de la libc, sin escribir una línea de C
try (Arena arena = Arena.ofConfined()) { // ámbito: al cerrarse, libera la memoria
Linker linker = Linker.nativeLinker();
MethodHandle strlen = linker.downcallHandle(
linker.defaultLookup().find("strlen").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));
MemorySegment texto = arena.allocateFrom("Hola, mundo"); // copia a memoria nativa
long longitud = (long) strlen.invokeExact(texto); // → 11
} // aquí la memoria se libera de forma determinista, sin GC ni free() manual
// Memoria off-heap con seguridad de límites y de tipos
try (Arena arena = Arena.ofShared()) {
MemorySegment buffer = arena.allocate(1024);
buffer.set(ValueLayout.JAVA_INT, 0, 42);
int leido = buffer.get(ValueLayout.JAVA_INT, 0);
// Acceder fuera de los límites lanza IndexOutOfBoundsException, no corrompe el proceso.
}
// Estructuras nativas descritas declarativamente
StructLayout punto = MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y"));
--enable-native-access=ALL-UNNAMED (o por módulo). La dirección del proyecto es clara: toda
operación insegura debe ser explícita y auditable. Lo mismo está ocurriendo con
sun.misc.Unsafe, cuyos métodos de acceso a memoria están deprecados para eliminación; su
sustituto son VarHandle y esta API.
9.3 Class-File API (estable en Java 24)
Una API estándar (java.lang.classfile) para leer, escribir y transformar ficheros
.class. Nació de un problema interno: el JDK dependía de una copia de ASM que había que
actualizar cada seis meses, con el formato de clase cambiando en cada versión. Te afecta si trabajas con
agentes Java, instrumentación, generación de código, análisis estático o frameworks que crean proxies.
import java.lang.classfile.*;
// Leer y analizar una clase sin dependencias externas
ClassModel modelo = ClassFile.of().parse(Path.of("target/classes/com/ejemplo/Servicio.class"));
System.out.println("clase: " + modelo.thisClass().asInternalName());
modelo.methods().forEach(m ->
System.out.println(" método " + m.methodName().stringValue() + m.methodType().stringValue()));
// Transformar: quitar todos los métodos que empiecen por "debug"
byte[] transformada = ClassFile.of().transformClass(modelo,
ClassTransform.dropping(e -> e instanceof MethodModel m
&& m.methodName().stringValue().startsWith("debug")));
9.4 Variables y patrones sin nombre: _ (estable en Java 22)
// El guion bajo dice "aquí hay algo, y no me importa qué". El compilador lo comprueba.
// En un catch cuya excepción no se usa
try { return Integer.parseInt(texto); }
catch (NumberFormatException _) { return 0; } // antes: 'e' sin usar → aviso del linter
// En parámetros de lambda no utilizados
mapa.forEach((clave, _) -> log.info("clave presente: {}", clave));
// En for-each cuando solo cuentas
int total = 0;
for (var _ : elementos) total++;
// En try-with-resources donde el recurso solo hace falta abierto
try (var _ = MDC.putCloseable("traceId", traceId)) { procesar(); }
// En patrones: el uso más potente. Deconstruyo lo que necesito e ignoro el resto.
record Punto(int x, int y) { }
record Segmento(Punto inicio, Punto fin) { }
switch (figura) {
case Segmento(Punto(var x, _), _) -> "empieza en x=" + x; // ignoro y, e ignoro el fin
case Circulo(_, var radio) -> "radio " + radio;
default -> "otra";
}
// Y para asignaciones cuyo valor se descarta explícitamente
var _ = colaDeMensajes.poll(); // "sí, sé que devuelve algo y lo estoy descartando a propósito"
9.5 Ficheros fuente compactos y main de instancia (estable en Java 25)
Tras cuatro rondas de preview (21 a 24), Java 25 estabiliza una de las mejoras más importantes para la
enseñanza y el scripting: escribir un programa sin clase explícita, sin
static, sin String[] args y sin System.out.println. La ceremonia
que había que explicar antes de la primera línea de código útil desaparece.
// Fichero Hola.java completo, en Java 25. Esto es TODO el fichero.
void main() {
IO.println("¿Cómo te llamas?");
var nombre = IO.readln();
IO.println("Hola, " + nombre);
}
// Ejecutar: java Hola.java
// Comparado con lo que había que escribir en Java 8:
// public class Hola {
// public static void main(String[] args) throws java.io.IOException {
// System.out.println("¿Cómo te llamas?");
// var lector = new java.io.BufferedReader(new java.io.InputStreamReader(System.in));
// System.out.println("Hola, " + lector.readLine());
// }
// }
// Import de módulo completo (estable en Java 25): un import en lugar de veinte
import module java.base; // trae java.util, java.io, java.time, java.util.stream…
void main() {
var numeros = List.of(3, 1, 4, 1, 5); // java.util, sin import explícito
var suma = numeros.stream().mapToInt(Integer::intValue).sum();
IO.println("suma = " + suma + " a las " + LocalTime.now());
}
public static void el primer día) y convierte a Java en una opción
razonable para scripts y utilidades de un solo fichero, terreno que había cedido por completo a
Python. Además, un fichero compacto se puede ampliar gradualmente a una clase completa sin reescribirlo:
la transición es continua.
9.6 Lo que sigue en preview o incubación (a fecha de este material)
| Feature | Estado | Qué es y por qué te interesa |
|---|---|---|
| Structured concurrency | Preview (con cambios de API entre versiones) | Tratar un grupo de tareas concurrentes como una unidad con ciclo de vida. Es el complemento natural de los hilos virtuales |
| Primitive types in patterns | Preview | Permitir primitivos en instanceof y en case, con conversiones seguras comprobadas: case int i when i > 0. Cierra un hueco de irregularidad del pattern matching |
| Vector API | Incubación (muchas rondas) | Cálculo SIMD explícito y portable. Espera a Valhalla (tipos de valor) para estabilizarse. Interesa en ML, procesamiento de señal e imagen |
| Stable values | Preview | Constantes de inicialización perezosa que el JIT puede tratar como verdaderamente finales: el holder idiom convertido en API |
| Compact object headers | Experimental y después activado por defecto | Cabecera de objeto de 8 bytes en lugar de 12–16. Ahorro de heap medible (a menudo del 5–15%) sin cambiar código |
| AOT class loading & linking | Estable a partir de la serie 24–25, con ergonomía mejorada | Proyecto Leyden: se graba el trabajo de carga, verificación y enlazado de clases (y perfiles de métodos) en una caché AOT que reduce mucho el arranque. El sucesor natural de CDS/AppCDS |
| String templates | Retirada tras dos previews | No existe hoy en el lenguaje. Se rediseñará |
# AOT class loading: dos comandos y el arranque baja de forma notable
# (JEP 483 y siguientes; comprueba los nombres exactos de las opciones en tu JDK)
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar app.jar # 1) entrenar
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
-XX:AOTCache=app.aot -jar app.jar # 2) crear la caché
java -XX:AOTCache=app.aot -jar app.jar # 3) usarla
# Regla de oro: mide con y sin caché en TU aplicación. Las mejoras publicadas
# (30–50% de arranque en aplicaciones grandes) dependen mucho del caso.
10. Tabla resumen: versión → 3 features que uso a diario
Esta tabla es deliberadamente práctica, no exhaustiva: si en una entrevista te preguntan «¿qué te aporta cada versión?», responder con tres cosas concretas que usas demuestra experiencia real; recitar veinte JEPs demuestra que te has estudiado la Wikipedia.
| Versión | Feature 1 | Feature 2 | Feature 3 |
|---|---|---|---|
| 8 | Lambdas y Streams | Optional |
java.time (y Map.computeIfAbsent/merge) |
| 9 | List.of/Set.of/Map.of |
Optional.stream/or/ifPresentOrElse |
jshell (y takeWhile/dropWhile) |
| 10 | var |
List.copyOf / Collectors.toUnmodifiableList |
Optional.orElseThrow() (y MaxRAMPercentage en contenedores) |
| 11 | HttpClient |
String.isBlank/strip/lines/repeat |
Files.readString/writeString (y java Fichero.java) |
| 14 | switch expressions con -> y yield |
NPE con mensaje útil | Collectors.teeing (12) y String.transform |
| 15–16 | Text blocks | Records | instanceof con patrón y Stream.toList() |
| 17 | Sealed classes | Records + instanceof con patrón como base de modelado |
RandomGenerator (y encapsulación fuerte, que sufres más que usas) |
| 21 | Virtual threads | Pattern matching for switch + record patterns | Sequenced collections (getFirst/getLast/reversed) |
| 22–25 | Stream gatherers (windowFixed, mapConcurrent) |
Patrones y variables sin nombre (_) |
Ficheros fuente compactos, import module, scoped values, caché AOT |
11. Migración real: de Java 8 a 17 o 21 sin romper el negocio
Una migración de versión de Java no es un cambio de imagen Docker. Es un proyecto con inventario, orden de dependencias, cambios de comportamiento silenciosos y un plan de vuelta atrás. Lo que sigue es el guion que funciona, en el orden en que funciona.
11.1 Estrategia paso a paso
RUTA DE MIGRACIÓN RECOMENDADA (no te saltes pasos: cada uno reduce el riesgo del siguiente)
0. LÍNEA BASE Tests verdes en CI con Java 8. Métricas guardadas: tiempo de arranque,
RSS, p95/p99, throughput, pausas de GC. Sin esto no podrás demostrar nada.
│
1. INVENTARIO jdeps, jdeprscan, dependency:tree. ¿APIs internas? ¿javax EE? ¿CORBA?
¿librerías abandonadas? Lista de riesgos priorizada.
│
2. HERRAMIENTAS Subir Maven/Gradle y TODOS los plugins ANTES de tocar el JDK.
(compiler, surefire, shade, jacoco, lombok, mockito, bytebuddy, asm)
│
3. EJECUTAR EN NUEVO Compilar con --release 8, EJECUTAR con JDK 17/21. ← paso clave
El bytecode antiguo es válido; así validas el RUNTIME sin tocar código.
Aquí saldrán los --add-opens, los GC eliminados y los charsets.
│
4. SUBIR --release 8 → 11 → 17 (→ 21). Un salto por PR, con CI verde entre saltos.
Corregir avisos de deprecación en cada escalón.
│
5. DEPENDENCIAS De abajo arriba: primero utilidades (Jackson, Guava, ASM), luego
frameworks (Hibernate, Spring), luego el BOM completo.
│
6. javax → jakarta SOLO si vas a Jakarta EE 9+/Spring Boot 3. Automatizado (OpenRewrite
o Eclipse Transformer), nunca a mano.
│
7. MODERNIZAR CÓDIGO Records, switch expressions, text blocks, var, List.of… en PRs
SEPARADOS de la migración. Nunca mezcles "que funcione" con "que sea
bonito": si algo falla, no sabrás qué lo rompió.
│
8. VALIDAR Regresión completa, pruebas de carga, canary/blue-green, feature flags.
Comparar métricas con la línea base del paso 0.
│
9. AJUSTAR GC, memoria, virtual threads, CDS/AOT. Ahora sí, con datos.
--release 8 y ejecutar con el JDK nuevo separa dos clases de problemas que
de otro modo se mezclan: los de runtime (encapsulación, GC eliminados, charset, TLS, librerías
que usan reflexión) y los de compilación (APIs eliminadas, identificadores reservados como
var, _ o yield). Depurar los dos a la vez multiplica el coste.
11.2 Inventario: mide antes de mover nada
# ───── 1. ¿Uso APIs internas del JDK que van a estar encapsuladas? ─────────────
jdeps --jdk-internals --multi-release 21 target/mi-app.jar
jdeps --jdk-internals -R --class-path 'libs/*' target/mi-app.jar # incluye dependencias
# Salida típica: "sun.misc.Unsafe → JDK internal API (java.base)"
# "Suggested replacement: VarHandle o la FFM API"
# ───── 2. ¿Uso APIs deprecadas o ya eliminadas? ────────────────────────────────
jdeprscan --release 17 target/mi-app.jar
jdeprscan --for-removal --release 21 target/mi-app.jar
jdeprscan --release 11 --class-path 'libs/*' target/mi-app.jar
# ───── 3. ¿Qué módulos del JDK necesito realmente? ─────────────────────────────
jdeps --print-module-deps --ignore-missing-deps target/mi-app.jar
# ───── 4. Árbol de dependencias y conflictos ───────────────────────────────────
mvn dependency:tree -Dverbose > arbol.txt
mvn dependency:analyze # declaradas y no usadas / usadas y no declaradas
mvn versions:display-dependency-updates
mvn versions:display-plugin-updates # ← los PLUGINS son la causa nº 1 de fallos al migrar
mvn enforcer:enforce # reglas: prohibir duplicados, exigir versión de Java
# Gradle
./gradlew dependencies --configuration runtimeClasspath
./gradlew dependencyInsight --dependency jackson-databind
# ───── 5. Buscar en el código los puntos calientes ─────────────────────────────
grep -rn "sun\.misc\|com\.sun\.\|javax\.xml\.bind\|javax\.annotation\|org\.omg" src/
grep -rn "setAccessible\|getDeclaredField\|Unsafe\|URLClassLoader" src/
grep -rn "SimpleDateFormat\|new Date(\|Calendar\.getInstance" src/
grep -rn "finalize()\|SecurityManager\|Thread.stop\|Applet" src/
grep -rn "UseConcMarkSweepGC\|PermSize\|MaxPermSize" . --include=*.sh --include=*.yaml --include=Dockerfile
# ───── 6. Detectar el charset implícito (bomba de relojería en Java 18+) ───────
grep -rn "new String(\|getBytes()\|new FileReader(\|new FileWriter(\|new InputStreamReader(" src/ \
| grep -v "UTF_8\|StandardCharsets"
11.3 Actualizar el build y los plugins (antes que el JDK)
Regla contraintuitiva pero cierta: la mayoría de los fallos de una migración no son de tu código,
son de tus herramientas. Maven, Gradle, Lombok, Mockito y cualquier cosa que manipule bytecode
llevan dentro una versión de ASM que debe conocer el formato de clase de la nueva versión. Si no, verás
Unsupported class file major version 65.
<!-- Maven: configuración correcta y moderna -->
<properties>
<maven.compiler.release>21</maven.compiler.release> <!-- ✅ NO uses source/target -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<compilerArgs>
<arg>-parameters</arg> <!-- nombres de parámetros: Spring, Jackson, JPA -->
<arg>-Xlint:all,-serial</arg>
<arg>-Werror</arg> <!-- avisos = errores: evita acumular deuda -->
</compilerArgs>
</configuration>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<!-- Parches de encapsulación necesarios para algunos frameworks de test -->
<argLine>
--add-opens java.base/java.lang=ALL-UNNAMED
--add-opens java.base/java.util=ALL-UNNAMED
-XX:+EnableDynamicAgentLoading
-Dfile.encoding=UTF-8
</argLine>
</configuration>
</plugin>
</plugins>
</build>
| Herramienta | Por qué falla con un JDK nuevo | Qué hacer |
|---|---|---|
| Maven | Versiones antiguas no reconocen el JDK ni usan HTTPS en los repositorios | Maven 3.9+ (y wrapper mvnw en el repositorio) |
| Gradle | Cada versión de Gradle soporta un rango cerrado de JDK; ASM interno desactualizado | Gradle 8.5+ para Java 21; consulta la matriz de compatibilidad y usa toolchains |
| Lombok | Manipula el AST del compilador con API interna: siempre hay que subirlo | Última versión, y valora eliminarlo donde un record haga el trabajo |
| Mockito / ByteBuddy | Generan clases en runtime; además Java 21 avisa de agentes cargados dinámicamente | Mockito 5+, y -XX:+EnableDynamicAgentLoading o declarar el -javaagent |
| JaCoCo | Instrumenta bytecode: versiones antiguas no entienden el formato nuevo | JaCoCo 0.8.11+ |
| Jackson | Necesita reflexión y soporte de records y de java.time | 2.12+ (records) y módulo jackson-datatype-jsr310 |
| Hibernate | Proxies con ByteBuddy y el cambio javax → jakarta | Hibernate 6.x para jakarta.persistence |
| Spring | CGLIB, reflexión masiva; Boot 3 exige Java 17 y jakarta.* | Boot 2.7 (último de la serie javax) → Boot 3.x. No intentes los dos saltos a la vez |
| shade / assembly / spring-boot-maven-plugin | Reescriben clases y manifiestos, incluidos multi-release jars | Actualizar y verificar que el fat jar conserva META-INF/versions |
11.4 javax → jakarta: el cambio que no es de Java
Este punto genera más confusión que ningún otro, así que conviene entender de dónde viene: cuando Oracle
donó Java EE a la Eclipse Foundation, no cedió la marca «Java». Por eso Jakarta EE 9
tuvo que renombrar todos los paquetes javax.* de la especificación a jakarta.*.
Es un cambio de Jakarta EE, no del JDK, y ocurre cuando subes de Spring Boot 2 a 3, no cuando
subes de Java 11 a 17.
| Sí cambia (Jakarta EE) | NO cambia nunca (es del JDK) |
|---|---|
javax.servlet → jakarta.servlet | javax.sql (DataSource) |
javax.persistence → jakarta.persistence | javax.crypto |
javax.validation → jakarta.validation | javax.naming (JNDI) |
javax.annotation (@PostConstruct) → jakarta.annotation | javax.management (JMX) |
javax.transaction → jakarta.transaction | javax.net.ssl |
javax.ws.rs (JAX-RS) → jakarta.ws.rs | javax.swing, javax.imageio, javax.sound |
javax.jms, javax.mail, javax.xml.bind | javax.script, javax.tools, javax.security.auth |
# ───── OPCIÓN A (recomendada): OpenRewrite, refactorización automática y revisable ─────
mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \
-Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-migrate-java:RELEASE \
-Drewrite.activeRecipes=org.openrewrite.java.migrate.jakarta.JavaxMigrationToJakarta
# Otras recetas muy rentables (revisa el catálogo de recetas de tu versión del plugin):
# org.openrewrite.java.migrate.UpgradeToJava21 → moderniza el código a Java 21
# org.openrewrite.java.migrate.Java8toJava11 → primer escalón
# org.openrewrite.java.migrate.util.JavaUtilAPIs → List.of, Optional moderno…
# org.openrewrite.java.testing.junit5.JUnit4to5Migration → JUnit 4 → 5
# org.openrewrite.staticanalysis.CommonStaticAnalysis → limpieza general
# Ventaja clave: OpenRewrite trabaja sobre el ÁRBOL SINTÁCTICO con tipos resueltos, no con
# expresiones regulares. Genera un diff que se revisa en un PR como cualquier otro cambio.
# ───── OPCIÓN B: Eclipse Transformer, sobre artefactos ya compilados ───────────
java -jar org.eclipse.transformer.cli.jar -tf jakarta-renames.properties \
app-javax.jar app-jakarta.jar
# Útil cuando NO tienes el código fuente de una dependencia. También existe la
# herramienta de migración a Jakarta EE de Apache Tomcat, con el mismo propósito.
# ───── OPCIÓN C: el IDE ───────────────────────────────────────────────────────
# IntelliJ IDEA tiene una inspección/refactor de migración a Jakarta EE. Bien para
# proyectos pequeños; en proyectos grandes prefiere OpenRewrite por ser reproducible en CI.
# ❌ LO QUE NUNCA DEBES HACER
find . -name '*.java' -exec sed -i 's/javax/jakarta/g' {} + # ROMPE javax.sql, javax.crypto…
11.5 --add-opens y --add-exports: el parche, no la cura
# Sintaxis
--add-opens <módulo>/<paquete>=<módulo destino o ALL-UNNAMED> # reflexión profunda (runtime)
--add-exports <módulo>/<paquete>=<módulo destino o ALL-UNNAMED> # acceso a tipos públicos
--add-modules <módulo> # añadir un módulo al grafo
# Los que aparecen una y otra vez en aplicaciones heredadas
java \
--add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.lang.reflect=ALL-UNNAMED \
--add-opens java.base/java.util=ALL-UNNAMED \
--add-opens java.base/java.util.concurrent=ALL-UNNAMED \
--add-opens java.base/java.text=ALL-UNNAMED \
--add-opens java.base/java.time=ALL-UNNAMED \
--add-opens java.base/java.nio=ALL-UNNAMED \
--add-opens java.base/sun.nio.ch=ALL-UNNAMED \
--add-opens java.desktop/java.awt.font=ALL-UNNAMED \
-jar app.jar
# Dónde ponerlos según el contexto
export JAVA_TOOL_OPTIONS="--add-opens java.base/java.lang=ALL-UNNAMED" # afecta a TODA JVM hija
export MAVEN_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED" # solo al proceso Maven
# En surefire/failsafe: <argLine> · En Gradle test: jvmArgs
# En un jar ejecutable, en el MANIFEST.MF:
# Add-Opens: java.base/java.lang java.base/java.util
# Enable-Native-Access: ALL-UNNAMED
# ⚠️ En Java 9–16 existía el atajo --illegal-access=permit. DESDE JAVA 17 NO EXISTE:
# la JVM lo ignora (o avisa) y hay que enumerar cada apertura. No hay vuelta atrás.
--add-opens es una librería
que hurga en los internos del JDK, es decir, una bomba de relojería para la próxima migración. Buenas
prácticas: (1) documenta en un comentario qué librería exige cada apertura; (2) crea un ticket
por cada una para actualizar o sustituir esa librería; (3) revisa la lista en cada subida de versión y
borra las que ya no hagan falta. Un proyecto sano tiende a cero aperturas.
11.6 Cambios de comportamiento silenciosos (los que muerden en producción)
Estos son peores que los errores de compilación: compila, arranca, pasa los tests y hace algo distinto. Repásalos uno por uno antes de desplegar.
| Cambio | Desde | Síntoma | Qué hacer |
|---|---|---|---|
| Charset por defecto = UTF-8 (JEP 400) | 18 | Antes el charset dependía de la plataforma y del locale. Al fijarse en UTF-8, los ficheros y datos escritos con ISO-8859-1 se leen como é, �… o al revés |
Especificar el charset siempre y explícitamente en toda E/S. Parche temporal: -Dfile.encoding=COMPAT. Para conocer el del sistema: propiedad native.encoding |
| Datos de locale CLDR por defecto | 9 | Cambian formatos de fecha, nombres de mes y día, separadores y símbolos de moneda. Tests de formato que fallan y facturas con otro aspecto. En español, además, los números de 4 cifras no llevan separador de miles («1234», no «1.234») | Usar DateTimeFormatter/NumberFormat con patrón y Locale explícitos. Parche temporal: -Djava.locale.providers=COMPAT,CLDR (el proveedor COMPAT se ha ido deprecando y eliminando: no te apoyes en él) |
| G1 como GC por defecto | 9 | Otro perfil de pausas y de memoria que con Parallel: mejor latencia, algo menos de throughput bruto, mayor consumo | Medir con la carga real. Si es un proceso por lotes puro, valorar -XX:+UseParallelGC. No cambiar a ciegas |
| CMS eliminado | 14 | -XX:+UseConcMarkSweepGC → la JVM no arranca |
Quitar el flag (G1) o pasar a ZGC/Shenandoah si necesitas pausas mínimas. Revisa también MaxPermSize, que ya no existe |
getSystemClassLoader() ya no es URLClassLoader |
9 | ClassCastException: jdk.internal.loader.ClassLoaders$AppClassLoader cannot be cast to java.net.URLClassLoader |
Eliminar el truco de «añadir jars al classpath en caliente». Usar -cp, un URLClassLoader propio o un mecanismo de plugins explícito |
| TLS 1.0/1.1 y algoritmos débiles deshabilitados | 8u/11+ | SSLHandshakeException contra sistemas antiguos; certificados con SHA-1 o claves cortas rechazados |
Actualizar el otro extremo. Como último recurso y temporalmente, ajustar jdk.tls.disabledAlgorithms en java.security, documentando el riesgo |
Precisión de Instant.now() |
9 | Microsegundos en lugar de milisegundos: comparaciones exactas con valores persistidos empiezan a fallar | truncatedTo(ChronoUnit.MILLIS) al persistir o al comparar |
Orden de iteración de Set.of/Map.of |
9 | Varía entre ejecuciones de la JVM (hay una sal aleatoria): tests que pasan en tu portátil y fallan en CI | No depender del orden; si lo necesitas, LinkedHashSet/List u ordenar explícitamente |
String compacto |
9 | Reflexión sobre String.value (que era char[] y ahora es byte[]) revienta |
Eliminar esa reflexión; suele venir de librerías de serialización antiguas |
| Agentes cargados dinámicamente avisan | 21 | WARNING: A Java agent has been loaded dynamically (típico de Mockito inline y algunos APM) |
-XX:+EnableDynamicAgentLoading o declarar el agente con -javaagent al arrancar |
javadoc más estricto (doclint) |
8+ | El build falla por HTML mal formado o @param ausentes |
Arreglar los comentarios (mejor) o -Xdoclint:none mientras tanto |
Runtime.exec(String) deprecado |
18 | Avisos; y la división en palabras siempre fue una fuente de bugs y de inyección de comandos | ProcessBuilder con lista de argumentos |
| Nombres de identificadores reservados | 9/10/14/22 | Variables llamadas var, yield, record o _; métodos llamados yield |
Renombrar. _ como identificador dejó de ser válido en Java 9 y hoy es un patrón |
11.7 Tabla de urgencias: error típico al migrar → causa → solución
| Error | Causa | Solución |
|---|---|---|
InaccessibleObjectException: Unable to make field … accessible: module java.base does not "opens java.lang" to unnamed module |
Encapsulación fuerte de JPMS (Java 17+). Una librería usa reflexión sobre internos del JDK | Actualizar la librería (solución real). Parche: --add-opens java.base/java.lang=ALL-UNNAMED |
NoClassDefFoundError: javax/xml/bind/JAXBException |
JEP 320 eliminó los módulos Java EE del JDK en Java 11 | Añadir jakarta.xml.bind-api + jaxb-runtime (serie 2.3.x si sigues en javax, 4.x si ya usas jakarta) |
UnsupportedClassVersionError: … class file version 61.0, this version … recognizes up to 55.0 |
Compilado con Java 17 (61) y ejecutado con Java 11 (55) | Alinear maven.compiler.release con el JRE de la imagen. Tabla: 52=8, 55=11, 61=17, 65=21, 69=25 |
Unsupported class file major version 65 en Gradle, Lombok, Mockito, JaCoCo o ASM |
La herramienta lleva un ASM que no conoce el formato de clase nuevo | Subir esa herramienta. Es la causa número uno de fallos de build al migrar |
ClassCastException: …$AppClassLoader cannot be cast to java.net.URLClassLoader |
El cargador de sistema cambió de tipo en Java 9 | Eliminar el truco de manipular el classpath en caliente |
Unrecognized VM option 'UseConcMarkSweepGC' / 'MaxPermSize' |
CMS eliminado en 14; PermGen desapareció en 8 | Quitar los flags. Revisar todos los sitios: scripts, Dockerfile, systemd, Helm, JAVA_OPTS |
| Acentos y eñes corruptos tras subir a Java 18+ | Charset por defecto ahora UTF-8 (JEP 400) | Charset explícito en E/S, BD, HTTP y consola. Parche: -Dfile.encoding=COMPAT |
| Tests de fechas o de importes que fallan sin tocar código | Datos de locale CLDR y agrupación de miles distinta | Patrones y Locale explícitos en los tests; nunca depender del locale del entorno |
NoSuchMethodError en Spring, Hibernate o Jackson |
Versiones descoordinadas de la misma familia de librerías | Importar el BOM (spring-boot-dependencies) y revisar mvn dependency:tree -Dverbose |
LinkageError / loader constraint violation con clases de servlet o de JPA |
Coexisten la API javax y la jakarta en el classpath |
Excluir la antigua con <exclusions>; migrar todos los módulos a la vez |
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called |
Los métodos de acceso a memoria de Unsafe están deprecados para eliminación |
Actualizar la librería culpable (Netty, Cassandra, cachés off-heap). A futuro: VarHandle y la FFM API |
WARNING: A Java agent has been loaded dynamically |
Java 21 avisa de la carga dinámica de agentes (Mockito inline, APM) | -XX:+EnableDynamicAgentLoading o declarar -javaagent al arrancar |
InvalidDefinitionException / Cannot construct instance con records en Jackson |
Jackson antiguo, o falta -parameters al compilar |
Jackson 2.12+ y <arg>-parameters</arg> en el compiler plugin |
SSLHandshakeException contra un sistema heredado |
TLS 1.0/1.1 y algoritmos débiles deshabilitados por defecto | Actualizar el otro extremo; como excepción documentada y temporal, ajustar java.security |
La JVM ignora los límites de memoria del contenedor y el pod muere con OOMKilled |
JDK antiguo sin conciencia de cgroups, o -Xmx fijo mal calculado |
JDK 11+ y -XX:MaxRAMPercentage=75. Añadir -XX:+ExitOnOutOfMemoryError y volcado de heap |
jlink/jpackage falla: automatic module cannot be used with jlink |
Dependencias sin module-info y sin Automatic-Module-Name |
Pedir/añadir Automatic-Module-Name en el manifest, o renunciar a jlink con el classpath completo |
| Todo compila y arranca, pero el arranque es más lento que en Java 8 | Más clases que verificar, distinta configuración de GC, CDS deshabilitado | Activar AppCDS o la caché AOT; revisar -Xshare; medir con JFR el desglose del arranque |
11.8 Herramientas que hacen el trabajo por ti
OpenRewrite
Motor de refactorización automatizada que opera sobre un árbol sintáctico enriquecido con tipos (LST), no con expresiones regulares. Se ejecuta como plugin de Maven o Gradle, produce un diff revisable y es idempotente, así que se puede pasar en CI.
UpgradeToJava17/UpgradeToJava21: sube elrelease, moderniza APIs y aplica cientos de reglas.JavaxMigrationToJakarta: el renombrado, hecho con conocimiento de tipos.- Recetas de Spring Boot 2 → 3, JUnit 4 → 5, Mockito, Lombok → records,
SimpleDateFormat→DateTimeFormatter. - Puedes escribir recetas propias (YAML o Java) para convenciones internas de tu empresa.
Cómo usarlo bien: una receta por PR, tests verdes entre PRs, y revisa el diff. No es magia: es un compañero muy rápido que se equivoca poco.
Error Prone y NullAway
Dos plugins del compilador que convierten clases enteras de bug en errores de compilación. No son de migración, pero al migrar es el momento perfecto para introducirlos: ya estás tocando el build y arreglando avisos.
- Error Prone (Google): detecta cientos de patrones peligrosos que
javacacepta —comparar tipos incompatibles conequals, resultado de un método ignorado,Optional.get()sin comprobar, formatos deprintfincorrectos, referencias en@Deprecated—. Muchas comprobaciones traen autofix. - NullAway: análisis de nulabilidad rápido (coste típico de un pequeño porcentaje del tiempo de compilación) basado en anotaciones
@Nullable. Es lo más parecido a la null safety de Kotlin que puedes tener en Java.
Complementos habituales: SpotBugs, PMD, Checkstyle, ArchUnit (reglas de arquitectura como tests) y SonarQube en CI. Ver módulo 07.
<!-- Error Prone + NullAway en el maven-compiler-plugin -->
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-XDcompilePolicy=simple</arg>
<arg>--should-stop=ifError=FLOW</arg>
<arg>-Xplugin:ErrorProne -XepOpt:NullAway:AnnotatedPackages=com.ejemplo</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>2.28.0</version>
</path>
<path>
<groupId>com.uber.nullaway</groupId>
<artifactId>nullaway</artifactId>
<version>0.11.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
11.9 Validación: pruebas, despliegue progresivo y medición
Una migración se justifica con números, no con entusiasmo. Y se despliega con red.
- Regresión completa antes de tocar nada: si la cobertura de los caminos críticos es baja, primero escribe tests de caracterización (que documentan el comportamiento actual, incluidos sus defectos). Testcontainers para bases de datos y colas reales; contract tests para las integraciones.
- Cambios de comportamiento invisibles: compara salidas, no solo códigos de estado. Un shadow traffic (duplicar el tráfico real contra la nueva versión y comparar respuestas sin devolverlas al cliente) detecta diferencias de formato de fecha o de importes que ningún test unitario ve.
- Feature flags y despliegue progresivo: canary (5% → 25% → 100%) o blue-green, con vuelta atrás en un clic. Nunca «todo el parque el viernes por la tarde».
- Un cambio por despliegue: versión de Java, luego Spring Boot, luego
javax→jakarta, luego modernización de código. Si van juntos y algo se rompe, el diagnóstico se multiplica. - Mide antes y después exactamente lo mismo, con la misma carga.
| Métrica | Cómo medirla | Qué esperar al pasar de 8 a 17/21 |
|---|---|---|
| Tiempo de arranque | Log de Spring Boot; JFR (jdk.InitialSystemProperty, eventos de carga de clases) | Similar o algo peor sin ajustes; notablemente mejor con AppCDS o caché AOT |
| RSS / memoria del pod | kubectl top, jcmd <pid> VM.native_memory | Suele bajar por los compact strings y, en versiones recientes, por las cabeceras de objeto compactas; G1 puede reservar algo más |
| Pausas de GC (p99) | -Xlog:gc*, JFR, dashboards de Micrometer | Mucho mejor: G1 frente a Parallel, y ZGC si de verdad necesitas < 1 ms |
| Latencia p95/p99 del servicio | Prueba de carga con k6/Gatling contra un entorno idéntico | Mejora moderada por el JIT y las librerías; grande en E/S concurrente si adoptas hilos virtuales |
| Throughput | La misma prueba de carga, saturando | Mejora típica de un dígito medio a alto en porcentaje, muy dependiente del caso |
| CVEs abiertas | OWASP Dependency-Check, Trivy, mvn versions:display-dependency-updates | Baja mucho: es a menudo el argumento que aprueba el proyecto |
Checklist — migración
12. Java moderno idiomático: la misma clase en Java 8 y en Java 21
La mejor forma de ver qué aporta todo lo anterior es escribir el mismo servicio dos veces. Mismo comportamiento, misma corrección, mismos casos cubiertos. Fíjate en la proporción entre líneas que expresan reglas de negocio y líneas que son ceremonia del lenguaje.
12.1 Versión Java 8
// ═══════════════════════════════════════════════════════════════════════════════
// JAVA 8 — ~150 líneas, de las cuales unas 25 son lógica de negocio
// ═══════════════════════════════════════════════════════════════════════════════
package com.ejemplo.informes;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
public class InformeVentasService {
// ── DTO: 45 líneas para transportar 4 valores ──────────────────────────────
public static final class LineaInforme {
private final String region;
private final BigDecimal total;
private final int pedidos;
private final Date generado;
public LineaInforme(String region, BigDecimal total, int pedidos, Date generado) {
if (region == null) throw new IllegalArgumentException("region");
if (total == null) throw new IllegalArgumentException("total");
this.region = region;
this.total = total;
this.pedidos = pedidos;
this.generado = new Date(generado.getTime()); // copia defensiva: Date es MUTABLE
}
public String getRegion() { return region; }
public BigDecimal getTotal() { return total; }
public int getPedidos() { return pedidos; }
public Date getGenerado() { return new Date(generado.getTime()); } // otra copia
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
LineaInforme that = (LineaInforme) o;
return pedidos == that.pedidos
&& region.equals(that.region)
&& total.equals(that.total)
&& generado.equals(that.generado);
}
@Override
public int hashCode() { return Objects.hash(region, total, pedidos, generado); }
@Override
public String toString() {
return "LineaInforme{region='" + region + "', total=" + total
+ ", pedidos=" + pedidos + ", generado=" + generado + '}';
}
}
// ── Clasificación por tipo: cadena de instanceof con casts ─────────────────
public String describir(Object evento) {
if (evento instanceof PedidoConfirmado) {
PedidoConfirmado p = (PedidoConfirmado) evento;
if (p.getTotal().compareTo(new BigDecimal("1000")) > 0) {
return "pedido grande " + p.getId() + " por " + p.getTotal();
}
return "pedido " + p.getId();
} else if (evento instanceof PedidoCancelado) {
PedidoCancelado c = (PedidoCancelado) evento;
return "cancelado " + c.getId() + ": " + c.getMotivo();
} else if (evento instanceof PedidoDevuelto) {
PedidoDevuelto d = (PedidoDevuelto) evento;
return "devuelto " + d.getId();
} else if (evento == null) {
return "evento nulo";
}
// Si mañana alguien añade PedidoReembolsado, este método devuelve "desconocido"
// en silencio. El compilador NO avisa. Bug en producción garantizado.
return "desconocido";
}
// ── Agregación ─────────────────────────────────────────────────────────────
public List<LineaInforme> generar(List<Pedido> pedidos) {
Map<String, List<Pedido>> porRegion = new HashMap<String, List<Pedido>>();
for (Pedido p : pedidos) {
if (!"CONFIRMADO".equals(p.getEstado())) continue;
List<Pedido> lista = porRegion.get(p.getRegion());
if (lista == null) {
lista = new ArrayList<Pedido>();
porRegion.put(p.getRegion(), lista);
}
lista.add(p);
}
Date ahora = new Date();
List<LineaInforme> resultado = new ArrayList<LineaInforme>();
for (Map.Entry<String, List<Pedido>> e : porRegion.entrySet()) {
BigDecimal total = BigDecimal.ZERO;
for (Pedido p : e.getValue()) total = total.add(p.getTotal());
resultado.add(new LineaInforme(e.getKey(),
total.setScale(2, RoundingMode.HALF_UP), e.getValue().size(), ahora));
}
Collections.sort(resultado, new Comparator<LineaInforme>() {
public int compare(LineaInforme a, LineaInforme b) {
return b.getTotal().compareTo(a.getTotal());
}
});
return Collections.unmodifiableList(resultado);
}
// ── SQL y JSON: concatenación ilegible ────────────────────────────────────
private static final String SQL =
"SELECT p.region, SUM(p.total) AS total, COUNT(*) AS pedidos\n" +
" FROM pedido p\n" +
" WHERE p.estado = 'CONFIRMADO'\n" +
" AND p.creado_en >= ?\n" +
" GROUP BY p.region\n" +
" ORDER BY total DESC";
// NO es thread-safe: compartido entre peticiones produce fechas corruptas
private static final SimpleDateFormat FMT = new SimpleDateFormat("yyyy-MM-dd");
public String aJson(LineaInforme l) {
return "{\"region\":\"" + l.getRegion() + "\","
+ "\"total\":" + l.getTotal() + ","
+ "\"pedidos\":" + l.getPedidos() + ","
+ "\"generado\":\"" + FMT.format(l.getGenerado()) + "\"}";
}
// ── El último elemento de un LinkedHashSet: hay que iterarlo todo ─────────
public String ultimaRegionVisitada(LinkedHashSet<String> visitadas) {
String ultima = null;
for (String s : visitadas) ultima = s;
return ultima;
}
}
12.2 La misma clase en Java 21
// ═══════════════════════════════════════════════════════════════════════════════
// JAVA 21 — ~60 líneas, casi todas lógica de negocio
// ═══════════════════════════════════════════════════════════════════════════════
package com.ejemplo.informes;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
public class InformeVentasService {
// ── DTO: 1 línea + validación explícita. Inmutable, con equals/hashCode/toString ──
public record LineaInforme(String region, BigDecimal total, int pedidos, Instant generado) {
public LineaInforme {
Objects.requireNonNull(region, "region");
Objects.requireNonNull(total, "total");
// Instant es inmutable: no hacen falta copias defensivas en ninguna dirección
}
}
// ── Clasificación: exhaustiva, verificada por el compilador ────────────────
// (siendo Evento una interfaz sealed con esos cuatro records como subtipos)
public String describir(Evento evento) {
return switch (evento) {
case PedidoConfirmado(String id, BigDecimal total, var cuando)
when total.compareTo(new BigDecimal("1000")) > 0 ->
"pedido grande %s por %s".formatted(id, total);
case PedidoConfirmado(String id, var total, var cuando) -> "pedido " + id;
case PedidoCancelado(String id, String motivo) -> "cancelado %s: %s".formatted(id, motivo);
case PedidoDevuelto(String id, var cuando) -> "devuelto " + id;
case null -> "evento nulo";
};
// (En Java 22+ los componentes que no se usan se escriben con el patrón sin
// nombre: case PedidoConfirmado(String id, _, _) -> "pedido " + id;)
// Si mañana se añade PedidoReembolsado a la jerarquía sellada,
// ESTE MÉTODO NO COMPILA. El bug se convierte en un error de compilación.
}
// ── Agregación: declarativa, en una expresión ─────────────────────────────
public List<LineaInforme> generar(List<Pedido> pedidos) {
var ahora = Instant.now();
return pedidos.stream()
.filter(p -> p.estado() == Estado.CONFIRMADO)
.collect(Collectors.groupingBy(Pedido::region))
.entrySet().stream()
.map(e -> new LineaInforme(
e.getKey(),
e.getValue().stream()
.map(Pedido::total)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP),
e.getValue().size(),
ahora))
.sorted(Comparator.comparing(LineaInforme::total).reversed())
.toList(); // ya es inmutable: sin unmodifiableList
}
// ── SQL y JSON legibles ──────────────────────────────────────────────────
private static final String SQL = """
SELECT p.region, SUM(p.total) AS total, COUNT(*) AS pedidos
FROM pedido p
WHERE p.estado = 'CONFIRMADO'
AND p.creado_en >= ?
GROUP BY p.region
ORDER BY total DESC""";
// Thread-safe: se puede compartir sin miedo
private static final DateTimeFormatter FMT = DateTimeFormatter.ISO_INSTANT;
public String aJson(LineaInforme l) {
return """
{"region":"%s","total":%s,"pedidos":%d,"generado":"%s"}"""
.formatted(l.region(), l.total(), l.pedidos(), FMT.format(l.generado()));
}
// ── El último elemento: un método ────────────────────────────────────────
public String ultimaRegionVisitada(SequencedSet<String> visitadas) {
return visitadas.isEmpty() ? null : visitadas.getLast();
}
}
| Aspecto | Java 8 | Java 21 | Qué se gana |
|---|---|---|---|
| DTO de 4 campos | ~45 líneas | ~6 líneas | Menos superficie donde equivocarse; equals/hashCode siempre coherentes |
| Clasificación por tipo | if/instanceof + casts, default silencioso | switch exhaustivo con deconstrucción | Un caso olvidado pasa de bug en producción a error de compilación |
| Agregación | 3 bucles y estructuras mutables intermedias | Una tubería declarativa | Se lee como el requisito; sin variables mutables que compartir por error |
| Fechas | Date mutable + SimpleDateFormat no thread-safe | Instant + DateTimeFormatter | Elimina copias defensivas y una clase entera de bugs de concurrencia |
| SQL y JSON | Concatenación con \n | Text blocks | Se copia y pega en un cliente SQL; se revisa de un vistazo |
| Inmutabilidad del resultado | Collections.unmodifiableList (vista) | toList() (inmutable) | Garantía real, no un envoltorio sobre algo mutable |
| Último elemento de un set ordenado | Iterar toda la colección | getLast() | Intención explícita y coste O(1) |
equals, garantizar inmutabilidad, comprobar que todos los casos están tratados, copiar
defensivamente. Eso es exactamente lo que un lenguaje debe hacer por ti.
13. Errores comunes y cómo solucionarlos
| Error / síntoma | Causa habitual | Solución |
|---|---|---|
Usar -source/-target en lugar de --release | Compila contra las APIs del JDK actual aunque el bytecode sea antiguo | --release N: valida también las firmas de esa versión y evita NoSuchMethodError en runtime |
Publicar un artefacto compilado con --enable-preview | Las clases con preview están atadas a una versión exacta del JDK | Nunca en librerías; en aplicaciones, solo si controlas el runtime exacto |
var lista = new ArrayList<>(); | Infiere ArrayList<Object> y todo lo que saques será Object | Indicar el tipo: new ArrayList<String>(), o declarar el tipo de la variable |
Record con componente List/Map/array sin copiar | El record garantiza que la referencia no cambia, no que el objeto sea inmutable | List.copyOf(...) en el constructor compacto; con arrays, sobrescribir equals/hashCode |
Intentar usar un record como entidad JPA | Hibernate necesita constructor sin argumentos, mutabilidad y proxies | Entidad como clase; record como DTO o proyección |
default -> throw new IllegalStateException() en un switch sobre tipos sellados | Anula la comprobación de exhaustividad: el compilador ya no te avisará | Omitir default y dejar que el compilador verifique |
Orden de case con guardas mal puesto | Regla de dominancia: un caso general antes de uno específico | Lo específico primero. El compilador lo rechaza, así que es error, no bug |
NullPointerException en un switch con patrones | Un switch con patrones lanza NPE si el selector es null y no hay case null | Añadir case null -> (o case null, default ->) |
| Text block con sangría inesperada | La posición del """ de cierre define el margen que se elimina | Alinear el cierre con el contenido; comprobar imprimiendo entre corchetes |
| Esperar interpolación en un text block | Java no tiene interpolación de cadenas (las string templates se retiraron) | .formatted(...) o String.format |
Un HttpClient por petición | Cada instancia trae pool de conexiones e hilos propios | Una instancia reutilizada; en Java 21+ ciérrala con try-with-resources si es efímera |
Asumir que HttpClient lanza excepción con un 500 | No lo hace: solo falla por errores de red o de protocolo | Comprobar statusCode() siempre |
LocalDateTime para marcas de tiempo de eventos | No identifica un instante: sin zona no sabes a qué momento se refiere | Instant (o OffsetDateTime si necesitas conservar el offset) |
Instant para un cumpleaños o una fecha de factura | Es un instante absoluto, no una fecha civil; se desplaza según la zona | LocalDate |
SimpleDateFormat como constante estática | No es thread-safe: produce fechas corruptas de forma intermitente | DateTimeFormatter, que sí lo es |
Patrón "YYYY-MM-dd" | YYYY es «año basado en semanas»: cambia en la última semana del año | "yyyy-MM-dd" |
Modificar el resultado de List.of, toList() o Map.of | Son inmutables por diseño | new ArrayList<>(...) si necesitas mutar |
Depender del orden de Set.of/Map.of en un test | El orden varía entre ejecuciones de la JVM, a propósito | Ordenar explícitamente o usar LinkedHashSet |
Reemplazar javax por jakarta con sed | Rompe javax.sql, javax.crypto, javax.naming… que son del JDK | OpenRewrite o Eclipse Transformer, que conocen los tipos |
Dejar --add-opens «para siempre» sin documentar | Deuda invisible que estalla en la siguiente migración | Comentario con la librería responsable + ticket para eliminarlo |
Migrar Java, Spring Boot y jakarta en el mismo PR | Si algo falla, no hay forma de saber qué lo rompió | Un cambio grande por PR y por despliegue |
| Activar hilos virtuales y esperar más CPU | Solo mejoran la concurrencia de E/S | Medir; para cómputo puro, hilos de plataforma (módulo 03) |
Dejar -XX:+ZGenerational en versiones donde ya es el modo por defecto | El flag pasó a ser innecesario y el modo no generacional se retiró | Revisar los flags de JVM en cada subida de versión |
14. Preguntas frecuentes de entrevista
Si Java 8 funciona, ¿para qué migrar? Convénceme como si fuera tu jefe.
Con cuatro argumentos, en orden de peso para un negocio:
- Seguridad y soporte. Los parches de Java 8 dependen del proveedor y su horizonte es finito, pero el problema mayor es el ecosistema: Spring Boot 3, Hibernate 6 y la mayoría de librerías modernas ya no publican versiones compatibles con 8. Quedarse en 8 significa acumular CVEs sin parche disponible.
- Coste de infraestructura. Mejor recolector por defecto, compact strings, cabeceras de objeto compactas en versiones recientes, CDS y caché AOT: menos memoria por instancia y arranques más rápidos. En cientos de pods eso es una factura mensual medible.
- Capacidad técnica. Los hilos virtuales permiten multiplicar la concurrencia de E/S sin reescribir a programación reactiva, que es un proyecto carísimo. Ese solo punto suele pagar la migración.
- Personas. Nadie quiere mantener Java 8. Afecta a la contratación, a la retención y a la velocidad del equipo.
Y añade el contrapeso, que es lo que demuestra seniority: «el coste real está en las dependencias y en los cambios de comportamiento silenciosos, no en el JDK; por eso propongo hacerlo por escalones medibles, con métricas antes y después, y no todo de golpe».
¿Qué es exactamente una versión LTS y quién decide cuál lo es?
LTS (Long-Term Support) no es una etiqueta técnica del código: es un compromiso de soporte. OpenJDK publica una versión cada seis meses; el ecosistema (Oracle y el resto de proveedores, coordinados en el proyecto de actualizaciones de OpenJDK) acuerda qué versiones reciben parches durante años. Hoy son 8, 11, 17, 21 y 25, con cadencia de dos años desde 21 (antes eran tres).
Consecuencias prácticas: una versión no-LTS deja de recibir cualquier actualización de seguridad seis meses después de salir, así que en producción se va de LTS en LTS. Y como el soporte lo da el proveedor, el calendario concreto depende de si usas Temurin, Corretto, Oracle o Red Hat.
¿Diferencia entre Oracle JDK y OpenJDK? ¿Cuál instalo y qué licencia acepto?
OpenJDK es el proyecto de código abierto donde se desarrolla Java, bajo GPLv2 con Classpath Exception. Esa excepción es lo que te permite enlazar tu aplicación propietaria con la librería estándar sin contagiarte de la GPL. Todas las distribuciones (Temurin, Corretto, Zulu, Oracle JDK, Liberica, Semeru…) parten de ese mismo código; se diferencian en el empaquetado, la certificación TCK, las plataformas soportadas, el calendario de parches y el soporte comercial. Técnicamente son prácticamente equivalentes.
Oracle JDK es la build de Oracle y su particularidad es la licencia:
- Java 8 y 11 están bajo OTN: uso comercial en producción requiere suscripción desde 2019.
- Desde Java 17 Oracle publica bajo NFTC: gratis, incluso comercialmente, pero solo hasta un año después de que salga el siguiente LTS. Pasado ese plazo, seguir recibiendo actualizaciones de esa versión exige pagar.
- La Java SE Universal Subscription se factura por empleado de la empresa, no por servidor ni por instalación, lo que la hace muy caro para organizaciones grandes.
Qué instalar: Eclipse Temurin salvo que exista una razón concreta (Corretto si vives en
AWS, Red Hat si tu soporte es RHEL, GraalVM si quieres native-image, Semeru si la huella de
memoria es crítica). Así el tema de licencias desaparece.
¿Records o Lombok?
No compiten en el mismo terreno. Records para datos inmutables: DTO, eventos, value objects, claves de mapa, proyecciones, resultados. Son parte del lenguaje, sin dependencias, y son la única opción compatible con la deconstrucción por patrones.
Lombok para lo que el lenguaje no cubre: @Builder con muchos campos
opcionales, @Slf4j, @RequiredArgsConstructor en servicios y entidades JPA.
Y el argumento de riesgo que marca la diferencia en una entrevista: Lombok manipula el árbol sintáctico del compilador usando API interna no soportada. Cada versión nueva del JDK tiende a romperlo hasta que sale una versión de Lombok compatible, lo que puede bloquear una migración. Los records no tienen ese riesgo porque son lenguaje. Por eso la tendencia sana es reducir la superficie de Lombok, no ampliarla.
¿Cuándo un enum y cuándo una jerarquía sealed?
enum cuando tienes un conjunto fijo de valores con la misma forma
(o sin datos): estados, monedas, días de la semana, niveles de log. Te da instancias únicas,
== seguro, values(), EnumMap/EnumSet (muy eficientes)
y serialización trivial.
sealed + record cuando tienes un conjunto fijo de
formas de dato distintas: una Tarjeta tiene PAN y caducidad, un
Bizum solo un teléfono, una Transferencia un IBAN. Un enum no puede modelar
eso sin campos nullables, que es precisamente el error que quieres evitar.
La pista definitiva: si te sorprendes escribiendo un enum con campos que solo tienen sentido para
algunos valores, necesitabas una jerarquía sellada. Y ambos comparten la mejor propiedad: el
switch exhaustivo verificado por el compilador.
¿No mata var la legibilidad?
Solo si se usa mal. var no cambia el sistema de tipos: el tipado sigue
siendo estático y el compilador comprueba exactamente lo mismo; lo que desaparece es la repetición.
El criterio es: ¿el lector sabe qué contiene la variable sin ir a otro sitio? Con
var mapa = new HashMap<String, List<Pedido>>(); el tipo está a la vista y escribirlo
dos veces era ruido. Con var r = servicio.procesar(dto); has borrado la única pista que
había: ahí escribe el tipo o mejora el nombre.
Detalles que conviene mencionar: solo vale para variables locales (nunca en campos, parámetros ni
retornos, así que no puede degradar una API pública), no se puede usar sin inicializador
ni con null, y hay que tener cuidado con new ArrayList<>() sin
parámetro de tipo, que infiere Object.
¿Se pueden usar features en preview en producción?
Técnicamente sí, y su calidad de implementación es de producción, pero en la práctica no, por tres razones:
- Un
.classcompilado con--enable-previewsolo arranca en esa versión exacta del JDK. Un parche de seguridad que suba de versión mayor te deja el artefacto inservible. - La API o la sintaxis pueden cambiar en la siguiente versión (las string templates directamente se retiraron): te comprometes a reescribir código.
- Hay que arrancar toda la JVM con
--enable-preview, lo que afecta a herramientas, agentes y contenedores.
Uso correcto: probarlas en ramas experimentales o en entornos internos y enviar feedback a la lista de OpenJDK, que es exactamente para lo que existen. Y jamás publicar una librería compilada con preview.
¿Qué pasó con javax.*? ¿Todo se llama ahora jakarta.*?
No, y confundir esto rompe aplicaciones. Hay que separar tres cosas:
javax.*que es parte del JDK y no cambia nunca:javax.sql,javax.crypto,javax.naming,javax.management,javax.net.ssl,javax.swing,javax.script…javax.*de Java EE que Java 11 eliminó del JDK (JEP 320): JAX-B, JAX-WS, Activation, Common Annotations, JTA, CORBA. Siguen existiendo como dependencias externas; solo hay que añadirlas alpom.xml.- El renombrado a
jakarta.*, que no es un cambio de Java: cuando Java EE pasó a la Eclipse Foundation, la marca «Java» no se cedió, así que Jakarta EE 9 renombró los paquetes de la especificación. Afecta ajavax.servlet,javax.persistence,javax.validation,javax.annotation,javax.transaction,javax.ws.rs… y ocurre cuando subes de Spring Boot 2 a 3, no cuando subes de Java 11 a 17.
Por eso jamás se hace con un sed global: se hace con OpenRewrite o Eclipse Transformer,
que trabajan con los tipos resueltos.
¿Cómo sé si mi aplicación está lista para Java 21?
Con una secuencia concreta y barata, en un día de trabajo:
jdeps --jdk-internalsyjdeprscan --for-removal --release 21sobre el artefacto y sus dependencias: te da la lista de riesgos reales.- Buscar en el código
sun.misc,com.sun.,javax.xml.bind,setAccessible,URLClassLoader,SecurityManager,finalize(), y en los scriptsUseConcMarkSweepGCyMaxPermSize. - Revisar versiones de las herramientas que tocan bytecode: Gradle/Maven, Lombok, Mockito, ByteBuddy,
JaCoCo, y del framework (Spring Boot 3 exige 17 y
jakarta). - Compilar con
--release 8y ejecutar el conjunto de tests con el JDK 21. Este paso, solo, encuentra la mayor parte de los problemas de runtime. - Subir
--releasepor escalones (11 → 17 → 21), un PR por escalón, corrigiendo avisos. - Prueba de carga y comparación con la línea base; después, canary.
Y una respuesta honesta que gusta oír: «si aparece CORBA, un servidor de aplicaciones antiguo o un cliente SOAP generado con herramientas obsoletas, eso no es una migración, es un proyecto aparte y hay que presupuestarlo como tal».
¿Hay que modularizar la aplicación con JPMS?
No, y casi nadie lo hace. El classpath sigue funcionando perfectamente y las aplicaciones
Spring Boot no ganan nada modularizándose (usan un fat jar con su propio ClassLoader y dependen
masivamente de reflexión, que exige opens por todas partes).
Lo que sí importa es que el JDK está modularizado, y de ahí vienen la encapsulación
fuerte (los InaccessibleObjectException), la desaparición de javax.* EE y la
posibilidad de usar jlink.
Dónde sí merece la pena JPMS: librerías públicas que quieran declarar una API estricta, aplicaciones de
escritorio empaquetadas con jlink/jpackage, y sistemas con arquitectura de
plugins. Para un microservicio, la encapsulación se consigue mejor con módulos de build separados y
reglas de ArchUnit.
¿Es Java 21 más rápido que Java 8? ¿Cuánto?
Sí, pero la respuesta profesional es «depende de qué midas, y hay que medirlo en tu aplicación».
- Throughput en un servicio de negocio típico: mejora, habitualmente en el rango de un dígito medio a alto en porcentaje, por mejoras acumuladas del JIT y de las librerías. No esperes milagros: si el cuello de botella es la base de datos, no cambiará nada.
- Latencia p99: mejora clara por el GC. G1 frente a Parallel ya es un salto, y ZGC lleva las pausas a menos de un milisegundo.
- Memoria: suele bajar por los compact strings y, en versiones recientes, por las cabeceras de objeto compactas.
- Arranque: puede empeorar ligeramente sin ajustes y mejorar mucho con AppCDS o caché AOT.
- Concurrencia de E/S: aquí el salto es de otro orden de magnitud, gracias a los hilos virtuales, pero requiere revisar el código (por ejemplo, quitar pools y limitar con semáforos).
¿Migro a 17, a 21 o espero a 25?
Depende de dónde estés:
- Si estás en Java 8: el objetivo es 21, con parada técnica en 17 para validar (17 es el mínimo de Spring Boot 3, así que lo vas a cruzar de todos modos). Ir a 17 y quedarse es dejar los hilos virtuales sobre la mesa.
- Si estás en 11: ve directo a 21. El salto es mucho menor de lo que parece porque los
cambios duros (JPMS,
javaxEE) ya los pagaste. - Si estás en 17: pasa a 21 cuando puedas y valora 25 en la siguiente ventana.
- Proyecto nuevo hoy: el LTS más reciente que soporte tu stack. Comprueba antes que tu versión de Spring Boot, tu proveedor de cloud y tus imágenes base lo soportan.
Y el criterio general: no esperes al «momento perfecto». Cuanto más tardas, más grande es el salto y más caro sale. Migrar cada dos años, de LTS a LTS, es un mantenimiento rutinario; migrar cada ocho, un proyecto de riesgo.
¿Los text blocks tienen interpolación de variables?
No. Un text block es solo una forma distinta de escribir un String: el resultado
es un String normal, con las mismas propiedades y, si es constante, incluso interned en el
pool. No hay interpolación.
Para insertar valores: .formatted(...) (mi preferido dentro de un text block),
String.format, concatenación o un motor de plantillas si es HTML de verdad. Las
string templates iban a resolverlo con validación incorporada (para evitar inyección
SQL/HTML por construcción), estuvieron dos versiones en preview y se retiraron para
rediseñarlas: hoy no existen en el lenguaje.
¿Stream.toList() es lo mismo que collect(Collectors.toList())?
No, y es una pregunta trampa excelente. Dos diferencias:
- Mutabilidad.
toList()(Java 16+) devuelve una lista inmodificable.Collectors.toList()no garantiza nada por contrato, pero en la práctica devuelve unArrayListmutable, y hay código que se apoya en eso. - Nulos.
toList()permite elementosnull(a diferencia deList.of(), que los rechaza). Es un detalle que sorprende a mucha gente.
Recomendación: usa toList() por defecto, y collect(Collectors.toCollection(ArrayList::new))
cuando de verdad necesites una lista mutable. Si necesitas garantía estricta de inmutabilidad y sin
nulos, collect(Collectors.toUnmodifiableList()).
¿Debo cambiar de recolector de basura al migrar?
Por defecto, no toques nada y mide. Lo que sí debes hacer es quitar flags
obsoletos: -XX:+UseConcMarkSweepGC (CMS se eliminó en Java 14 y la JVM no arranca),
-XX:MaxPermSize (no existe desde Java 8) y, en versiones recientes,
-XX:+ZGenerational (ya es el modo por defecto).
Criterio después de medir: G1 (el defecto) para el 90% de los servicios; ZGC si necesitas pausas de menos de un milisegundo o tienes heaps de decenas de GB; Parallel para procesos por lotes donde solo cuenta el throughput; Serial en contenedores diminutos. Y siempre con logs de GC activados en producción, que son casi gratis.
¿Qué es la regla de dominancia en un switch con patrones?
Los case se evalúan en orden, así que un patrón más general
domina (hace inalcanzable) a uno más específico que venga después. Y como eso siempre es un
error del programador, el compilador lo rechaza en lugar de dejarlo pasar:
case Object o ->antes decase String s ->: no compila.case String s ->antes decase String s when s.isBlank() ->: no compila, porque el primero ya cubre todos losString.- Regla práctica: de lo más específico a lo más general, y las guardas
whenantes del caso sin guarda del mismo tipo.
Compáralo con el if/else if clásico, donde poner la condición general primero compila
perfectamente y te deja un bug silencioso. Es otro ejemplo de trabajo que ahora hace el compilador.
¿Por qué los records son más seguros al deserializar que una clase normal?
Porque la deserialización nativa de Java no llama al constructor: reconstruye el objeto campo a campo, saltándose todas tus validaciones. De ahí que un flujo serializado manipulado pueda crear objetos en estados imposibles, base de muchas vulnerabilidades conocidas.
Los records se deserializan invocando el constructor canónico, así que tus invariantes
del constructor compacto se aplican siempre. Además no admiten los ganchos personalizables
(writeObject, readObject, readResolve,
serialPersistentFields): su forma serializada es su lista de componentes, sin
superficie para trucos.
Dicho esto, la recomendación general sigue siendo no usar serialización nativa para datos que cruzan una frontera de confianza: JSON con tipos explícitos, y filtros de deserialización si no hay alternativa.
¿Cómo explico qué son los hilos virtuales en dos frases?
«Un hilo de plataforma es un hilo del sistema operativo: cuesta alrededor de un megabyte de pila, así que puedes tener cientos, no cientos de miles. Un hilo virtual lo gestiona la JVM, cuesta cientos de bytes y, cuando se bloquea esperando E/S, se desmonta del hilo portador y lo deja libre para otra tarea».
Y el remate que demuestra que lo entiendes: «el objetivo no es ir más rápido, es que el estilo bloqueante —el que todo el mundo sabe leer, depurar con un stack trace y perfilar— vuelva a escalar, sin tener que reescribir la aplicación en estilo reactivo».
15. Ejercicios y retos prácticos
15.1 Ejercicios guiados (haz los 8)
15.2 Retos (nivel entrevista senior)
16. Resumen y recursos
Las 15 ideas que debes llevarte
- Java saca una versión cada seis meses (marzo y septiembre) y una LTS cada dos años desde 21: 8, 11, 17, 21, 25. Las no-LTS viven seis meses y existen para dar feedback sobre previews.
- Preview (lenguaje, se activa con
--enable-previewy ata el.classa esa versión), incubator (API en módulojdk.incubator.*) y experimental (opciones de VM) son tres cosas distintas. No publiques artefactos con preview. - «Java» gratuito de producción es OpenJDK bajo GPLv2 + Classpath Exception: Temurin por defecto, Corretto en AWS. Oracle JDK cobra según licencia y momento, y factura por empleado.
- Java 8 sigue siendo la base cultural (lambdas, streams,
Optional,java.time), pero quedarse ahí significa perder virtual threads, records, mejores GC, menos memoria y compatibilidad con Spring Boot 3. - En
java.time:Instantpara «cuándo ocurrió»,LocalDatepara fechas civiles,OffsetDateTimepara APIs y base de datos,ZonedDateTimepara agendas con horario de verano.LocalDateTimeno identifica un instante. Durationes tiempo de máquina (segundos);Periodes tiempo de calendario (meses). «Un mes» no es una cantidad fija, y «sumar un día» no es «sumar 24 horas».- Casi nadie modulariza su aplicación con JPMS, pero el JDK sí está modularizado: de ahí la encapsulación fuerte de Java 17 y todos los
InaccessibleObjectExceptionde tu migración. vares inferencia local, no tipado dinámico. Úsalo cuando el inicializador hace obvio el tipo; evítalo cuando el tipo era la única información útil.- Java 11 dio
HttpClient, los métodos deStringque usas a diario y TLS 1.3, y quitó los módulos Java EE: ese es el dolor del salto desde 8. - Los records son portadores transparentes de datos inmutables: generan constructor, accesores,
equals,hashCodeytoString, se deserializan por el constructor y no sirven como entidades JPA. Copia defensiva obligatoria si un componente es mutable. - Sealed + records + switch con patrones convierten al compilador en verificador de tu dominio: un caso no tratado pasa de bug de producción a error de compilación. Nunca lo anules con un
defaultinnecesario. - Java 21 es el objetivo razonable hoy: virtual threads, pattern matching completo con deconstrucción y guardas, y sequenced collections (
getFirst,getLast,reversed). - En la serie 22–25 se estabilizaron stream gatherers, la Foreign Function & Memory API, la Class-File API, los patrones
_, los ficheros fuente compactos,import moduley los scoped values. Las string templates se retiraron; structured concurrency, primitivos en patrones y la Vector API siguen en preview o incubación. - Migrar bien es un método, no un cambio de imagen: línea base medida → inventario con
jdeps/jdeprscan→ herramientas → ejecutar en el JDK nuevo compilando aún con--release 8→ subir--releasepor escalones → dependencias →javax→jakartacon OpenRewrite → modernizar en PRs aparte → validar y medir. - Los cambios que muerden en silencio: charset UTF-8 por defecto (18), datos de locale CLDR (9), G1 por defecto (9) y CMS eliminado (14),
getSystemClassLoader()que ya no esURLClassLoader, TLS antiguo deshabilitado y la precisión deInstant.now().
Recursos oficiales
- Índice de JEPs — la fuente de verdad: qué entra en cada versión, en qué estado y por qué. Si dudas de una versión, se comprueba aquí.
- dev.java — portal oficial con tutoriales actualizados por versión, especialmente bueno en records, pattern matching y streams.
- Inside Java — artículos y podcast del equipo de la plataforma. Aquí se explican las decisiones de diseño.
- javaalmanac.io — imprescindible: diffs de API entre cualquier par de versiones. «¿Qué métodos nuevos hay de 11 a 21?» se responde en 10 segundos.
- Javadoc del JDK 21 y JLS/JVMS — la referencia y la ley.
- Adoptium (Temurin) — descargas y calendario de soporte.
Migración y herramientas
- Documentación de OpenRewrite — catálogo de recetas (
UpgradeToJava21, migración a Jakarta, JUnit 4→5) y guía para escribir las tuyas. - Error Prone y NullAway — convierte clases enteras de bug en errores de compilación.
- Eclipse Transformer — renombrado
javax→jakartasobre artefactos compilados. - Herramientas del propio JDK:
jdeps,jdeprscan,jlink,jpackage,jshell,jfr,jcmd. - SDKMAN! para gestionar varias versiones en local; toolchains de Maven/Gradle para fijar el JDK del build.
- Lecturas: Effective Java (Bloch, 3.ª ed.) para el criterio de fondo, y las guías oficiales de estilo de
vary de data-oriented programming en Java publicadas en Inside Java.
CompletableFuture, concurrencia estructurada y
scoped values—, que es justo donde Java 21 cambia de verdad la forma de construir servicios.