Plan de estudio Java 2026
Módulo 02 crítico muy preguntado Días 4–5 ≈ 5 h de estudio activo

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.

Progreso de este módulo0 / 0
Cómo leer este módulo: si vienes de Java Core y ya dominas lambdas y streams, puedes hojear la sección 2 y detenerte en 2.4 (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.
Idea que ordena todo el módulo: desde 2017 Java no evoluciona a saltos gigantes cada tres años, sino en incrementos pequeños cada seis meses que se cocinan durante varias versiones en modo preview. Por eso una feature “de Java 21” en realidad nació en Java 14 o 16. Entender el tren de releases es entender por qué el lenguaje es hoy tan distinto sin haberse roto nunca.

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:

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)
Analogía: el modelo antiguo era como un tren que no sale hasta que llegue el último pasajero; el actual es un tren de cercanías: sale puntual cada seis meses y quien no llegue coge el siguiente. Las estaciones LTS son las de largo recorrido: paradas donde el sistema se queda a vivir un par de años.

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:

MecanismoQué esCómo se activaGarantí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
Trampa de las preview features que arruina despliegues: un .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ónFechaTipoTitularSoporte
8mar 2014LTSLambdas, Streams, Optional, java.timeExtendido por varios proveedores hasta finales de esta década; Oracle pide suscripción para uso comercial desde 2019
9sep 2017JPMS (módulos), jshell, List.of, compact stringsTerminado (6 meses)
10mar 2018var, List.copyOf, conciencia de contenedoresTerminado
11sep 2018LTSHttpClient estándar, salida de javax.* EE, TLS 1.3, JFR libreActualizaciones de seguridad de la comunidad y proveedores durante años; Oracle ofrece soporte extendido de pago
12mar 2019switch expressions (preview), ShenandoahTerminado
13sep 2019Text blocks (preview), yieldTerminado
14mar 2020switch expressions estándar, records (preview), NPE útilesTerminado
15sep 2020Text blocks estándar, sealed (preview), ZGC en producciónTerminado
16mar 2021Records estándar, instanceof con patrón estándar, Stream.toList()Terminado
17sep 2021LTSSealed classes, encapsulación fuerte por defecto, RandomGeneratorMínimo de Spring Boot 3; soporte amplio de todos los proveedores
18mar 2022UTF-8 por defecto, servidor web simpleTerminado
19sep 2022Virtual threads (preview), record patterns (preview)Terminado
20mar 2023Iteraciones de Loom y de pattern matchingTerminado
21sep 2023LTSVirtual threads, pattern matching completo, sequenced collectionsEl objetivo razonable para migrar hoy
22mar 2024Foreign Function & Memory estable, patrones _Terminado
23sep 2024ZGC generacional por defecto, Javadoc en MarkdownTerminado
24mar 2025Stream gatherers y Class-File API estables, AOT class loadingTerminado
25sep 2025LTSFicheros fuente compactos, import module, scoped values, mejoras de arranqueEl LTS más reciente; destino natural de los proyectos nuevos
26 y siguientesmar 2026 →Continúa la cadencia de 6 meses; siguiente LTS previsto dos años después de 25Consultar el calendario oficial de OpenJDK
Cómo se dice esto en una entrevista, en dos frases: «Java saca una versión cada seis meses, en marzo y septiembre, y una LTS cada dos años desde Java 21: 8, 11, 17, 21 y 25. Las no-LTS existen para probar features en preview y dar feedback; en producción se va de LTS en LTS, y hoy el mínimo sensato es 17 porque lo exige Spring Boot 3, con 21 como objetivo por los virtual threads».

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ónQuién la haceLicencia / costeCuá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
El error de licencia más caro y más frecuente: descargar «Java» desde 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
Por qué esa restricción de 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 antiguaEquivalente modernoPor 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
SimpleDateFormatDateTimeFormatterThread-safe y reutilizable como constante
TimeZoneZoneId / ZoneOffsetSepara «región con reglas» de «desplazamiento fijo»
long millis para durarDurationAutodocumentado: nadie se pregunta si son segundos o milisegundos
System.currentTimeMillis()Instant.now(clock)Testeable inyectando un Clock
System.nanoTime()sigue siendo correcto para medirEs un reloj monótono, no una fecha: no lo formatees nunca
Detalle de precisión que rompe tests al migrar: en Java 8, 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ú.
No abuses de 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

// 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 8Qué 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 ciudad porque direccion es 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 String ASCII, 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:

// 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
}
DirectivaSignificadoCuándo la necesitas de verdad
requiresNecesito este módulo para compilar y ejecutarSiempre
requires transitiveAdemás lo re-exporto a quien me useSi tu API pública devuelve tipos de ese módulo
requires staticObligatorio al compilar, opcional al ejecutarAnotaciones, procesadores
exportsEstos paquetes son visibles para otros módulos en compilaciónTu API
exports … toVisible solo para módulos concretosSPI interno entre módulos propios
opensPermite reflexión sobre miembros no públicos en ejecuciónFrameworks: Jackson, Hibernate, Spring, JAXB
uses / provides…withDeclaración de servicios de ServiceLoaderArquitecturas 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

Pero sus consecuencias las sufre todo el mundo, incluso sin modularizar: el JDK sí está modularizado. Eso significa que sus internos están encapsulados, y desde Java 17 la encapsulación es fuerte por defecto y no se puede desactivar en bloque. De ahí vienen los dos errores más comunes de cualquier migración: 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.

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
Truco de producción: combina 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
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).
Cómo defenderlo en una entrevista: «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();
La mejora de Java 10 que más dinero ahorra no es 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
Error de rendimiento clásico: crear un 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 11PaqueteQué 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. -->
La confusión más importante de toda la migración: NO todo 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 javaxjakarta» a ciegas rompe la aplicación entera.

5.4 TLS 1.3, Flight Recorder libre, Epsilon, nestmates y un solo fichero

# 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);
}
No mezcles estilos: dentro de un mismo 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):

  1. El delimitador de apertura es """ seguido obligatoriamente de un salto de línea. String s = """hola"""; no compila.
  2. 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.
  3. Se eliminan los espacios finales de cada línea (para que no dependa de tu editor).
  4. Si el """ de cierre está en su propia línea, el texto acaba con \n. Si va pegado al último carácter, no.
  5. 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).
  6. No hay interpolación de variables. Se usa .formatted(...), String.format o 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)
Regla práctica: alinea el """ 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.
Nota de seguridad: el mensaje incluye nombres de variables locales y de campos. En un servicio expuesto a Internet, nunca devuelvas la traza al cliente: registra el detalle con un 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

# 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

CriteriorecordLombokClase a mano
DependenciasNinguna: es lenguajeLibrería + procesador de anotaciones + plugin del IDENinguna
MutabilidadInmutable, obligatorioA elección (@Data mutable, @Value inmutable)A elección
HerenciaNo puede extender clases
Pattern matching / deconstrucción (record patterns)NoNo
BuilderNo lo tiene (hazlo a mano o con una factoría)@Builder, muy cómodo con 8+ camposA mano
Riesgo con nuevas versiones del JDKCeroReal: manipula el AST del compilador con API interna; cada JDK nuevo suele requerir subir LombokCero
Entidades JPANo 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 frecuentesDTO, value object, evento, clave de mapa, resultado de consulta@Slf4j, @RequiredArgsConstructor, @SneakyThrowsLógica de dominio con estado
Respuesta madura a «records o Lombok»: «No compiten en el mismo terreno. Uso records para todo lo que sea datos inmutables: DTO de entrada y salida, eventos, value objects, resultados de consulta y proyecciones. Mantengo Lombok donde el lenguaje no llega: @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/hashCode correctos 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: @ConfigurationProperties con constructor binding.

❌ Cuándo NO usarlos

  • Entidades JPA: Hibernate necesita constructor sin argumentos, mutabilidad y proxies. (Como @Embeddable o 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 List mutable, 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…HerramientaPor 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
La disyuntiva de fondo (el «problema de expresión»): el polimorfismo clásico facilita añadir variantes pero obliga a tocar todas las clases al añadir una operación. Los tipos sellados con 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);

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
Tres cosas que hay que saber antes de activarlos: (1) no aceleran la CPU, solo la concurrencia de E/S —para cómputo puro siguen valiendo los hilos de plataforma y el 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);
}
Por qué esto es más que azúcar sintáctico: el conjunto sealed + record + switch con patrones convierte al compilador en un verificador de tu modelo de dominio. Los tres errores más caros del software de negocio —«no contemplamos ese caso», «ese campo llegó vacío» y «alguien añadió un estado y no lo trató»— pasan de ser incidencias de producción a ser errores de compilación. Eso es ingeniería, no estética.

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ónPrimer elemento (antes)Último elemento (antes)Con Java 21
Listlist.get(0)list.get(list.size()-1)getFirst() / getLast()
DequegetFirst()getLast()igual (ya estaba)
SortedSetfirst()last()getFirst() / getLast()
LinkedHashSetiterator().next()😱 iterar toda la coleccióngetFirst() / getLast()
LinkedHashMapentrySet().iterator().next()😱 iterar todofirstEntry() / 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:

FeatureEstadoQué 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 concurrency
StructuredTaskScope
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 values
ScopedValue
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

Checklist — Java 21

9. Java 22–25: qué se ha estabilizado y qué sigue cocinándose

Aviso de precisión: en esta sección la trampa es afirmar que algo «es de Java X» cuando estuvo tres versiones en preview. Cada apartado indica explícitamente el estado. Si en una entrevista no estás seguro de la versión exacta, di «se estabilizó en la serie 22–25» o «a partir de Java 2x»: es infinitamente mejor que inventar un número.

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"));
Acceso nativo restringido: a partir de la serie 22–25, usar FFM (o cargar bibliotecas nativas) emite avisos y el camino oficial es autorizarlo explícitamente con --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());
}
Por qué esto importa aunque escribas microservicios: baja la barrera de entrada (dar clase de Java ya no exige explicar 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)

FeatureEstadoQué 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ónFeature 1Feature 2Feature 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.
El paso 3 es el que casi nadie hace y el que más tiempo ahorra. Compilar con --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>
HerramientaPor qué falla con un JDK nuevoQué hacer
MavenVersiones antiguas no reconocen el JDK ni usan HTTPS en los repositoriosMaven 3.9+ (y wrapper mvnw en el repositorio)
GradleCada versión de Gradle soporta un rango cerrado de JDK; ASM interno desactualizadoGradle 8.5+ para Java 21; consulta la matriz de compatibilidad y usa toolchains
LombokManipula el AST del compilador con API interna: siempre hay que subirloÚltima versión, y valora eliminarlo donde un record haga el trabajo
Mockito / ByteBuddyGeneran clases en runtime; además Java 21 avisa de agentes cargados dinámicamenteMockito 5+, y -XX:+EnableDynamicAgentLoading o declarar el -javaagent
JaCoCoInstrumenta bytecode: versiones antiguas no entienden el formato nuevoJaCoCo 0.8.11+
JacksonNecesita reflexión y soporte de records y de java.time2.12+ (records) y módulo jackson-datatype-jsr310
HibernateProxies con ByteBuddy y el cambio javaxjakartaHibernate 6.x para jakarta.persistence
SpringCGLIB, 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-pluginReescriben clases y manifiestos, incluidos multi-release jarsActualizar y verificar que el fat jar conserva META-INF/versions

11.4 javaxjakarta: 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.servletjakarta.servletjavax.sql (DataSource)
javax.persistencejakarta.persistencejavax.crypto
javax.validationjakarta.validationjavax.naming (JNDI)
javax.annotation (@PostConstruct) → jakarta.annotationjavax.management (JMX)
javax.transactionjakarta.transactionjavax.net.ssl
javax.ws.rs (JAX-RS) → jakarta.ws.rsjavax.swing, javax.imageio, javax.sound
javax.jms, javax.mail, javax.xml.bindjavax.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.
Trátalos como deuda técnica con fecha de caducidad. Cada --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.

CambioDesdeSíntomaQué 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:+UseConcMarkSweepGCla 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

ErrorCausaSolució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 el release, 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, SimpleDateFormatDateTimeFormatter.
  • 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 javac acepta —comparar tipos incompatibles con equals, resultado de un método ignorado, Optional.get() sin comprobar, formatos de printf incorrectos, 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.

MétricaCómo medirlaQué esperar al pasar de 8 a 17/21
Tiempo de arranqueLog 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 podkubectl top, jcmd <pid> VM.native_memorySuele 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 MicrometerMucho mejor: G1 frente a Parallel, y ZGC si de verdad necesitas < 1 ms
Latencia p95/p99 del servicioPrueba de carga con k6/Gatling contra un entorno idénticoMejora moderada por el JIT y las librerías; grande en E/S concurrente si adoptas hilos virtuales
ThroughputLa misma prueba de carga, saturandoMejora típica de un dígito medio a alto en porcentaje, muy dependiente del caso
CVEs abiertasOWASP Dependency-Check, Trivy, mvn versions:display-dependency-updatesBaja 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();
    }
}
AspectoJava 8Java 21Qué se gana
DTO de 4 campos~45 líneas~6 líneasMenos superficie donde equivocarse; equals/hashCode siempre coherentes
Clasificación por tipoif/instanceof + casts, default silenciososwitch exhaustivo con deconstrucciónUn caso olvidado pasa de bug en producción a error de compilación
Agregación3 bucles y estructuras mutables intermediasUna tubería declarativaSe lee como el requisito; sin variables mutables que compartir por error
FechasDate mutable + SimpleDateFormat no thread-safeInstant + DateTimeFormatterElimina copias defensivas y una clase entera de bugs de concurrencia
SQL y JSONConcatenación con \nText blocksSe copia y pega en un cliente SQL; se revisa de un vistazo
Inmutabilidad del resultadoCollections.unmodifiableList (vista)toList() (inmutable)Garantía real, no un envoltorio sobre algo mutable
Último elemento de un set ordenadoIterar toda la coleccióngetLast()Intención explícita y coste O(1)
La conclusión que importa: el código Java 21 no es «más corto» por moda. Es más corto porque ha delegado en el compilador tareas que antes hacías a mano y podías hacer mal: generar 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íntomaCausa habitualSolución
Usar -source/-target en lugar de --releaseCompila 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-previewLas clases con preview están atadas a una versión exacta del JDKNunca en librerías; en aplicaciones, solo si controlas el runtime exacto
var lista = new ArrayList<>();Infiere ArrayList<Object> y todo lo que saques será ObjectIndicar el tipo: new ArrayList<String>(), o declarar el tipo de la variable
Record con componente List/Map/array sin copiarEl record garantiza que la referencia no cambia, no que el objeto sea inmutableList.copyOf(...) en el constructor compacto; con arrays, sobrescribir equals/hashCode
Intentar usar un record como entidad JPAHibernate necesita constructor sin argumentos, mutabilidad y proxiesEntidad como clase; record como DTO o proyección
default -> throw new IllegalStateException() en un switch sobre tipos selladosAnula 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 puestoRegla de dominancia: un caso general antes de uno específicoLo específico primero. El compilador lo rechaza, así que es error, no bug
NullPointerException en un switch con patronesUn switch con patrones lanza NPE si el selector es null y no hay case nullAñadir case null -> (o case null, default ->)
Text block con sangría inesperadaLa posición del """ de cierre define el margen que se eliminaAlinear el cierre con el contenido; comprobar imprimiendo entre corchetes
Esperar interpolación en un text blockJava no tiene interpolación de cadenas (las string templates se retiraron).formatted(...) o String.format
Un HttpClient por peticiónCada instancia trae pool de conexiones e hilos propiosUna instancia reutilizada; en Java 21+ ciérrala con try-with-resources si es efímera
Asumir que HttpClient lanza excepción con un 500No lo hace: solo falla por errores de red o de protocoloComprobar statusCode() siempre
LocalDateTime para marcas de tiempo de eventosNo identifica un instante: sin zona no sabes a qué momento se refiereInstant (o OffsetDateTime si necesitas conservar el offset)
Instant para un cumpleaños o una fecha de facturaEs un instante absoluto, no una fecha civil; se desplaza según la zonaLocalDate
SimpleDateFormat como constante estáticaNo es thread-safe: produce fechas corruptas de forma intermitenteDateTimeFormatter, 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.ofSon inmutables por diseñonew ArrayList<>(...) si necesitas mutar
Depender del orden de Set.of/Map.of en un testEl orden varía entre ejecuciones de la JVM, a propósitoOrdenar explícitamente o usar LinkedHashSet
Reemplazar javax por jakarta con sedRompe javax.sql, javax.crypto, javax.naming… que son del JDKOpenRewrite o Eclipse Transformer, que conocen los tipos
Dejar --add-opens «para siempre» sin documentarDeuda invisible que estalla en la siguiente migraciónComentario con la librería responsable + ticket para eliminarlo
Migrar Java, Spring Boot y jakarta en el mismo PRSi 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 CPUSolo mejoran la concurrencia de E/SMedir; para cómputo puro, hilos de plataforma (módulo 03)
Dejar -XX:+ZGenerational en versiones donde ya es el modo por defectoEl 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

  1. Un .class compilado con --enable-preview solo arranca en esa versión exacta del JDK. Un parche de seguridad que suba de versión mayor te deja el artefacto inservible.
  2. La API o la sintaxis pueden cambiar en la siguiente versión (las string templates directamente se retiraron): te comprometes a reescribir código.
  3. 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:

  1. 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
  2. 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 al pom.xml.
  3. 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 a javax.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:

  1. jdeps --jdk-internals y jdeprscan --for-removal --release 21 sobre el artefacto y sus dependencias: te da la lista de riesgos reales.
  2. Buscar en el código sun.misc, com.sun., javax.xml.bind, setAccessible, URLClassLoader, SecurityManager, finalize(), y en los scripts UseConcMarkSweepGC y MaxPermSize.
  3. Revisar versiones de las herramientas que tocan bytecode: Gradle/Maven, Lombok, Mockito, ByteBuddy, JaCoCo, y del framework (Spring Boot 3 exige 17 y jakarta).
  4. Compilar con --release 8 y ejecutar el conjunto de tests con el JDK 21. Este paso, solo, encuentra la mayor parte de los problemas de runtime.
  5. Subir --release por escalones (11 → 17 → 21), un PR por escalón, corrigiendo avisos.
  6. 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, javax EE) 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 un ArrayList mutable, y hay código que se apoya en eso.
  • Nulos. toList() permite elementos null (a diferencia de List.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 de case String s ->: no compila.
  • case String s -> antes de case String s when s.isBlank() ->: no compila, porque el primero ya cubre todos los String.
  • Regla práctica: de lo más específico a lo más general, y las guardas when antes 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

  1. 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.
  2. Preview (lenguaje, se activa con --enable-preview y ata el .class a esa versión), incubator (API en módulo jdk.incubator.*) y experimental (opciones de VM) son tres cosas distintas. No publiques artefactos con preview.
  3. «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.
  4. 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.
  5. En java.time: Instant para «cuándo ocurrió», LocalDate para fechas civiles, OffsetDateTime para APIs y base de datos, ZonedDateTime para agendas con horario de verano. LocalDateTime no identifica un instante.
  6. Duration es tiempo de máquina (segundos); Period es tiempo de calendario (meses). «Un mes» no es una cantidad fija, y «sumar un día» no es «sumar 24 horas».
  7. 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 InaccessibleObjectException de tu migración.
  8. var es 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.
  9. Java 11 dio HttpClient, los métodos de String que usas a diario y TLS 1.3, y quitó los módulos Java EE: ese es el dolor del salto desde 8.
  10. Los records son portadores transparentes de datos inmutables: generan constructor, accesores, equals, hashCode y toString, se deserializan por el constructor y no sirven como entidades JPA. Copia defensiva obligatoria si un componente es mutable.
  11. 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 default innecesario.
  12. Java 21 es el objetivo razonable hoy: virtual threads, pattern matching completo con deconstrucción y guardas, y sequenced collections (getFirst, getLast, reversed).
  13. 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 module y los scoped values. Las string templates se retiraron; structured concurrency, primitivos en patrones y la Vector API siguen en preview o incubación.
  14. 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 --release por escalones → dependencias → javaxjakarta con OpenRewrite → modernizar en PRs aparte → validar y medir.
  15. 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 es URLClassLoader, TLS antiguo deshabilitado y la precisión de Instant.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.ioimprescindible: 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 javaxjakarta sobre 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 var y de data-oriented programming en Java publicadas en Inside Java.
Siguiente paso: el módulo 03 desarrolla en profundidad lo que aquí solo se ha presentado —hilos virtuales, el modelo de memoria de Java, CompletableFuture, concurrencia estructurada y scoped values—, que es justo donde Java 21 cambia de verdad la forma de construir servicios.