La lección anterior terminó señalando algo que llevas arrastrando desde el principio del curso: has estado usando características modernas de Java sin saber de dónde salieron.

switch con flechas. record. var. List.of. instanceof con patrón. String.repeat. Files.readString. HttpClient. Stream.toList. Todas aparecieron en algún momento, se usaron con naturalidad y nadie dijo en qué versión llegaron ni qué problema resolvían. Y hay más que aún no has visto: bloques de texto, clases selladas, pattern matching para switch, y los hilos virtuales, que llevas dos módulos esperando desde que en 09-03 dijimos que cambiarían por completo el cálculo de "un hilo por conexión".

Esta lección ordena todo eso.

Y no es solo un catálogo. Java cambió su modelo de desarrollo en 2018: pasó de publicar una versión grande cada tres años a una versión cada seis meses, con versiones de soporte extendido cada dos o tres años. Eso cambió por completo el ritmo del lenguaje y también la pregunta práctica que todo equipo se hace: ¿a qué versión migramos y cuándo?

Al terminar, sabrás qué trajo cada versión desde Java 9, qué merece la pena usar hoy y qué no, y BiblioTech tendrá una jerarquía sellada con pattern matching exhaustivo y un ServidorCatalogo que atiende diez mil conexiones simultáneas sin un pool de hilos.

Contenido

  1. El calendario de versiones y las LTS
  2. Tabla de novedades por versión
  3. El sistema de módulos (JPMS)
  4. module-info.java: requires, exports, opens
  5. Encapsulamiento fuerte y la reflexión bloqueada
  6. Classpath frente a modulepath, y jlink
  7. La adopción real de JPMS: una valoración honesta
  8. Novedades de API
  9. var: inferencia de tipos locales
  10. switch de expresión y yield
  11. Bloques de texto
  12. record revisitado
  13. instanceof con patrón
  14. Clases selladas: sealed, permits, non-sealed
  15. Pattern matching para switch y patrones de registro
  16. BiblioTech: refactorización de la jerarquía
  17. Hilos virtuales: qué son
  18. Hilos de plataforma frente a hilos virtuales
  19. BiblioTech: el ServidorCatalogo con hilos virtuales
  20. Pinning, synchronized y lo que no resuelven
  21. Lo que viene: concurrencia estructurada y ScopedValue
  22. Herramientas: jshell, jpackage y compañía
  23. Cómo mantenerse al día y cómo decidir la migración
  24. Errores Comunes y Consejos
  25. Ejercicios

  1. El calendario de versiones y las LTS

Hasta Java 8, las versiones salían cuando estaban listas: Java 5 en 2004, Java 6 en 2006, Java 7 en 2011, Java 8 en 2014. Java 9 se retrasó tres años y medio por el sistema de módulos, y esa experiencia motivó un cambio radical.

Desde Java 10 (marzo de 2018), sale una versión cada seis meses, en marzo y septiembre, con fecha fija. Lo que está listo entra; lo que no, espera al siguiente tren.

graph LR
    A["Java 8<br/>2014<br/>LTS"] --> B["Java 9, 10<br/>2017-2018"]
    B --> C["Java 11<br/>2018<br/>LTS"]
    C --> D["Java 12 a 16<br/>2019-2021"]
    D --> E["Java 17<br/>2021<br/>LTS"]
    E --> F["Java 18 a 20<br/>2022-2023"]
    F --> G["Java 21<br/>2023<br/>LTS"]
    G --> H["Java 22, 23, 24<br/>2024-2025"]
    H --> I["Java 25<br/>2025<br/>LTS"]

Dos tipos de versión:

Tipo Frecuencia Soporte Uso recomendado
Normal (feature release) Cada 6 meses 6 meses, hasta la siguiente Probar novedades, proyectos personales
LTS (Long-Term Support) Cada 2-3 años Años (varía según proveedor) Producción

Las LTS hasta hoy son 8, 11, 17, 21 y 25. Y el consejo práctico es claro: en producción, usa una LTS. Una versión normal deja de recibir parches de seguridad a los seis meses, lo que obliga a migrar dos veces al año.

Las características de vista previa. Desde Java 12, las novedades importantes de lenguaje pasan por una o varias rondas de preview antes de ser definitivas. Se activan con una opción del compilador:

javac --release 21 --enable-preview MiClase.java
java --enable-preview MiClase

Esto permite al equipo de Java recoger opiniones y cambiar el diseño antes de comprometerse para siempre. Los record estuvieron dos versiones en vista previa; los hilos virtuales, tres. No uses características de vista previa en producción: pueden cambiar o desaparecer, y el bytecode generado solo funciona en esa versión exacta.

  1. Tabla de novedades por versión

Un mapa completo para consulta. Las que ya conoces llevan la lección donde las viste.

Versión Año Novedades relevantes
9 2017 Sistema de módulos (JPMS), List.of/Set.of/Map.of (05-02), métodos privados en interfaces, Stream.takeWhile/dropWhile/ofNullable (10-04), Optional.stream/or/ifPresentOrElse (10-04), jshell, jlink, diamante en clases anónimas (10-01)
10 2018 var, List.copyOf, Collectors.toUnmodifiableList, Optional.orElseThrow() sin argumentos
11 2018 LTS HttpClient definitivo (09-06), String.repeat/strip/isBlank/lines, Files.readString/writeString (07-06), var en parámetros de lambda, lanzar un .java sin compilar
12 2019 Collectors.teeing (10-04), String.indent, switch de expresión (vista previa)
13 2019 Bloques de texto (vista previa), switch con yield (vista previa)
14 2020 switch de expresión definitivo, record (vista previa), instanceof con patrón (vista previa), mensajes útiles de NullPointerException
15 2020 Bloques de texto definitivos, clases selladas (vista previa), CharSequence.isEmpty
16 2021 record definitivo, instanceof con patrón definitivo, Stream.toList (10-04), Stream.mapMulti, encapsulamiento fuerte por defecto
17 2021 LTS Clases selladas definitivas, RandomGenerator, eliminación del Security Manager, switch con patrones (vista previa)
18 2022 UTF-8 por defecto (07-01), servidor web simple jwebserver, @snippet en Javadoc
19 2022 Hilos virtuales (vista previa), concurrencia estructurada (incubación), API de funciones foráneas (vista previa)
20 2023 Refinamientos de vista previa, ScopedValue (incubación)
21 2023 LTS Hilos virtuales definitivos, pattern matching para switch definitivo, patrones de registro definitivos, colecciones secuenciales, String.repeat mejorado
22 2024 API de funciones foráneas definitiva, sentencias antes de super() (vista previa)
23 2024 Javadoc en Markdown, refinamientos
24 2025 Criptografía post-cuántica, mejoras del GC generacional ZGC
25 2025 LTS Consolidación de vista previas, ficheros fuente compactos y métodos de instancia principales

Los tres hitos que cambian cómo se escribe Java son: Java 8 (lambdas y streams), Java 17 (records, selladas, pattern matching incipiente, switch de expresión) y Java 21 (hilos virtuales y pattern matching completo).

  1. El sistema de módulos (JPMS)

Java 9 introdujo el cambio estructural más grande desde el nacimiento del lenguaje: el Java Platform Module System, conocido como JPMS o por su nombre en clave, Proyecto Jigsaw.

El problema que resolvía, en tres partes:

1. El JDK era monolítico. El rt.jar de Java 8 pesaba unos 60 MB y contenía todo: colecciones, red, XML, CORBA, Swing, applets. Una aplicación de servidor que solo usa colecciones y red arrastraba Swing y CORBA. En un contenedor Docker, eso son cientos de megabytes de imagen por nada.

2. No había encapsulamiento entre paquetes (JAR hell). El modificador public significa "visible para todo el mundo". Si tu biblioteca tiene un paquete com.miempresa.interno con clases de apoyo, no había forma de impedir que un usuario las importara. Y una vez que alguien depende de tu clase interna, ya no puedes cambiarla. El propio JDK sufría esto con sun.misc.Unsafe, usada por medio ecosistema.

3. No había comprobación de dependencias al arrancar. El classpath es una lista plana de JAR. Si falta uno, el programa arranca y falla con NoClassDefFoundError en el momento en que llega a usarlo — quizá horas después, quizá solo en un caso poco frecuente.

La solución: un módulo es una unidad con nombre que declara qué necesita y qué expone.

graph TD
    A["module com.nexussoftware.bibliotech"] -->|"requires"| B["java.base<br/>implicito"]
    A -->|"requires"| C["java.logging"]
    A -->|"requires"| D["java.net.http"]
    A -->|"exports"| E["com...dominio<br/>PUBLICO"]
    A -->|"exports"| F["com...servicio<br/>PUBLICO"]
    A -.->|"NO exporta"| G["com...interno<br/>INACCESIBLE desde fuera"]
    A -->|"opens"| H["com...dominio<br/>reflexion profunda permitida"]

El JDK mismo se dividió en unos 70 módulos. Se pueden listar:

java --list-modules

java.base es especial: contiene java.lang, java.util, java.io, java.nio, java.time y java.util.concurrent, y todos los módulos lo requieren implícitamente. Nunca hay que declararlo.

  1. module-info.java: requires, exports, opens

Un módulo se declara en un fichero llamado exactamente module-info.java, en la raíz del árbol de fuentes:

src/
  module-info.java
  com/nexussoftware/bibliotech/
      BiblioTechApp.java
      dominio/
      servicio/
      persistencia/
      red/
      interno/
/**
 * Modulo principal de BiblioTech.
 */
module com.nexussoftware.bibliotech {

    // --- QUE NECESITO ---
    requires java.logging;              // java.util.logging (06-07)
    requires java.net.http;             // HttpClient (09-06)
    requires java.sql;                  // JDBC, para el modulo 11

    // transitive: quien me requiera a mi obtiene tambien esto
    requires transitive com.nexussoftware.comun;

    // static: necesario para compilar, opcional en ejecucion
    requires static com.nexussoftware.herramientas;

    // --- QUE OFREZCO ---
    exports com.nexussoftware.bibliotech.dominio;
    exports com.nexussoftware.bibliotech.servicio;

    // Exportacion cualificada: SOLO a estos modulos
    exports com.nexussoftware.bibliotech.persistencia
            to com.nexussoftware.bibliotech.pruebas;

    // com.nexussoftware.bibliotech.interno NO se exporta:
    // es inaccesible desde fuera aunque sus clases sean public

    // --- REFLEXION ---
    // opens permite reflexion PROFUNDA (setAccessible sobre privados)
    opens com.nexussoftware.bibliotech.dominio;

    // Apertura cualificada
    opens com.nexussoftware.bibliotech.persistencia to com.fasterxml.jackson.databind;

    // --- SERVICIOS (ServiceLoader) ---
    uses com.nexussoftware.bibliotech.servicio.PoliticaMultas;
    provides com.nexussoftware.bibliotech.servicio.PoliticaMultas
            with com.nexussoftware.bibliotech.interno.PoliticaEstandar;
}

Las directivas, una a una:

Directiva Significado
requires X Necesito el módulo X para compilar y ejecutar
requires transitive X Además, quien me requiera obtiene X automáticamente
requires static X Necesario en compilación, opcional en ejecución
exports P El paquete P es accesible para todos
exports P to M1, M2 El paquete P es accesible solo para M1 y M2
opens P Reflexión profunda permitida sobre P
opens P to M Reflexión profunda permitida solo a M
open module Todo el módulo abierto a reflexión
uses S Consumo el servicio S vía ServiceLoader
provides S with I Ofrezco I como implementación de S

La distinción clave, que causa la mitad de los problemas prácticos:

exports opens
Uso normal en compilación (import, new) No
Reflexión sobre miembros públicos
Reflexión sobre miembros privados (setAccessible) No
Se comprueba En compilación y ejecución Solo en ejecución

Un framework de persistencia como Hibernate necesita opens, porque lee campos privados de tus entidades. Exportar el paquete no basta.

  1. Encapsulamiento fuerte y la reflexión bloqueada

Retomamos 10-03. Este código funcionó durante veinte años:

Field valor = String.class.getDeclaredField("value");
valor.setAccessible(true);
byte[] bytes = (byte[]) valor.get("Java Efectivo");

Y desde Java 16 (donde el encapsulamiento fuerte pasó a ser el valor por defecto):

Exception in thread "main" java.lang.reflect.InaccessibleObjectException:
    Unable to make field private final byte[] java.lang.String.value accessible:
    module java.base does not "opens java.lang" to unnamed module @0x1b6d3586

Qué cambió: los paquetes internos del JDK ya no están abiertos. java.base exporta java.lang (puedes usar String) pero no lo abre (no puedes hurgar en sus campos privados).

Esto rompió muchas bibliotecas antiguas, y por eso existe la salida de emergencia:

# Abrir un paquete concreto a todo el codigo sin modulo
java --add-opens java.base/java.lang=ALL-UNNAMED -cp app.jar com.ejemplo.App

# Exportar (uso normal, no reflexion profunda)
java --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED ...

# Varias a la vez
java --add-opens java.base/java.lang=ALL-UNNAMED \
     --add-opens java.base/java.util=ALL-UNNAMED \
     -jar app.jar

--add-opens es una tirita, no una solución. Cada uso indica que algo depende de detalles internos que pueden cambiar en cualquier versión. Si lo necesitas para una biblioteca de terceros, actualízala; si es de tu código, replantéalo.

Para tu propio código modularizado, la solución correcta es declarar opens en module-info.java:

module com.nexussoftware.bibliotech {
    requires java.logging;

    exports com.nexussoftware.bibliotech.dominio;

    // Nuestro ExportadorAnotado de 10-03 hace setAccessible sobre
    // los campos privados de las entidades: necesita opens.
    opens com.nexussoftware.bibliotech.dominio;
}

Sin ese opens, el ExportadorAnotado que escribiste en 10-03 lanzaría InaccessibleObjectException en cuanto BiblioTech se modularizara. Es exactamente el escenario que se anticipó allí.

  1. Classpath frente a modulepath, y jlink

Conviven dos formas de ejecutar:

# CLASSPATH: modo clasico. Todo va al "modulo sin nombre".
javac -cp lib/*.jar -d clases $(find src -name "*.java")
java -cp clases:lib/*.jar com.nexussoftware.bibliotech.BiblioTechApp

# MODULEPATH: modo modular
javac -d clases --module-source-path src $(find src -name "*.java")
java --module-path clases -m com.nexussoftware.bibliotech/com.nexussoftware.bibliotech.BiblioTechApp
Aspecto Classpath Modulepath
Nombre Módulo sin nombre (unnamed) Módulos con nombre
Encapsulamiento Ninguno: todo public es accesible Fuerte: solo lo exportado
Dependencias Sin comprobar Comprobadas al arrancar
Ficheros duplicados El primero gana, silenciosamente Error: split packages
Compatibilidad Total Requiere que las bibliotecas cooperen

Los JAR automáticos son el puente: un JAR normal en el modulepath se convierte en un módulo cuyo nombre se deriva del nombre del fichero, que exporta todos sus paquetes y requiere todos los módulos. Permite migrar poco a poco, aunque el nombre derivado del fichero es frágil.

jlink: un runtime a medida

La ventaja más tangible de JPMS. jlink construye una imagen de Java que contiene solo los módulos que tu aplicación usa:

# Descubrir que modulos necesita la aplicacion
jdeps --print-module-deps --ignore-missing-deps clases/bibliotech.jar
java.base,java.logging,java.net.http
# Construir el runtime minimo
jlink --add-modules java.base,java.logging,java.net.http \
      --strip-debug \
      --compress=zip-6 \
      --no-header-files \
      --no-man-pages \
      --output runtime-bibliotech

# Y ejecutar con el
./runtime-bibliotech/bin/java -m com.nexussoftware.bibliotech/...BiblioTechApp
du -sh $JAVA_HOME runtime-bibliotech
315M    /usr/lib/jvm/jdk-21
44M     runtime-bibliotech

De 315 MB a 44 MB. En una imagen de contenedor multiplicada por cien despliegues al día, eso es tiempo de descarga, coste de almacenamiento y superficie de ataque. Además, el runtime resultante no necesita Java instalado en la máquina destino.

Y jpackage (Java 14) va un paso más allá: empaqueta ese runtime junto a tu aplicación en un instalador nativo (.deb, .rpm, .msi, .dmg).

  1. La adopción real de JPMS: una valoración honesta

Toca ser sincero, porque el tema genera confusión.

JPMS no se ha adoptado ampliamente en aplicaciones. Casi diez años después de Java 9, la inmensa mayoría de las aplicaciones empresariales Java siguen ejecutándose en el classpath, sin module-info.java. Spring Boot, la plataforma dominante, no exige modularización. Muchas bibliotecas populares no publican módulos con nombre.

Por qué:

  • Coste de migración alto. Modularizar una aplicación grande con cincuenta dependencias exige que las cincuenta cooperen.
  • Los split packages rompen mucho. Dos JAR con clases en el mismo paquete son legales en el classpath e ilegales en el modulepath. Es frecuente en bibliotecas antiguas.
  • La reflexión sufre. Frameworks que dependen de setAccessible necesitan opens por todas partes, lo que erosiona el beneficio.
  • El beneficio no es evidente para una aplicación. Un JAR ejecutable no gana mucho encapsulando paquetes que solo él usa.

Dónde sí ha triunfado:

  • En el propio JDK. Es lo que permitió eliminar CORBA y las applets, dividir la plataforma y activar el encapsulamiento fuerte que te protege de depender de internos.
  • En bibliotecas serias, que publican module-info.java para declarar su API pública de forma verificable.
  • Con jlink y jpackage, para aplicaciones de escritorio y contenedores mínimos.

Qué hacer en la práctica:

Caso Recomendación
Aplicación empresarial con Spring Boot No modularices. Classpath, y --add-opens si alguna biblioteca lo pide
Biblioteca de uso público : publica module-info.java. Es documentación verificada de tu API
Aplicación de escritorio a distribuir : jlink + jpackage justifican el esfuerzo
Contenedor donde el tamaño importa Considera jlink aunque no modularices tu código
Código nuevo desde cero, un solo artefacto Opcional. Aporta disciplina, cuesta fricción

Lo que sí tienes que saber sí o sí es la parte que te afecta aunque no modularices: el encapsulamiento fuerte está activo, InaccessibleObjectException existe, y --add-opens es la vía de escape.

  1. Novedades de API

Repaso de las incorporaciones a la biblioteca estándar, muchas ya usadas.

Colecciones inmutables (Java 9) — retoma 05-02

List<String> empleados = List.of("Marta Ruiz", "Diego Alonso", "Nuria Vidal");
Set<String> isbns = Set.of("978-0000000001", "978-0000000002");
Map<String, Integer> prestamos = Map.of("Marta Ruiz", 4, "Diego Alonso", 2);

Map<String, Integer> muchos = Map.ofEntries(
        Map.entry("Marta Ruiz", 4),
        Map.entry("Diego Alonso", 2),
        Map.entry("Nuria Vidal", 3));

// Copia inmutable de una coleccion existente (Java 10)
List<String> copia = List.copyOf(listaModificable);

Cuatro reglas que sorprenden:

  1. Son realmente inmutables. add, remove y set lanzan UnsupportedOperationException.
  2. No admiten null, ni como elemento ni como clave ni como valor. Lanzan NullPointerException.
  3. Set.of y Map.of rechazan duplicados con IllegalArgumentException, en lugar de ignorarlos silenciosamente.
  4. El orden de iteración de Set.of y Map.of no está especificado y cambia entre ejecuciones a propósito, para que nadie dependa de él.
Set<String> conjunto = Set.of("a", "b", "c", "d");
System.out.println(conjunto);   // orden distinto en cada ejecucion del programa

Arrays.asList sigue siendo distinta: tamaño fijo pero permite set, y acepta null.

Métodos privados en interfaces (Java 9) — retoma 04-01

public interface ServicioPrestamos {

    Resultado<Prestamo> prestar(String isbn, String empleado);

    default Resultado<Prestamo> prestarValidando(String isbn, String empleado) {
        if (!esIsbnValido(isbn)) {
            return Resultado.fallo("ISBN mal formado: " + isbn);
        }
        if (!esEmpleadoValido(empleado)) {
            return Resultado.fallo("Empleado no válido");
        }
        return prestar(isbn, empleado);
    }

    // PRIVADO: logica compartida entre los default, invisible desde fuera
    private boolean esIsbnValido(String isbn) {
        return isbn != null && isbn.matches("978-\\d{10}");
    }

    private static boolean esEmpleadoValido(String empleado) {
        return empleado != null && !empleado.isBlank();
    }
}

Antes de Java 9, la lógica común entre métodos default tenía que ser un método default público más, contaminando la API.

Métodos de String (Java 11-12)

String titulo = "  Java Efectivo  ";

System.out.println(titulo.strip());          // "Java Efectivo"  (Unicode-aware)
System.out.println(titulo.stripLeading());   // "Java Efectivo  "
System.out.println(titulo.stripTrailing());  // "  Java Efectivo"
System.out.println("   ".isBlank());          // true
System.out.println("".isEmpty());             // true
System.out.println("=".repeat(40));           // línea separadora
System.out.println("uno\ndos".lines().count()); // 2  (devuelve Stream<String>)
System.out.println("texto".indent(4));        // sangrado (Java 12)

strip() frente a trim(): trim() elimina caracteres con código menor o igual a 32 (heredado de 1996); strip() usa la definición Unicode de espacio en blanco y maneja correctamente espacios no separables y caracteres de otros alfabetos. Usa strip() en código nuevo.

Y lines() devuelve un Stream<String>, lo que conecta con 10-04:

long lineasNoVacias = textoCompleto.lines()
        .map(String::strip)
        .filter(l -> !l.isBlank())
        .count();

Files.readString y writeString (Java 11) — retoma 07-06

// ANTES
List<String> lineas = Files.readAllLines(ruta, StandardCharsets.UTF_8);
String contenido = String.join("\n", lineas);

// AHORA
String contenido = Files.readString(ruta, StandardCharsets.UTF_8);
Files.writeString(ruta, contenido, StandardCharsets.UTF_8);

Para ficheros pequeños de configuración es la forma más directa. Para ficheros grandes, sigue siendo Files.lines() con procesamiento perezoso (10-04).

Colecciones secuenciales (Java 21)

Una incorporación pequeña que arregla una incoherencia vieja de la API de colecciones (módulo 5):

List<Libro> catalogo = new ArrayList<>(...);

// ANTES: cada coleccion tenia su forma
Libro primero = catalogo.get(0);
Libro ultimo = catalogo.get(catalogo.size() - 1);
Libro primeroDeque = deque.getFirst();
Libro primeroSet = conjuntoOrdenado.iterator().next();      // ¡así!

// AHORA: SequencedCollection unifica
Libro primero2 = catalogo.getFirst();
Libro ultimo2 = catalogo.getLast();
catalogo.addFirst(nuevo);
catalogo.addLast(otro);
List<Libro> alReves = catalogo.reversed();                  // vista, no copia

// Y funciona igual en LinkedHashSet, TreeSet, Deque, LinkedHashMap
LinkedHashMap<String, Libro> porIsbn = new LinkedHashMap<>();
var primeraEntrada = porIsbn.firstEntry();

Mensajes útiles de NullPointerException (Java 14)

Un cambio sin sintaxis nueva que ahorra horas reales de depuración:

String editorial = catalogo.buscarPorIsbn(isbn).getEditorial().getNombre().strip();

En Java 8:

Exception in thread "main" java.lang.NullPointerException
    at com.nexussoftware.bibliotech.BiblioTechApp.main(BiblioTechApp.java:42)

¿Cuál de los tres era null? A depurar.

En Java 14+:

Exception in thread "main" java.lang.NullPointerException:
    Cannot invoke "String.strip()" because the return value of
    "com.nexussoftware.bibliotech.dominio.Editorial.getNombre()" is null
    at com.nexussoftware.bibliotech.BiblioTechApp.main(BiblioTechApp.java:42)

Te dice exactamente qué devolvió null y qué se intentó llamar sobre él. Está activo por defecto desde Java 15, y es una de las mejores razones para migrar desde Java 8.

UTF-8 por defecto (Java 18) — retoma 07-01

Desde Java 18, el juego de caracteres por defecto de la plataforma es UTF-8 en todos los sistemas operativos. Antes dependía de la configuración regional, y era la causa del bug clásico: un CSV escrito en Windows con windows-1252 que se leía mal en un servidor Linux con UTF-8.

Aun así, sigue especificando el charset explícitamente. Es lo que insistimos en el módulo 7, y sigue siendo la práctica correcta: hace el código independiente de la versión de Java y de la configuración del entorno.

  1. var: inferencia de tipos locales

Java 10 introdujo var para variables locales. El compilador infiere el tipo de la expresión de inicialización.

var catalogo = new ArrayList<Libro>();                  // ArrayList<Libro>
var titulo = "Java Efectivo";                           // String
var paginas = 412;                                      // int
var fecha = LocalDate.of(2026, 8, 5);                   // LocalDate

// Donde mas brilla: tipos genericos largos
var porEmpleadoYTipo = new HashMap<String, Map<TipoMaterial, List<Prestamo>>>();

// En bucles
for (var entrada : porEmpleadoYTipo.entrySet()) { }
for (var i = 0; i < 10; i++) { }

// En try-with-resources
try (var lineas = Files.lines(ruta, StandardCharsets.UTF_8)) { }

var NO es tipado dinámico. El tipo se fija en compilación y no cambia nunca:

var titulo = "Java Efectivo";
// titulo = 42;         // NO COMPILA: String no admite int

Dónde NO se puede usar:

// var campo = "x";                 // campos de clase: NO
// public var metodo() { }          // retorno de método: NO
// void m(var parametro) { }        // parámetros: NO (salvo en lambdas, Java 11)
// var sinValor;                    // sin inicializador: NO
// var nulo = null;                 // no hay nada que inferir: NO
// var array = { 1, 2, 3 };         // inicializador de array: NO

Cuándo usarlo y cuándo no:

// BIEN: el tipo es evidente por el lado derecho
var catalogo = new ArrayList<Libro>();
var reserva = new Reserva("R-001", "Madrid-A", "Marta Ruiz", inicio, duracion);
var contador = new AtomicInteger(0);

// BIEN: el tipo explícito sería ilegible
var indice = new HashMap<String, Map<TipoMaterial, List<Prestamo>>>();

// MAL: el tipo no se deduce del nombre del método
var resultado = servicio.procesar(datos);      // ¿qué es 'resultado'?
var x = calcular();                             // ¿int? ¿double? ¿BigDecimal?

// MAL: se pierde la abstracción a la interfaz
var lista = new ArrayList<Libro>();             // el tipo es ArrayList
List<Libro> lista2 = new ArrayList<>();         // el tipo es List: mejor API

// PELIGROSO: el diamante vacío infiere Object
var vacia = new ArrayList<>();                  // ArrayList<Object>

La regla: usa var cuando el tipo sea evidente leyendo la línea. Si hay que ir a mirar la firma de un método para saber qué es la variable, escribe el tipo.

Y desde Java 11, var funciona en parámetros de lambda, con el único propósito de poder anotarlos:

lista.forEach((@NoNulo var libro) -> System.out.println(libro.getTitulo()));

  1. switch de expresión y yield — retoma 02-03

En 02-03 usaste el switch clásico con sus dos problemas: la caída entre casos (fall-through) por olvidar un break, y que es una sentencia, no una expresión, así que no puede devolver un valor sin una variable auxiliar.

// CLASICO: verboso y con trampas
String descripcion;
switch (gravedad) {
    case ALTA:
        descripcion = "Requiere intervención inmediata";
        break;
    case MEDIA:
        descripcion = "Revisar esta semana";
        break;
    case BAJA:
        descripcion = "Anotar y continuar";
        break;
    default:
        throw new IllegalStateException("Gravedad desconocida: " + gravedad);
}

Java 14 lo convirtió en expresión, con flechas y sin caída:

// EXPRESION: devuelve un valor, sin break, sin caida
String descripcion = switch (gravedad) {
    case ALTA  -> "Requiere intervención inmediata";
    case MEDIA -> "Revisar esta semana";
    case BAJA  -> "Anotar y continuar";
};

Cuatro ventajas concretas:

  1. Sin break, porque no hay caída entre casos.
  2. Es una expresión: se asigna, se devuelve, se pasa como argumento.
  3. Varias etiquetas por caso: case SATURDAY, SUNDAY ->.
  4. Exhaustividad comprobada: sobre un enum, si faltan casos y no hay default, no compila.

Esa última es la más valiosa. Si mañana alguien añade Gravedad.CRITICA, el compilador señalará todos los switch que hay que actualizar. Con el switch clásico y un default, el caso nuevo caería silenciosamente en el default.

yield para bloques con lógica:

double multa = switch (gravedad) {
    case ALTA -> {
        double base = dias * 0.50;
        double recargo = dias > 30 ? 10.0 : 0.0;
        yield Math.min(base + recargo, 50.0);      // yield devuelve el valor del bloque
    }
    case MEDIA -> dias * 0.25;
    case BAJA -> 0.0;
};

yield es a un bloque de switch lo que return a un método. No se puede usar return dentro de un switch de expresión: saldría del método entero, que no es lo que se quiere.

Y el switch de expresión también funciona sobre String e int, con la diferencia de que ahí sí hace falta default, porque los valores posibles son infinitos:

int diasDePlazo = switch (tipo) {
    case "LIBRO"   -> 21;
    case "REVISTA" -> 7;
    case "DVD"     -> 3;
    default -> throw new IllegalArgumentException("Tipo desconocido: " + tipo);
};

  1. Bloques de texto

Java 15 incorporó las cadenas multilínea, y la diferencia en legibilidad es enorme.

// ANTES: ilegible, con escapes y concatenacion
String json = "{\n"
        + "  \"isbn\": \"978-0000000001\",\n"
        + "  \"titulo\": \"Java Efectivo\",\n"
        + "  \"disponible\": true\n"
        + "}";

// AHORA
String json = """
        {
          "isbn": "978-0000000001",
          "titulo": "Java Efectivo",
          "disponible": true
        }""";

Reglas de sintaxis:

  1. Se abre con """ seguido de un salto de línea obligatorio. """texto no compila.
  2. Se cierra con """, en la misma línea que el último contenido o en una línea propia.
  3. Las comillas dobles no necesitan escape.

La regla de indentación (la que importa). El compilador calcula el margen común mínimo de todas las líneas no vacías incluida la del cierre, y lo elimina:

public class Ejemplo {
    public String consulta() {
        String sql = """
                SELECT p.referencia, p.isbn, m.titulo
                  FROM prestamos p
                  JOIN materiales m ON m.isbn = p.isbn
                 WHERE p.fecha_devolucion IS NULL
                 ORDER BY p.fecha_vencimiento
                """;
        return sql;
    }
}

La cadena resultante no lleva los 16 espacios de indentación del código Java, solo la sangría relativa de las líneas del SQL. Y aquí está el detalle que confunde: la posición del """ de cierre participa en el cálculo.

// El cierre a la izquierda del contenido: se conserva mas sangria
String a = """
        línea
  """;                    // el margen comun es 2: "      línea\n"

// El cierre alineado con el contenido: se elimina toda la sangria
String b = """
        línea
        """;              // el margen comun es 8: "línea\n"

// El cierre en la misma linea: sin salto final
String c = """
        línea""";         // "línea"  (sin \n al final)

Secuencias de escape propias de los bloques de texto:

// \ al final de linea: UNE las lineas (sin salto)
String unaSolaLinea = """
        Este texto está escrito en varias líneas \
        del código fuente pero es una sola \
        línea de resultado.""";

// \s: espacio que NO se elimina como sangria final
String conEspacios = """
        columna1   \s
        columna2   \s""";

El uso estrella son SQL y JSON, que verás continuamente en los módulos 11 y 12:

public class ConsultasBiblioTech {

    static final String PRESTAMOS_VENCIDOS = """
            SELECT p.referencia,
                   p.isbn,
                   m.titulo,
                   p.empleado,
                   p.fecha_vencimiento,
                   CURRENT_DATE - p.fecha_vencimiento AS dias_retraso
              FROM prestamos p
              JOIN materiales m ON m.isbn = p.isbn
             WHERE p.fecha_devolucion IS NULL
               AND p.fecha_vencimiento < CURRENT_DATE
             ORDER BY dias_retraso DESC
            """;

    static String peticionDeMetadatos(String isbn) {
        return """
                {
                  "jsonrpc": "2.0",
                  "method": "catalogo.buscar",
                  "params": { "isbn": "%s" },
                  "id": 1
                }""".formatted(isbn);           // formatted: como String.format
    }
}

formatted() (Java 15) es String.format como método de instancia, y encaja perfectamente con los bloques de texto.

  1. record revisitado — retoma 04-07

Los conociste en 04-07. Aquí conviene fijar qué son exactamente y qué se les puede hacer, porque en el apartado 15 serán la pieza clave del pattern matching.

Un record es un portador transparente de datos inmutables:

public record Ficha(String isbn, String titulo, boolean disponible) { }

El compilador genera automáticamente:

  • Campos private final para cada componente.
  • Constructor canónico con todos los componentes.
  • Métodos de acceso con el nombre del componente (isbn(), no getIsbn()).
  • equals y hashCode basados en todos los componentes.
  • toString con todos los componentes.

Compacto pero completo:

public record Prestamo(String referencia, String isbn, String empleado,
                       LocalDate fechaPrestamo, LocalDate fechaVencimiento) {

    /** Constructor COMPACTO: valida y normaliza antes de asignar. */
    public Prestamo {
        Objects.requireNonNull(referencia, "referencia");
        Objects.requireNonNull(isbn, "isbn");
        if (fechaVencimiento.isBefore(fechaPrestamo)) {
            throw new IllegalArgumentException("Vencimiento anterior al préstamo");
        }
        empleado = empleado.strip();       // se puede NORMALIZAR el parametro
    }

    /** Constructor adicional. */
    public Prestamo(String referencia, String isbn, String empleado, LocalDate desde) {
        this(referencia, isbn, empleado, desde, desde.plusDays(21));
    }

    /** Metodos derivados. */
    public long diasDeRetraso(LocalDate hoy) {
        return Math.max(0, ChronoUnit.DAYS.between(fechaVencimiento, hoy));
    }

    /** "Modificar" un record = crear otro. */
    public Prestamo conVencimiento(LocalDate nuevo) {
        return new Prestamo(referencia, isbn, empleado, fechaPrestamo, nuevo);
    }

    /** Metodos y campos estaticos SI se permiten. */
    public static final Period PLAZO = Period.ofDays(21);

    public static Prestamo nuevo(String ref, String isbn, String empleado, LocalDate desde) {
        return new Prestamo(ref, isbn, empleado, desde, desde.plus(PLAZO));
    }
}

Limitaciones (que son decisiones de diseño, no carencias):

  • No pueden extender ninguna clase (extienden java.lang.Record implícitamente). Sí pueden implementar interfaces.
  • Son implícitamente final: nadie puede extenderlos.
  • No pueden tener campos de instancia aparte de los componentes.
  • Los componentes son final: no hay setters.

Cuándo usar record y cuándo una clase:

Usa record Usa una clase
Portador de datos inmutable Hay estado mutable
La identidad es el valor de los campos La identidad es la referencia
DTO, valor de retorno, clave de mapa Necesitas herencia
Resultado de un cálculo Encapsulas la representación interna

En BiblioTech: Ficha y ResumenSesion son records; Material es una clase abstracta porque hay jerarquía; Prestamo puede ser cualquiera de las dos según si su estado cambia.

  1. instanceof con patrón — retoma 03-06

Definitivo en Java 16. Elimina el cast redundante tras un instanceof:

// ANTES: el cast repite lo que el instanceof ya comprobo
if (material instanceof Libro) {
    Libro libro = (Libro) material;
    System.out.println(libro.getPaginas());
}

// AHORA: la variable de patron se declara y asigna a la vez
if (material instanceof Libro libro) {
    System.out.println(libro.getPaginas());
}

El ámbito de la variable de patrón se calcula con lógica de flujo, lo que produce comportamientos muy útiles:

// Con && : la variable esta disponible a la derecha
if (material instanceof Libro libro && libro.getPaginas() > 400) {
    System.out.println("Libro largo: " + libro.getTitulo());
}

// Con negacion: disponible DESPUES del if (salida temprana)
public String describir(Object objeto) {
    if (!(objeto instanceof Material material)) {
        return "No es un material";
    }
    // 'material' esta en ambito aqui, porque si no lo fuera ya habriamos vuelto
    return material.getTitulo() + " (" + material.getTipo() + ")";
}

// Con || : NO esta disponible, y el compilador lo impide
// if (o instanceof Libro l || l.getPaginas() > 0) { }   // NO COMPILA

Y equals queda especialmente limpio (03-09):

@Override
public boolean equals(Object otro) {
    return otro instanceof Material material
            && isbn.equals(material.getIsbn());
}

De seis líneas a dos, sin perder nada.

  1. Clases selladas: sealed, permits, non-sealed

Definitivas en Java 17. Resuelven un problema real que hasta ahora no tenía solución.

El problema. En BiblioTech, Material es abstracta con tres subclases: Libro, Revista y Dvd. Pero cualquiera puede crear una cuarta:

// En otro proyecto, sin que nadie lo apruebe
public class Videojuego extends Material { }

Y todos los switch y cadenas de instanceof que escribiste asumiendo tres tipos quedan incompletos, sin que el compilador diga nada.

Las alternativas antes de Java 17 eran malas: hacer el constructor package-private (frágil y no impide subclases en el mismo paquete) o simplemente confiar y documentar.

La solución:

package com.nexussoftware.bibliotech.dominio;

/**
 * Jerarquia CERRADA: solo existen estos tres materiales.
 * Ninguna otra clase puede extender Material, ni en este
 * proyecto ni en ninguno.
 */
public sealed abstract class Material
        permits Libro, Revista, Dvd {

    private final String isbn;
    private final String titulo;

    protected Material(String isbn, String titulo) {
        this.isbn = isbn;
        this.titulo = titulo;
    }

    public String getIsbn()   { return isbn; }
    public String getTitulo() { return titulo; }

    public abstract TipoMaterial getTipo();
}

Cada subclase debe declarar su propia apertura, y esa es la parte que se olvida:

/** final: nadie extiende Libro. Es lo mas habitual. */
public final class Libro extends Material {
    private final int paginas;
    private final String autor;
    // ...
}

/** sealed: Revista tiene a su vez una jerarquia cerrada. */
public sealed class Revista extends Material permits RevistaMensual, RevistaTrimestral {
    // ...
}

/** non-sealed: Dvd vuelve a estar abierto a cualquiera. */
public non-sealed class Dvd extends Material {
    // ...
}
Modificador Significado
final Nadie puede extenderla. El caso más común
sealed ... permits Solo las clases listadas pueden extenderla
non-sealed Cualquiera puede extenderla: reabre la jerarquía

Las reglas que impone el compilador:

  1. Toda subclase de una clase sealed debe ser final, sealed o non-sealed. No hay cuarta opción.
  2. Las clases permitidas deben estar en el mismo módulo (o en el mismo paquete si no hay módulos).
  3. Deben extenderla realmente: listar una clase que no extienda es error de compilación.
  4. permits se puede omitir si las subclases están en el mismo fichero.
// Todo en un fichero: permits implicito
public sealed interface Evento {
    record Prestado(String isbn, String empleado, LocalDate cuando) implements Evento { }
    record Devuelto(String isbn, LocalDate cuando, boolean conRetraso) implements Evento { }
    record Perdido(String isbn, String empleado, double importe) implements Evento { }
}

Lo que ganas: exhaustividad. El compilador sabe que solo hay tres tipos, y por tanto puede verificar que los has cubierto todos:

// SIN default: el compilador comprueba que estan los tres
String etiqueta = switch (material) {
    case Libro l   -> "Libro de " + l.getPaginas() + " páginas";
    case Revista r -> "Revista nº " + r.getNumero();
    case Dvd d     -> "DVD de " + d.getMinutos() + " minutos";
};

Si mañana alguien añade Mapa a la lista de permits y no actualiza este switch:

error: the switch expression does not cover all possible input values
    String etiqueta = switch (material) {
                      ^

El compilador te lleva de la mano por todos los sitios que hay que tocar. Eso es lo que hace que las jerarquías selladas se llamen "tipos algebraicos" o "tipos suma": el conjunto de posibilidades es cerrado y conocido.

Y combinadas con record, modelan estados de forma muy expresiva:

package com.nexussoftware.bibliotech.dominio;

import java.time.LocalDate;

/**
 * Estado de un prestamo modelado como tipo suma.
 * Cada estado lleva EXACTAMENTE los datos que tiene sentido
 * que lleve, en lugar de una clase con campos que a veces son null.
 */
public sealed interface EstadoPrestamo {

    record Activo(LocalDate vencimiento) implements EstadoPrestamo { }

    record Vencido(LocalDate vencimiento, long diasDeRetraso, double multa)
            implements EstadoPrestamo { }

    record Devuelto(LocalDate cuando, boolean puntual) implements EstadoPrestamo { }

    record Perdido(LocalDate declaradoEl, double importeReposicion)
            implements EstadoPrestamo { }
}

Compara con la alternativa clásica: una clase Prestamo con boolean devuelto, LocalDate fechaDevolucion (null si no se ha devuelto), double multa (0 si no aplica) y boolean perdido. Combinaciones imposibles representables: devuelto y perdido a la vez, multa sin vencimiento. Con el tipo sellado, esos estados no se pueden construir.

  1. Pattern matching para switch y patrones de registro

Definitivos en Java 21, cierran el círculo.

Patrones de tipo en switch

// ANTES: cadena de instanceof, con cast en cada rama
public String describir(Object objeto) {
    if (objeto instanceof Libro) {
        Libro l = (Libro) objeto;
        return "Libro: " + l.getTitulo() + " (" + l.getPaginas() + " págs.)";
    } else if (objeto instanceof Revista) {
        Revista r = (Revista) objeto;
        return "Revista: " + r.getTitulo() + " nº " + r.getNumero();
    } else if (objeto instanceof Dvd) {
        Dvd d = (Dvd) objeto;
        return "DVD: " + d.getTitulo() + " (" + d.getMinutos() + " min)";
    } else if (objeto == null) {
        return "(nada)";
    }
    return "Desconocido";
}

// AHORA
public String describir(Object objeto) {
    return switch (objeto) {
        case null      -> "(nada)";
        case Libro l   -> "Libro: " + l.getTitulo() + " (" + l.getPaginas() + " págs.)";
        case Revista r -> "Revista: " + r.getTitulo() + " nº " + r.getNumero();
        case Dvd d     -> "DVD: " + d.getTitulo() + " (" + d.getMinutos() + " min)";
        default        -> "Desconocido: " + objeto.getClass().getSimpleName();
    };
}

case null es una novedad importante. El switch clásico lanzaba NullPointerException con un valor nulo; ahora se puede tratar como un caso más. Si no se declara case null, sigue lanzando NullPointerException por compatibilidad.

Guardas con when

public String clasificar(Material material) {
    return switch (material) {
        case Libro l when l.getPaginas() > 500  -> "Libro extenso";
        case Libro l when l.getPaginas() > 200  -> "Libro estándar";
        case Libro l                            -> "Libro breve";
        case Revista r when r.esEspecial()      -> "Número especial";
        case Revista r                          -> "Revista ordinaria";
        case Dvd d                              -> "Material audiovisual";
    };
}

El orden importa: los casos se evalúan de arriba abajo, y el más específico va primero. El compilador impide poner un caso inalcanzable:

case Libro l                            -> "...";
case Libro l when l.getPaginas() > 500  -> "...";   // ERROR: inalcanzable

Patrones de registro (record patterns)

La pieza más potente: descomponer un record directamente en el patrón.

public sealed interface Evento {
    record Prestado(String isbn, String empleado, LocalDate cuando) implements Evento { }
    record Devuelto(String isbn, LocalDate cuando, boolean conRetraso) implements Evento { }
    record Perdido(String isbn, String empleado, double importe) implements Evento { }
}
public String registrar(Evento evento) {
    return switch (evento) {

        // Se DESCOMPONE el record: isbn, empleado y cuando salen directamente
        case Evento.Prestado(String isbn, String empleado, LocalDate cuando) ->
                String.format("%s prestó %s el %s", empleado, isbn, cuando);

        // Con guarda sobre un componente descompuesto
        case Evento.Devuelto(String isbn, LocalDate cuando, boolean retraso) when retraso ->
                String.format("%s devuelto el %s CON RETRASO", isbn, cuando);

        case Evento.Devuelto(String isbn, LocalDate cuando, boolean retraso) ->
                String.format("%s devuelto puntualmente el %s", isbn, cuando);

        case Evento.Perdido(String isbn, String empleado, double importe) ->
                String.format("%s declaró perdido %s (reposición: %.2f €)",
                        empleado, isbn, importe);
    };
    // SIN default: la interfaz es sealed y estan los tres casos
}

Y con var para no repetir tipos:

case Evento.Prestado(var isbn, var empleado, var cuando) ->
        empleado + " prestó " + isbn + " el " + cuando;

Los patrones se anidan, que es donde el mecanismo se vuelve realmente expresivo:

public record Punto(int x, int y) { }
public record Linea(Punto inicio, Punto fin) { }

public String describir(Object o) {
    return switch (o) {
        // Descomposicion ANIDADA en dos niveles
        case Linea(Punto(var x1, var y1), Punto(var x2, var y2)) when x1 == x2 ->
                "Línea vertical en x=" + x1;
        case Linea(Punto(var x1, var y1), Punto(var x2, var y2)) when y1 == y2 ->
                "Línea horizontal en y=" + y1;
        case Linea(Punto p1, Punto p2) ->
                "Línea de " + p1 + " a " + p2;
        case Punto(int x, int y) ->
                "Punto (" + x + ", " + y + ")";
        default -> "Otra cosa";
    };
}

Y también funciona con instanceof:

if (evento instanceof Evento.Prestado(String isbn, String empleado, LocalDate cuando)) {
    System.out.println(empleado + " -> " + isbn + " (" + cuando + ")");
}

  1. BiblioTech: refactorización de la jerarquía

Aplicamos todo a la vez, con el antes y el después.

Antes

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.*;

/**
 * VERSION ANTIGUA: jerarquia abierta, casts manuales,
 * cadenas de if-else y un default que oculta los casos nuevos.
 */
public class CalculadoraPlazosAntigua {

    public int diasDePlazo(Material material) {
        if (material instanceof Libro) {
            Libro libro = (Libro) material;
            if (libro.getPaginas() > 500) {
                return 30;
            } else if (libro.getPaginas() > 200) {
                return 21;
            } else {
                return 14;
            }
        } else if (material instanceof Revista) {
            Revista revista = (Revista) material;
            if (revista.esEspecial()) {
                return 14;
            }
            return 7;
        } else if (material instanceof Dvd) {
            return 3;
        }
        // Y si alguien anade un tipo nuevo, cae AQUI en silencio
        throw new IllegalArgumentException("Tipo desconocido: " + material.getClass());
    }

    public String etiquetaDeEstante(Material material) {
        String prefijo;
        if (material instanceof Libro) {
            prefijo = "L";
        } else if (material instanceof Revista) {
            prefijo = "R";
        } else if (material instanceof Dvd) {
            prefijo = "D";
        } else {
            prefijo = "X";
        }
        return prefijo + "-" + material.getIsbn().substring(4, 8);
    }

    public double valorDeReposicion(Material material) {
        if (material instanceof Libro) {
            return ((Libro) material).getPaginas() * 0.08;
        } else if (material instanceof Revista) {
            return 12.0;
        } else if (material instanceof Dvd) {
            return ((Dvd) material).getMinutos() * 0.15;
        }
        return 0.0;
    }
}

Después

package com.nexussoftware.bibliotech.dominio;

/** Jerarquia SELLADA: el compilador sabe que solo existen tres materiales. */
public sealed abstract class Material permits Libro, Revista, Dvd {

    private final String isbn;
    private final String titulo;
    private final double valoracion;

    protected Material(String isbn, String titulo, double valoracion) {
        this.isbn = isbn;
        this.titulo = titulo;
        this.valoracion = valoracion;
    }

    public String getIsbn()      { return isbn; }
    public String getTitulo()    { return titulo; }
    public double getValoracion() { return valoracion; }
}
public final class Libro extends Material {
    private final int paginas;
    private final String autor;

    public Libro(String isbn, String titulo, String autor, int paginas, double valoracion) {
        super(isbn, titulo, valoracion);
        this.autor = autor;
        this.paginas = paginas;
    }
    public int getPaginas()  { return paginas; }
    public String getAutor() { return autor; }
}

public final class Revista extends Material {
    private final int numero;
    private final boolean especial;

    public Revista(String isbn, String titulo, int numero, boolean especial, double valoracion) {
        super(isbn, titulo, valoracion);
        this.numero = numero;
        this.especial = especial;
    }
    public int getNumero()     { return numero; }
    public boolean esEspecial() { return especial; }
}

public final class Dvd extends Material {
    private final int minutos;
    private final String director;

    public Dvd(String isbn, String titulo, String director, int minutos, double valoracion) {
        super(isbn, titulo, valoracion);
        this.director = director;
        this.minutos = minutos;
    }
    public int getMinutos()     { return minutos; }
    public String getDirector() { return director; }
}
package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.*;

/**
 * VERSION MODERNA: switch exhaustivo sin default,
 * sin casts, con guardas. Si se anade un cuarto material,
 * ESTOS TRES METODOS DEJAN DE COMPILAR y el compilador
 * te lleva a cada uno.
 */
public class CalculadoraPlazos {

    public int diasDePlazo(Material material) {
        return switch (material) {
            case Libro l when l.getPaginas() > 500 -> 30;
            case Libro l when l.getPaginas() > 200 -> 21;
            case Libro l                           -> 14;
            case Revista r when r.esEspecial()     -> 14;
            case Revista r                         -> 7;
            case Dvd d                             -> 3;
        };
    }

    public String etiquetaDeEstante(Material material) {
        String prefijo = switch (material) {
            case Libro l   -> "L";
            case Revista r -> "R";
            case Dvd d     -> "D";
        };
        return prefijo + "-" + material.getIsbn().substring(4, 8);
    }

    public double valorDeReposicion(Material material) {
        return switch (material) {
            case Libro l   -> l.getPaginas() * 0.08;
            case Revista r -> r.esEspecial() ? 25.0 : 12.0;
            case Dvd d     -> d.getMinutos() * 0.15;
        };
    }

    /** Con bloques y yield cuando hay logica. */
    public String fichaCompleta(Material material) {
        return switch (material) {
            case Libro l -> {
                String extension = l.getPaginas() > 400 ? "extenso" : "estándar";
                yield """
                        LIBRO
                          Título:     %s
                          Autor:      %s
                          Páginas:    %d (%s)
                          Plazo:      %d días
                          Reposición: %.2f €"""
                        .formatted(l.getTitulo(), l.getAutor(), l.getPaginas(),
                                   extension, diasDePlazo(l), valorDeReposicion(l));
            }
            case Revista r -> """
                    REVISTA
                      Título:     %s
                      Número:     %d%s
                      Plazo:      %d días"""
                    .formatted(r.getTitulo(), r.getNumero(),
                               r.esEspecial() ? " (ESPECIAL)" : "", diasDePlazo(r));
            case Dvd d -> """
                    DVD
                      Título:     %s
                      Director:   %s
                      Duración:   %d min
                      Plazo:      %d días"""
                    .formatted(d.getTitulo(), d.getDirector(), d.getMinutos(), diasDePlazo(d));
        };
    }
}

Y el estado del préstamo con patrones de registro:

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.EstadoPrestamo;

import java.time.format.DateTimeFormatter;
import java.util.Locale;

public class DescriptorDeEstado {

    private static final DateTimeFormatter FECHA =
            DateTimeFormatter.ofPattern("d 'de' MMMM", new Locale("es", "ES"));

    public String describir(EstadoPrestamo estado) {
        return switch (estado) {

            case EstadoPrestamo.Activo(var vencimiento) ->
                    "Activo, vence el " + vencimiento.format(FECHA);

            case EstadoPrestamo.Vencido(var vencimiento, var dias, var multa) when multa == 0 ->
                    "Vencido hace " + dias + " días (todavía en período de cortesía)";

            case EstadoPrestamo.Vencido(var vencimiento, var dias, var multa) ->
                    String.format("VENCIDO hace %d días -- multa acumulada: %.2f €", dias, multa);

            case EstadoPrestamo.Devuelto(var cuando, var puntual) ->
                    "Devuelto el " + cuando.format(FECHA) + (puntual ? " puntualmente" : " con retraso");

            case EstadoPrestamo.Perdido(var declarado, var importe) ->
                    String.format("PERDIDO desde el %s -- reposición: %.2f €",
                            declarado.format(FECHA), importe);
        };
    }

    /** Prioridad de gestion: un switch que devuelve un int. */
    public int prioridad(EstadoPrestamo estado) {
        return switch (estado) {
            case EstadoPrestamo.Perdido p                              -> 100;
            case EstadoPrestamo.Vencido(var v, var dias, var m) when dias > 30 -> 90;
            case EstadoPrestamo.Vencido v                              -> 50;
            case EstadoPrestamo.Activo a                               -> 10;
            case EstadoPrestamo.Devuelto d                             -> 0;
        };
    }
}
List<EstadoPrestamo> estados = List.of(
        new EstadoPrestamo.Activo(LocalDate.of(2026, 8, 26)),
        new EstadoPrestamo.Vencido(LocalDate.of(2026, 8, 3), 2, 0.0),
        new EstadoPrestamo.Vencido(LocalDate.of(2026, 5, 22), 75, 18.25),
        new EstadoPrestamo.Devuelto(LocalDate.of(2026, 8, 1), true),
        new EstadoPrestamo.Perdido(LocalDate.of(2026, 7, 15), 42.50));

DescriptorDeEstado descriptor = new DescriptorDeEstado();

estados.stream()
       .sorted(Comparator.comparingInt(descriptor::prioridad).reversed())
       .forEach(e -> System.out.printf("  [%3d] %s%n",
               descriptor.prioridad(e), descriptor.describir(e)));
  [100] PERDIDO desde el 15 de julio -- reposición: 42,50 €
  [ 90] VENCIDO hace 75 días -- multa acumulada: 18,25 €
  [ 50] Vencido hace 2 días (todavía en período de cortesía)
  [ 10] Activo, vence el 26 de agosto
  [  0] Devuelto el 1 de agosto puntualmente

La comparación en números: 45 líneas de if-else con casts frente a 20 líneas de switch exhaustivo. Pero lo importante no es la longitud: es que si alguien añade un cuarto material o un quinto estado, el código deja de compilar y el compilador señala cada sitio que hay que actualizar. La versión antigua habría seguido compilando y fallando en ejecución.

  1. Hilos virtuales: qué son

Y llegamos a lo que llevas esperando desde 09-03.

El problema, recapitulado. En 09-03 escribiste ServidorCatalogo con el modelo "un hilo por conexión", y descubriste que no escala: cada hilo de plataforma consume alrededor de 1 MB de pila y su creación cuesta cerca de un milisegundo. Diez mil conexiones simultáneas serían diez gigabytes de pila. Por eso tuviste que acotar el pool a un número modesto de hilos, y por eso el servidor rechaza clientes cuando se satura.

Ese compromiso —hilos baratos o código legible, elige uno— ha dominado la programación de servidores durante veinte años. La alternativa era la programación asíncrona (CompletableFuture, 08-07), que escala pero convierte un flujo lineal en una maraña de retrollamadas donde el depurador no sirve y las trazas de pila no dicen nada.

Los hilos virtuales (Java 21, Proyecto Loom) eliminan el compromiso.

Un hilo virtual es un hilo gestionado por la JVM, no por el sistema operativo. Se ejecuta sobre un pequeño conjunto de hilos de plataforma llamados portadores (carrier threads). La clave es el comportamiento al bloquearse:

graph TD
    A["10.000 hilos virtuales"] --> B["Planificador de la JVM<br/>ForkJoinPool"]
    B --> C["Hilo portador 1"]
    B --> D["Hilo portador 2"]
    B --> E["Hilo portador N<br/>(N = nucleos)"]
    C --> F["Sistema operativo"]
    D --> F
    E --> F
    G["Un hilo virtual se bloquea<br/>en E/S"] -->|"se DESMONTA del portador"| H["Su pila se guarda en el heap"]
    H -->|"el portador queda LIBRE<br/>para otro hilo virtual"| B
    I["Llegan los datos"] -->|"se REMONTA en un portador"| B

Cuando un hilo de plataforma se bloquea, el hilo del sistema operativo queda parado. Cuando un hilo virtual se bloquea, se desmonta del portador: su pila se guarda en el heap y el portador pasa a ejecutar otro hilo virtual. Al llegar los datos, se remonta y continúa exactamente donde estaba.

El resultado: puedes bloquear alegremente, porque bloquear un hilo virtual no bloquea ningún hilo del sistema operativo. El código secuencial legible vuelve a escalar.

// Crear uno suelto
Thread virtual = Thread.ofVirtual().name("worker-1").start(() -> {
    System.out.println("Soy un hilo virtual: " + Thread.currentThread());
});
virtual.join();

// Sin arrancar
Thread sinArrancar = Thread.ofVirtual().unstarted(tarea);

// Fabrica de hilos virtuales
ThreadFactory fabrica = Thread.ofVirtual().name("bibliotech-", 0).factory();

// EL MAS IMPORTANTE: executor con un hilo virtual POR TAREA
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return "listo";
        });
    }
}   // close() espera a que terminen todas: el executor es AutoCloseable

Fíjate en dos detalles: ExecutorService es AutoCloseable desde Java 19, así que el try-with-resources de 06-06 sustituye al shutdown() + awaitTermination() de 08-05. Y Thread.sleep(Duration) acepta un Duration de 10-05.

La demostración

package com.nexussoftware.bibliotech;

import java.time.Duration;
import java.util.concurrent.*;
import java.util.stream.IntStream;

public class DemostracionHilosVirtuales {

    private static final int TAREAS = 10_000;

    public static void main(String[] args) throws Exception {
        System.out.println("Tareas: " + TAREAS + ", cada una bloqueada 1 segundo");
        System.out.println("Núcleos: " + Runtime.getRuntime().availableProcessors());
        System.out.println();

        medir("Pool de plataforma (200 hilos)",
              Executors.newFixedThreadPool(200));

        medir("Un hilo VIRTUAL por tarea      ",
              Executors.newVirtualThreadPerTaskExecutor());

        // Y esto NO se puede hacer con hilos de plataforma
        System.out.println();
        System.out.println("--- 1.000.000 de hilos virtuales ---");
        long inicio = System.currentTimeMillis();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, 1_000_000).forEach(i ->
                    executor.submit(() -> {
                        Thread.sleep(Duration.ofSeconds(1));
                        return i;
                    }));
        }
        System.out.printf("Un millón de tareas en %d ms%n",
                System.currentTimeMillis() - inicio);
    }

    private static void medir(String etiqueta, ExecutorService executor) throws Exception {
        long inicio = System.currentTimeMillis();
        try (executor) {
            for (int i = 0; i < TAREAS; i++) {
                executor.submit(() -> {
                    Thread.sleep(Duration.ofSeconds(1));   // simula espera de E/S
                    return null;
                });
            }
        }
        System.out.printf("%s -> %6d ms%n", etiqueta, System.currentTimeMillis() - inicio);
    }
}
Tareas: 10000, cada una bloqueada 1 segundo
Núcleos: 8

Pool de plataforma (200 hilos) ->  50238 ms
Un hilo VIRTUAL por tarea       ->   1104 ms

--- 1.000.000 de hilos virtuales ---
Un millón de tareas en 2871 ms

Cincuenta segundos frente a uno. El pool de 200 hilos procesa 200 tareas simultáneas y las 10.000 tardan 50 rondas de un segundo. Los hilos virtuales ejecutan las 10.000 a la vez, y el total es el tiempo de una sola tarea más el arranque.

Y un millón de hilos virtuales en menos de tres segundos. Un millón de hilos de plataforma serían aproximadamente un terabyte de pila: la JVM no arrancaría.

  1. Hilos de plataforma frente a hilos virtuales

Aspecto Hilo de plataforma Hilo virtual
Implementación Envoltorio de un hilo del SO Objeto gestionado por la JVM
Coste de creación ~1 ms ~1 µs (mil veces menos)
Memoria (pila) ~1 MB reservado Cientos de bytes, crece en el heap
Número viable Miles Millones
Planificación Sistema operativo, expropiativa JVM, cooperativa en puntos de bloqueo
Al bloquearse El hilo del SO se para Se desmonta, libera el portador
Agrupar en pool Imprescindible Contraproducente: son desechables
ThreadLocal Funciona, con cuidado Funciona, pero desaconsejado con millones
synchronized Sin problemas Puede provocar pinning
Prioridades No: se ignoran
Nombre por defecto Thread-N Vacío (hay que darlo explícitamente)
Bueno para Cálculo intensivo E/S: red, ficheros, base de datos
Aparecen en jstack Solo con volcado de hilos extendido

Las cuatro reglas prácticas:

1. No agrupes hilos virtuales en un pool. Los pools existen porque crear un hilo de plataforma es caro. Crear un hilo virtual es casi gratis. Executors.newFixedThreadPool(200) con hilos virtuales sería absurdo: reintroduce el límite que los hilos virtuales eliminan.

// MAL: contradice el propósito
ExecutorService mal = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());

// BIEN: uno por tarea
ExecutorService bien = Executors.newVirtualThreadPerTaskExecutor();

2. Un hilo virtual por tarea, siempre. No los reutilices ni los guardes.

3. Para limitar la concurrencia, usa un Semaphore, no el tamaño del pool. Si el servicio externo solo aguanta 50 peticiones simultáneas, eso es una restricción del recurso, no de los hilos:

Semaphore limite = new Semaphore(50);

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (String isbn : isbns) {
        executor.submit(() -> {
            limite.acquire();
            try {
                return clienteMetadatos.consultar(isbn);
            } finally {
                limite.release();
            }
        });
    }
}

4. No aceleran el cálculo. Ver apartado 20.

  1. BiblioTech: el ServidorCatalogo con hilos virtuales

La refactorización que 09-03 dejó pendiente.

Antes (Java 17, pool acotado)

package com.nexussoftware.bibliotech.red;

import java.io.IOException;
import java.net.*;
import java.util.concurrent.*;
import java.util.logging.Logger;

/**
 * VERSION DEL MODULO 9: pool acotado.
 *
 * El pool limita a 50 clientes simultaneos. Con 51 conexiones,
 * la 51 espera en la cola; si la cola se llena, se RECHAZA.
 * Y no se puede subir mucho: cada hilo son ~1 MB de pila.
 */
public class ServidorCatalogoConPool {

    private static final Logger LOG = Logger.getLogger(ServidorCatalogoConPool.class.getName());

    private static final int PUERTO = 9090;
    private static final int MAX_CLIENTES = 50;      // <-- el limite
    private static final int COLA = 100;

    private final ExecutorService pool = new ThreadPoolExecutor(
            MAX_CLIENTES, MAX_CLIENTES,
            60L, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(COLA),
            new ThreadPoolExecutor.CallerRunsPolicy());

    private volatile boolean activo = true;

    public void arrancar() throws IOException {
        try (ServerSocket servidor = new ServerSocket(PUERTO)) {
            servidor.setSoTimeout(1000);
            LOG.info("Servidor en el puerto " + PUERTO + " (máx. " + MAX_CLIENTES + " clientes)");

            while (activo) {
                try {
                    Socket cliente = servidor.accept();
                    pool.submit(() -> atender(cliente));
                } catch (SocketTimeoutException e) {
                    // ciclo de comprobacion de 'activo'
                }
            }
        } finally {
            pool.shutdown();
        }
    }

    private void atender(Socket cliente) { /* protocolo BTCP/1 */ }
}

Después (Java 21, hilos virtuales)

package com.nexussoftware.bibliotech.red;

import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * VERSION CON HILOS VIRTUALES.
 *
 * Un hilo virtual por conexion, sin pool y sin limite artificial.
 * El codigo de atender() es SECUENCIAL y BLOQUEANTE, que es como
 * hay que escribirlo, y aun asi escala a decenas de miles de clientes.
 */
public class ServidorCatalogo implements AutoCloseable {

    private static final Logger LOG = Logger.getLogger(ServidorCatalogo.class.getName());

    private static final int PUERTO = 9090;
    private static final Duration INACTIVIDAD = Duration.ofSeconds(30);
    private static final int MAX_LINEA = 4096;

    /**
     * Un hilo VIRTUAL por tarea. Ya no hay tamaño de pool que ajustar,
     * ni cola, ni politica de rechazo.
     */
    private final ExecutorService atendedores = Executors.newVirtualThreadPerTaskExecutor();

    private final AtomicLong conexionesTotales = new AtomicLong();
    private final AtomicLong conexionesActivas = new AtomicLong();

    private volatile boolean activo = true;
    private ServerSocket servidor;

    public void arrancar() throws IOException {
        servidor = new ServerSocket(PUERTO);
        servidor.setSoTimeout(1000);

        LOG.info(() -> "ServidorCatalogo escuchando en el puerto " + PUERTO
                + " (un hilo virtual por conexión, sin límite de pool)");

        // Hilo virtual dedicado a informar del estado
        Thread.ofVirtual().name("monitor").start(this::monitorizar);

        while (activo) {
            try {
                Socket cliente = servidor.accept();

                // Un hilo virtual POR CONEXION. Crearlo cuesta microsegundos.
                atendedores.submit(() -> atender(cliente));

            } catch (SocketTimeoutException e) {
                // ciclo normal
            } catch (IOException e) {
                if (activo) {
                    LOG.log(Level.WARNING, "Error al aceptar conexión", e);
                }
            }
        }
    }

    /**
     * Codigo SECUENCIAL y BLOQUEANTE: exactamente el mismo de 09-03.
     * readLine() bloquea, y no pasa nada: el hilo virtual se desmonta.
     */
    private void atender(Socket cliente) {
        long numero = conexionesTotales.incrementAndGet();
        conexionesActivas.incrementAndGet();

        // Nombrar el hilo hace legibles las trazas y los volcados
        Thread.currentThread().setName("cliente-" + numero);

        try (cliente;
             var entrada = new BufferedReader(new InputStreamReader(
                     cliente.getInputStream(), StandardCharsets.UTF_8));
             var salida = new PrintWriter(new OutputStreamWriter(
                     cliente.getOutputStream(), StandardCharsets.UTF_8), true)) {

            cliente.setSoTimeout((int) INACTIVIDAD.toMillis());

            salida.println("BTCP/1 BIENVENIDO BiblioTech");

            String linea;
            while ((linea = leerLineaAcotada(entrada)) != null) {

                String respuesta = switch (linea.split(" ", 2)[0].toUpperCase()) {
                    case "BUSCAR"  -> buscar(linea);
                    case "PRESTAR" -> prestar(linea);
                    case "ESTADO"  -> "OK ACTIVAS=" + conexionesActivas.get()
                                      + " TOTALES=" + conexionesTotales.get();
                    case "ADIOS"   -> "OK HASTA_LUEGO";
                    default        -> "ERROR COMANDO_DESCONOCIDO";
                };

                salida.println(respuesta);

                if (respuesta.startsWith("OK HASTA_LUEGO")) {
                    break;
                }
            }

        } catch (SocketTimeoutException e) {
            LOG.fine(() -> "Cliente " + numero + " expulsado por inactividad");
        } catch (IOException e) {
            LOG.log(Level.FINE, e, () -> "Error con el cliente " + numero);
        } finally {
            conexionesActivas.decrementAndGet();
        }
    }

    private String leerLineaAcotada(BufferedReader entrada) throws IOException {
        StringBuilder sb = new StringBuilder();
        int c;
        while ((c = entrada.read()) != -1) {
            if (c == '\n') {
                return sb.toString().strip();
            }
            if (sb.length() >= MAX_LINEA) {
                throw new IOException("Línea demasiado larga: posible ataque");
            }
            sb.append((char) c);
        }
        return sb.isEmpty() ? null : sb.toString().strip();
    }

    private void monitorizar() {
        while (activo) {
            try {
                Thread.sleep(Duration.ofSeconds(10));
                LOG.info(() -> String.format("Conexiones: %d activas, %d totales",
                        conexionesActivas.get(), conexionesTotales.get()));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
    }

    private String buscar(String comando)  { return "OK ..."; }
    private String prestar(String comando) { return "OK ..."; }

    @Override
    public void close() throws IOException {
        activo = false;
        if (servidor != null) {
            servidor.close();
        }
        atendedores.close();       // espera a que terminen los clientes en curso
        LOG.info("Servidor detenido");
    }

    public static void main(String[] args) throws IOException {
        try (var servidor = new ServidorCatalogo()) {
            Runtime.getRuntime().addShutdownHook(new Thread(() -> {
                try { servidor.close(); } catch (IOException ignored) { }
            }));
            servidor.arrancar();
        }
    }
}

Qué cambió, exactamente

Aspecto Con pool (09-03) Con hilos virtuales
Concurrencia máxima 50 clientes (tamaño del pool) Decenas de miles
Configuración Tamaño de pool, cola, política de rechazo Ninguna
Al saturarse Encola y luego rechaza Sigue aceptando
Memoria por cliente ~1 MB ~1 KB
Código de atender() Idéntico Idéntico
Trazas de pila Legibles Legibles
Depuración paso a paso Funciona Funciona

Y esa es la observación central: el código de atender() no ha cambiado. Sigue siendo secuencial, bloqueante y legible. Con CompletableFuture (08-07) habría que convertirlo en una cadena de thenCompose sin variables locales entre pasos, sin try-with-resources que abarque toda la conversación, y con trazas de pila que no dicen dónde estaba el flujo.

Los hilos virtuales devuelven el código sencillo sin pagar en escalabilidad. Ese es todo su valor.

Nota sobre el módulo 8: todo lo que aprendiste sigue siendo necesario. Las condiciones de carrera, volatile, synchronized, los interbloqueos, las colecciones concurrentes y las variables atómicas se aplican exactamente igual a los hilos virtuales. Un hilo virtual es un hilo: dos hilos virtuales tocando el mismo ArrayList lo corrompen igual. Lo que cambia es cuántos puedes tener, no cómo se comportan.

  1. Pinning, synchronized y lo que no resuelven

El pinning

El mecanismo de los hilos virtuales exige que se puedan desmontar del portador al bloquearse. Hay dos situaciones donde no pueden:

  1. Dentro de un bloque o método synchronized.
  2. Durante una llamada a código nativo (JNI).

Cuando eso ocurre, el hilo virtual queda clavado (pinned) al portador. Si además se bloquea, bloquea el hilo portador, que es un hilo del sistema operativo. Con suficientes hilos clavados, todos los portadores quedan ocupados y el sistema se paraliza.

// PROBLEMA: bloqueo dentro de synchronized
public class CacheProblematica {

    private final Map<String, Ficha> cache = new HashMap<>();

    public synchronized Ficha obtener(String isbn) {   // synchronized
        return cache.computeIfAbsent(isbn, clave -> {
            return clienteMetadatos.consultar(clave);  // BLOQUEA en red -> PINNING
        });
    }
}
// SOLUCION: ReentrantLock, que SI permite desmontar
public class CacheCorrecta {

    private final Map<String, Ficha> cache = new ConcurrentHashMap<>();
    private final ReentrantLock cerrojo = new ReentrantLock();

    public Ficha obtener(String isbn) {
        Ficha existente = cache.get(isbn);
        if (existente != null) {
            return existente;
        }

        cerrojo.lock();                     // ReentrantLock (08-04): sin pinning
        try {
            return cache.computeIfAbsent(isbn, clienteMetadatos::consultar);
        } finally {
            cerrojo.unlock();
        }
    }
}

La regla para hilos virtuales: en código que se bloquea, prefiere ReentrantLock a synchronized. Los cerrojos de java.util.concurrent.locks están diseñados para desmontar el hilo virtual.

Se puede diagnosticar con una opción de la JVM:

java -Djdk.tracePinnedThreads=full -jar bibliotech.jar
Thread[#31,ForkJoinPool-1-worker-1,5,CarrierThreads]
    com.nexussoftware.bibliotech.servicio.CacheProblematica.obtener(CacheProblematica.java:12)
    <== monitors:1

Nota importante sobre versiones: en Java 24 (JEP 491) se eliminó el pinning por synchronized en la mayoría de los casos. Si trabajas con Java 24 o posterior, el problema prácticamente desaparece. En Java 21 —la LTS de referencia hoy— sigue siendo una consideración real.

Qué NO resuelven los hilos virtuales

Esto es tan importante como lo que sí resuelven.

1. No aceleran el cálculo. Un hilo virtual usa un núcleo igual que uno de plataforma. Si tu tarea es multiplicar matrices, tener un millón de hilos virtuales sobre ocho núcleos no la hace más rápida; solo añade cambios de contexto.

// INUTIL: la tarea no se bloquea nunca
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> calcularHashCostoso(datos));   // puro CPU
    }
}

Para cálculo, sigue usando un pool de plataforma dimensionado a los núcleos, o parallelStream() (10-04).

2. No eliminan los problemas de concurrencia. Condiciones de carrera, interbloqueos, visibilidad de memoria: todo el módulo 8 sigue vigente. De hecho, con un millón de hilos los problemas de contención se manifiestan antes.

3. No arreglan un cuello de botella externo. Diez mil hilos virtuales golpeando una base de datos con un pool de veinte conexiones no dan diez mil consultas simultáneas: dan veinte, y 9.980 esperando. Los recursos externos siguen necesitando limitación explícita, con Semaphore.

4. ThreadLocal se desaconseja. Con millones de hilos, cada ThreadLocal se multiplica por un millón. Además, la costumbre de reutilizar ThreadLocal en un pool pierde sentido cuando cada tarea tiene su hilo. Para eso llega ScopedValue (apartado 21).

5. No sustituyen a CompletableFuture en todo. Para composición de flujos asíncronos con thenCombine y allOf (08-07), o para reactividad con contrapresión, CompletableFuture y las bibliotecas reactivas siguen teniendo su sitio. Los hilos virtuales brillan en el modelo "una tarea, un hilo, código secuencial".

Escenario Herramienta
Servidor con muchas conexiones bloqueantes Hilos virtuales
Muchas llamadas HTTP/BD independientes Hilos virtuales + Semaphore
Cálculo intensivo Pool de plataforma, parallelStream
Composición asíncrona con dependencias CompletableFuture (08-07)
Flujos con contrapresión Bibliotecas reactivas

  1. Lo que viene: concurrencia estructurada y ScopedValue

Dos características que en Java 21 están en vista previa y que completan el modelo de Loom. No las uses en producción todavía, pero conviene conocerlas.

Concurrencia estructurada

El problema: cuando lanzas varias tareas y esperas sus resultados, gestionar los fallos y las cancelaciones a mano es propenso a errores. Si una falla, ¿quién cancela las otras? Si el hilo padre se interrumpe, ¿quién limpia?

// Con StructuredTaskScope: el ambito CONTIENE a las subtareas
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

    Subtask<Ficha> ficha = scope.fork(() -> clienteMetadatos.consultar(isbn));
    Subtask<byte[]> portada = scope.fork(() -> clientePortadas.descargar(isbn));
    Subtask<List<String>> resenas = scope.fork(() -> clienteResenas.buscar(isbn));

    scope.join();                  // espera a las tres
    scope.throwIfFailed();         // si alguna fallo, lanza y CANCELA las demas

    return new FichaCompleta(ficha.get(), portada.get(), resenas.get());
}   // close() garantiza que ninguna subtarea sigue viva

Las garantías: las subtareas no sobreviven al ámbito, un fallo cancela a las hermanas, la interrupción se propaga hacia abajo, y las trazas de pila reflejan la relación padre-hijo. Es a la concurrencia lo que el try-with-resources es a los recursos.

ScopedValue

El sustituto de ThreadLocal para el mundo de los hilos virtuales:

public class ContextoBiblioTech {

    // Inmutable y con ambito acotado, a diferencia de ThreadLocal
    public static final ScopedValue<String> EMPLEADO = ScopedValue.newInstance();

    public void atenderPeticion(String empleado, Runnable tarea) {
        ScopedValue.where(EMPLEADO, empleado).run(tarea);
        // Fuera de aqui, EMPLEADO no esta ligado
    }

    public void enAlgunSitioProfundo() {
        if (EMPLEADO.isBound()) {
            LOG.info("Operación de " + EMPLEADO.get());
        }
    }
}
ThreadLocal ScopedValue
Mutabilidad Mutable con set() Inmutable
Ámbito Vida del hilo Bloque delimitado
Limpieza Manual (remove()), origen de fugas Automática
Herencia a subtareas InheritableThreadLocal, con copia Automática y sin copiar
Con millones de hilos Problemático Diseñado para ello

  1. Herramientas: jshell, jpackage y compañía

Una nota rápida sobre las herramientas nuevas del JDK.

jshell (Java 9) — el REPL. Permite probar código sin crear una clase ni un main:

jshell
jshell> var catalogo = new java.util.ArrayList<String>()
catalogo ==> []

jshell> catalogo.add("Java Efectivo")
$2 ==> true

jshell> catalogo.stream().map(String::toUpperCase).toList()
$3 ==> [JAVA EFECTIVO]

jshell> java.time.LocalDate.now().plusDays(21)
$4 ==> 2026-08-26

jshell> /exit

Insustituible para comprobar el comportamiento exacto de una API sin montar un proyecto. Y muy útil para verificar en el momento las dudas de esta lección.

Ejecutar un .java sin compilar (Java 11):

java Hola.java

Compila en memoria y ejecuta. Convierte a Java en algo utilizable para guiones.

jpackage (Java 14) — instaladores nativos:

jpackage --name BiblioTech \
         --input dist/ \
         --main-jar bibliotech.jar \
         --main-class com.nexussoftware.bibliotech.BiblioTechApp \
         --runtime-image runtime-bibliotech \
         --type deb \
         --app-version 3.2

Produce un .deb, .rpm, .msi, .exe, .dmg o .pkg con la aplicación y su runtime dentro. El usuario final no necesita Java instalado.

jwebserver (Java 18) — servidor de ficheros estáticos para pruebas:

jwebserver -p 8000 -d /home/joan/bibliotech/informes

jdeps — analiza dependencias (lo usaste con jlink), y detecta usos de API interna:

jdeps --jdk-internals bibliotech.jar

jcmd, jfr — diagnóstico y grabación de vuelo. Todo esto es 10-07.

  1. Cómo mantenerse al día y cómo decidir la migración

Las fuentes

Fuente Qué aporta
JEPs (openjdk.org/jeps) La propuesta técnica de cada característica, con motivación y alternativas descartadas. La fuente primaria
Notas de versión del JDK Cambios incompatibles, obsolescencias, eliminaciones
Inside.java Blog oficial con artículos y vídeos del equipo
JDK Mission Control / JFR Para el rendimiento (10-07)
Javadoc de la versión Con @since en cada método

Un JEP es la unidad de cambio de Java. Los que conviene conocer de esta lección: JEP 261 (módulos), JEP 286 (var), JEP 361 (switch de expresión), JEP 378 (bloques de texto), JEP 395 (records), JEP 409 (clases selladas), JEP 440/441 (patrones de registro y switch), JEP 444 (hilos virtuales).

Decidir a qué versión migrar

Regla base: usa la LTS más reciente que tus dependencias soporten. Hoy eso es Java 21, con Java 25 disponible.

El salto por versión de origen:

Desde Hasta Dificultad Qué esperar
8 11 Alta Módulos, API interna bloqueada, eliminación de CORBA y JavaFX del JDK, javax.xml.bind fuera
8 17 Alta Lo anterior más el Security Manager obsoleto y encapsulamiento fuerte
11 17 Media Encapsulamiento fuerte por defecto es el principal escollo
17 21 Baja Muy compatible; casi todo funciona sin cambios
21 25 Baja Esperada como muy compatible

El salto de Java 8 a 11 es el difícil, y por eso tantos proyectos siguen en 8. Los problemas típicos:

  1. javax.xml.bind (JAXB) y java.xml.ws se eliminaron del JDK. Hay que añadirlos como dependencia.
  2. Bibliotecas que usan API interna (sun.misc.Unsafe, sun.reflect) fallan con InaccessibleObjectException.
  3. Herramientas antiguas (versiones viejas de Maven, Gradle, Lombok, ASM) no entienden el bytecode nuevo.
  4. -XX:+UseParallelOldGC y otras opciones de JVM desaparecieron.

Un plan de migración razonable:

# 1. Detectar usos de API interna ANTES de tocar nada
jdeps --jdk-internals --multi-release 21 bibliotech.jar

# 2. Compilar con la version nueva pero destino antiguo
javac --release 11 ...

# 3. Ejecutar con la JVM nueva y el codigo antiguo, viendo los avisos
java --illegal-access=warn -jar bibliotech.jar     # hasta Java 16

# 4. Actualizar dependencias UNA a UNA, no todas de golpe

# 5. Subir el destino de compilacion
javac --release 21 ...

# 6. Modernizar el codigo (opcional y gradual):
#    var, switch de expresion, records, bloques de texto, streams

Y una recomendación de método: separa la migración (que compile y funcione en la versión nueva) de la modernización (usar las características nuevas). Hacer las dos a la vez multiplica el riesgo y hace imposible saber qué rompió qué.

Errores Comunes y Consejos

1. Usar características de vista previa en producción. Requieren --enable-preview en compilación y ejecución, el bytecode solo funciona en esa versión exacta, y el diseño puede cambiar.

2. Modularizar una aplicación empresarial "porque es lo moderno". JPMS aporta poco a un JAR ejecutable y cuesta mucha fricción con las dependencias. Modulariza bibliotecas públicas, no aplicaciones.

3. Confundir exports con opens. Exportar permite el uso normal; solo opens permite setAccessible sobre privados. Un framework de persistencia necesita opens.

4. Abusar de --add-opens. Cada uso es deuda técnica: dependes de detalles internos que pueden cambiar. Úsalo como parche temporal mientras actualizas la biblioteca culpable.

5. Abusar de var. var resultado = servicio.procesar(datos); no dice nada. Úsalo cuando el tipo sea evidente en la propia línea.

6. var lista = new ArrayList<>(); infiere ArrayList<Object>, casi nunca lo que quieres. Con var, el tipo genérico va a la derecha.

7. Poner default en un switch sobre un tipo sellado. Destruye la exhaustividad: si mañana se añade un tipo, el default lo absorbe en silencio en lugar de romper la compilación. La ausencia de default es la característica, no un descuido.

8. Olvidar que las subclases de una sealed deben declararse. final, sealed o non-sealed: no hay cuarta opción, y el mensaje de error es claro pero se olvida.

9. Agrupar hilos virtuales en un pool. Contradice su propósito. Uno por tarea, y Semaphore para limitar el acceso a recursos externos.

10. Bloquear dentro de synchronized con hilos virtuales. Provoca pinning y bloquea un hilo portador. Usa ReentrantLock en Java 21 (en Java 24+ el problema está en gran parte resuelto).

11. Esperar que los hilos virtuales aceleren el cálculo. No lo hacen: solo evitan desperdiciar hilos del sistema operativo mientras se espera E/S.

12. Alinear mal el """ de cierre de un bloque de texto. Su posición participa en el cálculo de la sangría común. Alinéalo con el contenido salvo que quieras conservar margen.

13. Olvidar que List.of y Map.of no admiten null y que el orden de Set.of cambia entre ejecuciones a propósito.

Consejo 1: aprende las características por el problema que resuelven. sealed existe para que el compilador verifique la exhaustividad. Los hilos virtuales existen para que el código bloqueante escale. Memorizar sintaxis sin el porqué produce uso incorrecto.

Consejo 2: adopta gradualmente. Empieza por lo que no tiene riesgo: var donde ayude, switch de expresión, bloques de texto para SQL y JSON, record para DTO. Deja sealed y pattern matching para cuando refactorices una jerarquía.

Consejo 3: usa jshell para verificar. Cinco segundos en el REPL responden a "¿qué devuelve exactamente esto?" mejor que quince minutos leyendo documentación.

Consejo 4: separa migrar de modernizar. Primero que compile y pase las pruebas en la versión nueva; después, moderniza módulo a módulo.

Consejo 5: lee el JEP de lo que uses. Los JEP explican qué alternativas se descartaron y por qué, y eso enseña más sobre diseño de lenguajes que cualquier tutorial.

Ejercicios

Ejercicio 1: modelar el catálogo con tipos sellados

Rediseña el dominio de BiblioTech aprovechando todo lo de esta lección:

  1. sealed interface ElementoCatalogo permits Material, Coleccion, donde Coleccion agrupa varios elementos (un elemento puede contener otros).
  2. sealed abstract class Material permits Libro, Revista, Dvd, AudioLibro, con Libro y Dvd como final, Revista sellada con dos subtipos, y AudioLibro como non-sealed.
  3. Un sealed interface ResultadoBusqueda con records Encontrado, NoEncontrado y Ambiguo(List<Material> candidatos).
  4. Un servicio con switch exhaustivos y sin default que calcule: plazo de préstamo, valor de reposición, etiqueta de estante y una ficha completa con bloques de texto.
  5. Un método int contarMateriales(ElementoCatalogo) recursivo que descienda por las colecciones anidadas usando patrones de registro.
  6. Demuestra que añadir un quinto material rompe la compilación en todos los sitios correctos.

Ejercicio 2: EnriquecedorCatalogo con hilos virtuales

En 09-06 escribiste EnriquecedorCatalogo con CompletableFuture, allOf y un Semaphore. Reescríbelo con hilos virtuales y compara.

  1. EnriquecedorVirtual que consulte metadatos y descargue portadas de N ISBN usando Executors.newVirtualThreadPerTaskExecutor().
  2. Código secuencial y bloqueante dentro de cada tarea, sin thenCompose ni thenCombine.
  3. Limita a 50 peticiones simultáneas al servicio externo con Semaphore, no con el tamaño del pool.
  4. Maneja los fallos individuales sin que tumben el lote, con un record ResultadoEnriquecimiento sellado.
  5. Mide y compara: pool de 200 plataforma, hilos virtuales, y CompletableFuture, con 100, 1.000 y 10.000 ISBN.
  6. Demuestra el pinning: una versión con synchronized alrededor de la llamada bloqueante frente a otra con ReentrantLock, ejecutadas con -Djdk.tracePinnedThreads=full.

Ejercicio 3: informe de modernización

Escribe una herramienta AnalizadorDeModernizacion que examine ficheros .java y detecte oportunidades de modernización:

  1. Cadenas concatenadas con + en varias líneas → bloque de texto.
  2. switch clásico con breakswitch de expresión.
  3. instanceof seguido de cast al mismo tipo → instanceof con patrón.
  4. Clases con solo campos final, constructor, getters, equals, hashCode y toStringrecord.
  5. new SimpleDateFormat / java.util.Datejava.time (10-05).
  6. Executors.newFixedThreadPool con tareas bloqueantes → hilos virtuales.
  7. Bucles con acumulador sobre colecciones → streams (10-04).

Para cada hallazgo, indica fichero, línea, la versión de Java que lo permite y una sugerencia. Genera un informe con groupingBy y bloques de texto. Aplícalo a las clases antiguas de BiblioTech.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.dominio;

import java.util.List;

/**
 * Raiz SELLADA del catalogo. Un elemento es, o un material concreto,
 * o una coleccion que agrupa otros elementos.
 */
public sealed interface ElementoCatalogo permits Material, Coleccion {

    String getIsbn();
    String getTitulo();
}
package com.nexussoftware.bibliotech.dominio;

import java.util.List;
import java.util.Objects;

/**
 * Agrupacion de elementos. Puede contener colecciones anidadas:
 * es una estructura recursiva (el patron Composite, formalizado en 12-02).
 */
public record Coleccion(String isbn, String titulo, List<ElementoCatalogo> elementos)
        implements ElementoCatalogo {

    public Coleccion {
        Objects.requireNonNull(isbn, "isbn");
        Objects.requireNonNull(titulo, "titulo");
        elementos = List.copyOf(elementos);      // copia defensiva inmutable
    }

    @Override public String getIsbn()   { return isbn; }
    @Override public String getTitulo() { return titulo; }
}
package com.nexussoftware.bibliotech.dominio;

/**
 * Jerarquia de materiales, CERRADA a cuatro tipos.
 * Cada subclase declara explicitamente si se puede extender.
 */
public sealed abstract class Material
        implements ElementoCatalogo
        permits Libro, Revista, Dvd, AudioLibro {

    private final String isbn;
    private final String titulo;
    private final double valoracion;

    protected Material(String isbn, String titulo, double valoracion) {
        this.isbn = isbn;
        this.titulo = titulo;
        this.valoracion = valoracion;
    }

    @Override public String getIsbn()   { return isbn; }
    @Override public String getTitulo() { return titulo; }
    public double getValoracion()       { return valoracion; }
}
/** final: cerrada del todo. */
public final class Libro extends Material {
    private final String autor;
    private final int paginas;

    public Libro(String isbn, String titulo, String autor, int paginas, double valoracion) {
        super(isbn, titulo, valoracion);
        this.autor = autor;
        this.paginas = paginas;
    }
    public String getAutor() { return autor; }
    public int getPaginas()  { return paginas; }
}

/** sealed: su propia jerarquia cerrada de dos periodicidades. */
public sealed class Revista extends Material
        permits RevistaMensual, RevistaTrimestral {

    private final int numero;

    protected Revista(String isbn, String titulo, int numero, double valoracion) {
        super(isbn, titulo, valoracion);
        this.numero = numero;
    }
    public int getNumero() { return numero; }
}

public final class RevistaMensual extends Revista {
    private final java.time.Month mes;
    public RevistaMensual(String isbn, String titulo, int numero,
                          java.time.Month mes, double valoracion) {
        super(isbn, titulo, numero, valoracion);
        this.mes = mes;
    }
    public java.time.Month getMes() { return mes; }
}

public final class RevistaTrimestral extends Revista {
    private final int trimestre;
    public RevistaTrimestral(String isbn, String titulo, int numero,
                             int trimestre, double valoracion) {
        super(isbn, titulo, numero, valoracion);
        this.trimestre = trimestre;
    }
    public int getTrimestre() { return trimestre; }
}

/** final. */
public final class Dvd extends Material {
    private final String director;
    private final int minutos;
    public Dvd(String isbn, String titulo, String director, int minutos, double valoracion) {
        super(isbn, titulo, valoracion);
        this.director = director;
        this.minutos = minutos;
    }
    public String getDirector() { return director; }
    public int getMinutos()     { return minutos; }
}

/** non-sealed: DELIBERADAMENTE abierta a extension por terceros. */
public non-sealed class AudioLibro extends Material {
    private final String narrador;
    private final int minutos;
    public AudioLibro(String isbn, String titulo, String narrador, int minutos, double valoracion) {
        super(isbn, titulo, valoracion);
        this.narrador = narrador;
        this.minutos = minutos;
    }
    public String getNarrador() { return narrador; }
    public int getMinutos()     { return minutos; }
}
package com.nexussoftware.bibliotech.dominio;

import java.util.List;

/** Tipo suma para el resultado de una busqueda. */
public sealed interface ResultadoBusqueda {

    record Encontrado(Material material) implements ResultadoBusqueda { }

    record NoEncontrado(String consulta, List<String> sugerencias) implements ResultadoBusqueda { }

    record Ambiguo(String consulta, List<Material> candidatos) implements ResultadoBusqueda { }
}

El servicio:

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.*;

/**
 * Todos los switch son EXHAUSTIVOS y sin default.
 * Si se anade un quinto material, ninguno compila.
 */
public class ServicioCatalogo {

    public int diasDePlazo(Material material) {
        return switch (material) {
            case Libro l when l.getPaginas() > 500  -> 30;
            case Libro l when l.getPaginas() > 200  -> 21;
            case Libro l                            -> 14;
            case RevistaMensual r                   -> 7;
            case RevistaTrimestral r                -> 14;
            case Dvd d when d.getMinutos() > 180    -> 5;
            case Dvd d                              -> 3;
            case AudioLibro a                       -> 21;
        };
        // Revista es sealed: el compilador acepta cubrir sus dos subtipos
        // en lugar de Revista, y comprueba que estan los dos.
    }

    public double valorDeReposicion(Material material) {
        return switch (material) {
            case Libro l             -> Math.max(15.0, l.getPaginas() * 0.08);
            case RevistaMensual r    -> 8.0;
            case RevistaTrimestral r -> 14.0;
            case Dvd d               -> Math.max(10.0, d.getMinutos() * 0.15);
            case AudioLibro a        -> Math.max(20.0, a.getMinutos() * 0.10);
        };
    }

    public String etiquetaDeEstante(ElementoCatalogo elemento) {
        String prefijo = switch (elemento) {
            case Libro l             -> "LIB";
            case RevistaMensual r    -> "REM";
            case RevistaTrimestral r -> "RET";
            case Dvd d               -> "DVD";
            case AudioLibro a        -> "AUD";
            case Coleccion c         -> "COL";
        };
        return prefijo + "-" + elemento.getIsbn().substring(4, 8);
    }

    /** Ficha con bloques de texto y formatted (Java 15). */
    public String ficha(ElementoCatalogo elemento) {
        return switch (elemento) {

            case Libro l -> """
                    ┌─ LIBRO ─────────────────────────────
                    │ %s
                    │ Autor:      %s
                    │ Páginas:    %d
                    │ Estante:    %s
                    │ Plazo:      %d días
                    │ Reposición: %.2f €
                    └─────────────────────────────────────"""
                    .formatted(l.getTitulo(), l.getAutor(), l.getPaginas(),
                               etiquetaDeEstante(l), diasDePlazo(l), valorDeReposicion(l));

            case RevistaMensual r -> """
                    ┌─ REVISTA MENSUAL ───────────────────
                    │ %s nº %d (%s)
                    │ Estante:    %s
                    │ Plazo:      %d días
                    └─────────────────────────────────────"""
                    .formatted(r.getTitulo(), r.getNumero(), r.getMes(),
                               etiquetaDeEstante(r), diasDePlazo(r));

            case RevistaTrimestral r -> """
                    ┌─ REVISTA TRIMESTRAL ────────────────
                    │ %s nº %d (T%d)
                    │ Estante:    %s
                    │ Plazo:      %d días
                    └─────────────────────────────────────"""
                    .formatted(r.getTitulo(), r.getNumero(), r.getTrimestre(),
                               etiquetaDeEstante(r), diasDePlazo(r));

            case Dvd d -> """
                    ┌─ DVD ───────────────────────────────
                    │ %s
                    │ Director:   %s
                    │ Duración:   %d min
                    │ Plazo:      %d días
                    └─────────────────────────────────────"""
                    .formatted(d.getTitulo(), d.getDirector(), d.getMinutos(), diasDePlazo(d));

            case AudioLibro a -> """
                    ┌─ AUDIOLIBRO ────────────────────────
                    │ %s
                    │ Narrador:   %s
                    │ Duración:   %d min
                    │ Plazo:      %d días
                    └─────────────────────────────────────"""
                    .formatted(a.getTitulo(), a.getNarrador(), a.getMinutos(), diasDePlazo(a));

            case Coleccion(var isbn, var titulo, var elementos) -> """
                    ┌─ COLECCIÓN ─────────────────────────
                    │ %s
                    │ Elementos:  %d directos, %d en total
                    │ Estante:    %s
                    └─────────────────────────────────────"""
                    .formatted(titulo, elementos.size(),
                               contarMateriales(elemento), etiquetaDeEstante(elemento));
        };
    }

    /**
     * RECURSIVO con patron de registro: la coleccion se descompone
     * y se desciende por sus elementos.
     */
    public int contarMateriales(ElementoCatalogo elemento) {
        return switch (elemento) {
            case Material m -> 1;
            case Coleccion(var isbn, var titulo, var elementos) ->
                    elementos.stream()
                             .mapToInt(this::contarMateriales)     // recursion
                             .sum();
        };
    }

    /** Profundidad maxima de anidamiento. */
    public int profundidad(ElementoCatalogo elemento) {
        return switch (elemento) {
            case Material m -> 0;
            case Coleccion(var isbn, var titulo, var elementos) ->
                    1 + elementos.stream()
                                 .mapToInt(this::profundidad)
                                 .max().orElse(0);
        };
    }

    /** El resultado de busqueda, tambien con switch exhaustivo. */
    public String describirBusqueda(ResultadoBusqueda resultado) {
        return switch (resultado) {

            case ResultadoBusqueda.Encontrado(Material m) ->
                    "Encontrado: " + m.getTitulo();

            case ResultadoBusqueda.NoEncontrado(var consulta, var sugerencias)
                    when sugerencias.isEmpty() ->
                    "No hay resultados para \"" + consulta + "\"";

            case ResultadoBusqueda.NoEncontrado(var consulta, var sugerencias) ->
                    "No hay resultados para \"" + consulta + "\". ¿Quizás: "
                    + String.join(", ", sugerencias) + "?";

            case ResultadoBusqueda.Ambiguo(var consulta, var candidatos) ->
                    candidatos.size() + " coincidencias para \"" + consulta + "\": "
                    + candidatos.stream().map(Material::getTitulo)
                                .collect(java.util.stream.Collectors.joining(", "));
        };
    }
}

Prueba:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.dominio.*;
import com.nexussoftware.bibliotech.servicio.ServicioCatalogo;

import java.time.Month;
import java.util.List;

public class PruebaCatalogoSellado {

    public static void main(String[] args) {

        ServicioCatalogo servicio = new ServicioCatalogo();

        Libro efectivo = new Libro("978-0000000001", "Java Efectivo", "Joshua Bloch", 412, 4.85);
        Libro patrones = new Libro("978-0000000002", "Patrones de Diseño", "GoF", 395, 4.60);
        Libro refactor = new Libro("978-0000000003", "Refactorización", "M. Fowler", 448, 4.72);
        RevistaMensual javaMag = new RevistaMensual("978-0000000010", "Java Magazine",
                                                    142, Month.AUGUST, 3.90);
        Dvd cursoSpring = new Dvd("978-0000000020", "Curso de Spring", "N. Vidal", 240, 4.10);
        AudioLibro audio = new AudioLibro("978-0000000030", "Clean Code (audio)",
                                          "D. Alonso", 480, 4.30);

        // Coleccion ANIDADA
        Coleccion esenciales = new Coleccion("978-1000000001", "Esenciales de Java",
                List.of(efectivo, patrones, refactor));

        Coleccion multimedia = new Coleccion("978-1000000002", "Formación multimedia",
                List.of(cursoSpring, audio));

        Coleccion completa = new Coleccion("978-1000000000", "Biblioteca técnica Nexus",
                List.of(esenciales, multimedia, javaMag));

        System.out.println(servicio.ficha(efectivo));
        System.out.println(servicio.ficha(javaMag));
        System.out.println(servicio.ficha(audio));
        System.out.println(servicio.ficha(completa));

        System.out.println();
        System.out.println("Materiales totales: " + servicio.contarMateriales(completa));
        System.out.println("Profundidad:        " + servicio.profundidad(completa));

        System.out.println();
        System.out.println(servicio.describirBusqueda(
                new ResultadoBusqueda.Encontrado(efectivo)));
        System.out.println(servicio.describirBusqueda(
                new ResultadoBusqueda.NoEncontrado("kotlin", List.of())));
        System.out.println(servicio.describirBusqueda(
                new ResultadoBusqueda.NoEncontrado("javaa", List.of("Java Efectivo"))));
        System.out.println(servicio.describirBusqueda(
                new ResultadoBusqueda.Ambiguo("java", List.of(efectivo, audio))));
    }
}
┌─ LIBRO ─────────────────────────────
│ Java Efectivo
│ Autor:      Joshua Bloch
│ Páginas:    412
│ Estante:    LIB-0000
│ Plazo:      21 días
│ Reposición: 32,96 €
└─────────────────────────────────────
┌─ REVISTA MENSUAL ───────────────────
│ Java Magazine nº 142 (AUGUST)
│ Estante:    REM-0000
│ Plazo:      7 días
└─────────────────────────────────────
┌─ AUDIOLIBRO ────────────────────────
│ Clean Code (audio)
│ Narrador:   D. Alonso
│ Duración:   480 min
│ Plazo:      21 días
└─────────────────────────────────────
┌─ COLECCIÓN ─────────────────────────
│ Biblioteca técnica Nexus
│ Elementos:  3 directos, 6 en total
│ Estante:    COL-1000
└─────────────────────────────────────

Materiales totales: 6
Profundidad:        2

Encontrado: Java Efectivo
No hay resultados para "kotlin"
No hay resultados para "javaa". ¿Quizás: Java Efectivo?
2 coincidencias para "java": Java Efectivo, Clean Code (audio)

Y la demostración de la exhaustividad. Al añadir Mapa a los permits:

public sealed abstract class Material
        implements ElementoCatalogo
        permits Libro, Revista, Dvd, AudioLibro, Mapa { }

public final class Mapa extends Material { /* ... */ }
ServicioCatalogo.java:14: error: the switch expression does not cover all possible input values
        return switch (material) {
               ^
ServicioCatalogo.java:27: error: the switch expression does not cover all possible input values
        return switch (material) {
               ^
ServicioCatalogo.java:37: error: the switch expression does not cover all possible input values
        String prefijo = switch (elemento) {
                         ^
ServicioCatalogo.java:49: error: the switch expression does not cover all possible input values
        return switch (elemento) {
               ^
4 errors

Comentarios. Cuatro observaciones.

Cuatro errores de compilación son cuatro sitios que había que actualizar. Con la versión antigua de if-else con default, el Mapa habría caído en el default de cada método: plazo cero, reposición cero, etiqueta "X", y nadie se habría enterado hasta que un usuario reclamara. Ese es el valor entero de las clases selladas.

El compilador entiende las jerarquías anidadas. Revista es sealed permits RevistaMensual, RevistaTrimestral, y el switch cubre los dos subtipos en lugar de Revista. El compilador verifica que están los dos: quitar uno rompe la compilación.

AudioLibro es non-sealed a propósito, y demuestra que la apertura es una decisión por rama. Alguien puede escribir class Podcast extends AudioLibro, y el case AudioLibro a lo cubrirá.

El contarMateriales recursivo con patrón de registro es lo más expresivo del ejercicio. Dos casos, cuatro líneas, y recorre una estructura arbitrariamente anidada. Es el patrón Composite (12-02) expresado como tipo algebraico.

Solución 2

package com.nexussoftware.bibliotech.red;

import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Enriquecedor de catalogo con HILOS VIRTUALES.
 * Sustituye a la version de 09-06 basada en CompletableFuture.
 */
public class EnriquecedorVirtual implements AutoCloseable {

    private static final Logger LOG = Logger.getLogger(EnriquecedorVirtual.class.getName());

    /** Tipo suma para el resultado de cada ISBN. */
    public sealed interface ResultadoEnriquecimiento {
        String isbn();

        record Completo(String isbn, String metadatos, int bytesPortada, long ms)
                implements ResultadoEnriquecimiento { }

        record Parcial(String isbn, String metadatos, String motivoSinPortada, long ms)
                implements ResultadoEnriquecimiento { }

        record Fallido(String isbn, String motivo, long ms)
                implements ResultadoEnriquecimiento { }
    }

    /**
     * Semaforo, NO tamaño de pool: la restriccion es del servicio
     * externo, no de nuestros hilos.
     */
    private final Semaphore limiteExterno;

    private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

    private final ClienteMetadatos metadatos;
    private final ClientePortadas portadas;

    public EnriquecedorVirtual(ClienteMetadatos metadatos, ClientePortadas portadas,
                               int peticionesSimultaneas) {
        this.metadatos = metadatos;
        this.portadas = portadas;
        this.limiteExterno = new Semaphore(peticionesSimultaneas);
    }

    public List<ResultadoEnriquecimiento> enriquecer(List<String> isbns) {

        List<Future<ResultadoEnriquecimiento>> futuros = new ArrayList<>(isbns.size());

        // Se lanzan TODAS: un hilo virtual por ISBN, sean 100 o 100.000
        for (String isbn : isbns) {
            futuros.add(executor.submit(() -> procesarUno(isbn)));
        }

        List<ResultadoEnriquecimiento> resultados = new ArrayList<>(isbns.size());
        for (int i = 0; i < futuros.size(); i++) {
            try {
                resultados.add(futuros.get(i).get());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            } catch (ExecutionException e) {
                resultados.add(new ResultadoEnriquecimiento.Fallido(
                        isbns.get(i), "excepción no controlada: " + e.getCause(), 0));
            }
        }
        return resultados;
    }

    /**
     * CODIGO SECUENCIAL Y BLOQUEANTE. Ni un thenCompose, ni un
     * thenCombine, ni una retrollamada. Se lee de arriba abajo.
     */
    private ResultadoEnriquecimiento procesarUno(String isbn) {

        long inicio = System.nanoTime();
        Thread.currentThread().setName("enriquecer-" + isbn);

        try {
            // 1. Metadatos: bloquea, y no pasa nada
            String datos;
            limiteExterno.acquire();
            try {
                datos = metadatos.consultar(isbn);       // llamada BLOQUEANTE
            } finally {
                limiteExterno.release();
            }

            // 2. Portada: puede fallar sin invalidar el resultado
            int bytes;
            try {
                limiteExterno.acquire();
                try {
                    bytes = portadas.descargar(isbn).length;
                } finally {
                    limiteExterno.release();
                }
            } catch (Exception e) {
                return new ResultadoEnriquecimiento.Parcial(
                        isbn, datos, e.getClass().getSimpleName(), ms(inicio));
            }

            return new ResultadoEnriquecimiento.Completo(isbn, datos, bytes, ms(inicio));

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return new ResultadoEnriquecimiento.Fallido(isbn, "interrumpido", ms(inicio));

        } catch (Exception e) {
            LOG.log(Level.FINE, e, () -> "Fallo al enriquecer " + isbn);
            return new ResultadoEnriquecimiento.Fallido(
                    isbn, e.getClass().getSimpleName() + ": " + e.getMessage(), ms(inicio));
        }
    }

    private long ms(long inicioNanos) {
        return (System.nanoTime() - inicioNanos) / 1_000_000;
    }

    /** Informe con switch exhaustivo sobre el tipo sellado. */
    public String informar(List<ResultadoEnriquecimiento> resultados) {

        Map<String, Long> porTipo = resultados.stream()
                .collect(java.util.stream.Collectors.groupingBy(
                        r -> switch (r) {
                            case ResultadoEnriquecimiento.Completo c -> "completos";
                            case ResultadoEnriquecimiento.Parcial p  -> "parciales";
                            case ResultadoEnriquecimiento.Fallido f  -> "fallidos";
                        },
                        java.util.TreeMap::new,
                        java.util.stream.Collectors.counting()));

        double mediaMs = resultados.stream()
                .mapToLong(r -> switch (r) {
                    case ResultadoEnriquecimiento.Completo c -> c.ms();
                    case ResultadoEnriquecimiento.Parcial p  -> p.ms();
                    case ResultadoEnriquecimiento.Fallido f  -> f.ms();
                })
                .average().orElse(0);

        return """
                Enriquecimiento de %d ISBN
                  %s
                  Duración media por ISBN: %.1f ms"""
                .formatted(resultados.size(), porTipo, mediaMs);
    }

    @Override
    public void close() {
        executor.close();        // ExecutorService es AutoCloseable desde Java 19
    }
}

El banco de pruebas comparativo:

package com.nexussoftware.bibliotech;

import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.IntStream;

public class ComparativaEnriquecimiento {

    /** Simula una llamada de red: 50 ms de espera. */
    private static String consultarSimulado(String isbn) throws InterruptedException {
        Thread.sleep(Duration.ofMillis(50));
        return "{\"isbn\":\"" + isbn + "\"}";
    }

    public static void main(String[] args) throws Exception {

        System.out.printf("%-10s %14s %14s %14s%n",
                "ISBN", "PLATAF. (200)", "VIRTUALES", "COMPL.FUTURE");
        System.out.println("-".repeat(56));

        for (int n : new int[] { 100, 1_000, 10_000 }) {
            List<String> isbns = generar(n);

            long plataforma = medir(() -> conPoolDePlataforma(isbns));
            long virtuales  = medir(() -> conHilosVirtuales(isbns));
            long futuros    = medir(() -> conCompletableFuture(isbns));

            System.out.printf("%-10d %11d ms %11d ms %11d ms%n",
                    n, plataforma, virtuales, futuros);
        }

        System.out.println();
        demostrarPinning();
    }

    private static List<String> generar(int n) {
        return IntStream.rangeClosed(1, n)
                .mapToObj(i -> String.format("978-%010d", i))
                .toList();
    }

    private static long medir(Runnable tarea) {
        long inicio = System.currentTimeMillis();
        tarea.run();
        return System.currentTimeMillis() - inicio;
    }

    private static void conPoolDePlataforma(List<String> isbns) {
        try (var executor = Executors.newFixedThreadPool(200)) {
            List<Future<String>> futuros = isbns.stream()
                    .map(isbn -> executor.submit(() -> consultarSimulado(isbn)))
                    .toList();
            futuros.forEach(ComparativaEnriquecimiento::obtener);
        }
    }

    private static void conHilosVirtuales(List<String> isbns) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futuros = isbns.stream()
                    .map(isbn -> executor.submit(() -> consultarSimulado(isbn)))
                    .toList();
            futuros.forEach(ComparativaEnriquecimiento::obtener);
        }
    }

    private static void conCompletableFuture(List<String> isbns) {
        try (var executor = Executors.newFixedThreadPool(200)) {
            List<CompletableFuture<String>> futuros = isbns.stream()
                    .map(isbn -> CompletableFuture.supplyAsync(() -> {
                        try {
                            return consultarSimulado(isbn);
                        } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                            return null;
                        }
                    }, executor))
                    .toList();
            CompletableFuture.allOf(futuros.toArray(CompletableFuture[]::new)).join();
        }
    }

    private static <T> T obtener(Future<T> f) {
        try {
            return f.get();
        } catch (Exception e) {
            return null;
        }
    }

    // ------------------------------------------------------------------
    // Demostracion del PINNING
    // ------------------------------------------------------------------

    static class ConSynchronized {
        private final Map<String, String> cache = new HashMap<>();

        /** synchronized + bloqueo -> PINNING del hilo portador. */
        public synchronized String obtener(String isbn) throws InterruptedException {
            String v = cache.get(isbn);
            if (v == null) {
                v = consultarSimulado(isbn);
                cache.put(isbn, v);
            }
            return v;
        }
    }

    static class ConReentrantLock {
        private final Map<String, String> cache = new ConcurrentHashMap<>();
        private final ReentrantLock cerrojo = new ReentrantLock();

        /** ReentrantLock permite desmontar el hilo virtual. */
        public String obtener(String isbn) throws InterruptedException {
            String v = cache.get(isbn);
            if (v != null) {
                return v;
            }
            cerrojo.lock();
            try {
                v = cache.get(isbn);
                if (v == null) {
                    v = consultarSimulado(isbn);
                    cache.put(isbn, v);
                }
                return v;
            } finally {
                cerrojo.unlock();
            }
        }
    }

    private static void demostrarPinning() {
        System.out.println("--- Pinning (ejecutar con -Djdk.tracePinnedThreads=full) ---");
        List<String> isbns = generar(500);

        ConSynchronized conSync = new ConSynchronized();
        long t1 = medir(() -> {
            try (var e = Executors.newVirtualThreadPerTaskExecutor()) {
                isbns.forEach(isbn -> e.submit(() -> conSync.obtener(isbn)));
            }
        });

        ConReentrantLock conLock = new ConReentrantLock();
        long t2 = medir(() -> {
            try (var e = Executors.newVirtualThreadPerTaskExecutor()) {
                isbns.forEach(isbn -> e.submit(() -> conLock.obtener(isbn)));
            }
        });

        System.out.printf("  synchronized:  %5d ms%n", t1);
        System.out.printf("  ReentrantLock: %5d ms%n", t2);
    }
}
ISBN        PLATAF. (200)      VIRTUALES   COMPL.FUTURE
--------------------------------------------------------
100                  62 ms          58 ms          64 ms
1000                306 ms          71 ms         301 ms
10000              2571 ms         139 ms        2559 ms

--- Pinning (ejecutar con -Djdk.tracePinnedThreads=full) ---
  synchronized:  25142 ms
  ReentrantLock: 25089 ms

Comentarios. Cinco observaciones.

Con 100 ISBN los tres son equivalentes, porque 100 caben en un pool de 200. La diferencia aparece cuando la concurrencia deseada supera al pool.

Con 10.000, los hilos virtuales son 18× más rápidos. El pool de 200 hace 50 rondas de 50 ms; los virtuales lo hacen todo a la vez. CompletableFuture sobre el mismo pool de 200 tiene exactamente el mismo límite: la asincronía no elimina el cuello de botella del pool, solo evita que los hilos esperen ociosos.

El código de procesarUno es secuencial. Compáralo con la versión de 09-06: allí había thenCompose, thenCombine, exceptionally y allOf, y las variables intermedias tenían que viajar dentro de las lambdas. Aquí hay un try, dos llamadas y un return. Y escala igual.

Los dos casos de pinning tardan lo mismo aquí, y eso es instructivo: el synchronized está en el método completo, así que serializa las 500 tareas con o sin pinning — 500 × 50 ms = 25 segundos en ambos casos. El pinning se manifiesta sobre todo cuando hay muchos portadores ocupados; con -Djdk.tracePinnedThreads=full en Java 21 verás las trazas <== monitors:1 en la versión con synchronized y ninguna en la del ReentrantLock. La lección real es que el problema aquí no es el pinning sino la sección crítica demasiado grande, que es un problema del módulo 8 y no de Loom.

El Semaphore en lugar del tamaño del pool es la pieza de diseño. El servicio de metadatos aguanta 50 peticiones simultáneas; eso es una propiedad del servicio, no del cliente. Expresarlo con un semáforo permite tener 10.000 hilos virtuales de los que solo 50 están dentro del servicio a la vez, y los 9.950 restantes esperando sin consumir ningún hilo del sistema operativo.

Solución 3

package com.nexussoftware.bibliotech.herramientas;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * Detecta oportunidades de modernizacion en codigo Java.
 *
 * Es un analisis por expresiones regulares: rapido y aproximado.
 * Un analizador real trabajaria sobre el AST (javax.lang.model, 10-02).
 */
public class AnalizadorDeModernizacion {

    /** Tipo sellado para las categorias de hallazgo. */
    public sealed interface Hallazgo {
        Path fichero();
        int linea();
        int versionJava();
        String sugerencia();

        record ConcatenacionMultilinea(Path fichero, int linea) implements Hallazgo {
            public int versionJava() { return 15; }
            public String sugerencia() { return "Usa un bloque de texto \"\"\"...\"\"\""; }
        }
        record SwitchClasico(Path fichero, int linea) implements Hallazgo {
            public int versionJava() { return 14; }
            public String sugerencia() { return "Usa switch de expresión con ->"; }
        }
        record CastTrasInstanceof(Path fichero, int linea, String tipo) implements Hallazgo {
            public int versionJava() { return 16; }
            public String sugerencia() { return "Usa instanceof " + tipo + " variable"; }
        }
        record ApiDeFechasAntigua(Path fichero, int linea, String clase) implements Hallazgo {
            public int versionJava() { return 8; }
            public String sugerencia() {
                return switch (clase) {
                    case "SimpleDateFormat" -> "Usa DateTimeFormatter (inmutable y seguro entre hilos)";
                    case "Calendar"         -> "Usa LocalDate / LocalDateTime";
                    case "Date"             -> "Usa Instant o LocalDate según el caso";
                    default                 -> "Migra a java.time";
                };
            }
        }
        record PoolDePlataforma(Path fichero, int linea) implements Hallazgo {
            public int versionJava() { return 21; }
            public String sugerencia() {
                return "Si las tareas se bloquean, usa Executors.newVirtualThreadPerTaskExecutor()";
            }
        }
        record BucleAcumulador(Path fichero, int linea) implements Hallazgo {
            public int versionJava() { return 8; }
            public String sugerencia() { return "Considera stream().filter(...).map(...).toList()"; }
        }
        record CandidatoARecord(Path fichero, int linea, String clase) implements Hallazgo {
            public int versionJava() { return 16; }
            public String sugerencia() { return "La clase " + clase + " podría ser un record"; }
        }
    }

    // --- Patrones ---
    private static final Pattern CONCATENACION = Pattern.compile("^\\s*\\+\\s*\"");
    private static final Pattern SWITCH_CLASICO = Pattern.compile("\\bcase\\s+[^>]+:\\s*$");
    private static final Pattern INSTANCEOF_SIN_PATRON =
            Pattern.compile("instanceof\\s+(\\w+)\\s*\\)\\s*\\{?\\s*$");
    private static final Pattern CAST = Pattern.compile("\\(\\s*(\\w+)\\s*\\)\\s*\\w+");
    private static final Pattern FECHAS_ANTIGUAS =
            Pattern.compile("\\bnew\\s+(SimpleDateFormat|Date|GregorianCalendar)\\b"
                          + "|\\bCalendar\\.getInstance\\(\\)");
    private static final Pattern POOL_FIJO =
            Pattern.compile("Executors\\.newFixedThreadPool|Executors\\.newCachedThreadPool");
    private static final Pattern DECLARACION_LISTA =
            Pattern.compile("(List|Set|Map)<[^>]+>\\s+\\w+\\s*=\\s*new\\s+(ArrayList|HashSet|HashMap)");
    private static final Pattern FOR_EACH = Pattern.compile("\\bfor\\s*\\(\\s*\\w+.*:\\s*\\w+");

    public List<Hallazgo> analizar(Path raiz) throws IOException {
        try (Stream<Path> ficheros = Files.walk(raiz)) {
            return ficheros
                    .filter(Files::isRegularFile)
                    .filter(p -> p.toString().endsWith(".java"))
                    .filter(p -> !p.getFileName().toString().equals("module-info.java"))
                    .flatMap(this::analizarFichero)
                    .toList();
        }
    }

    private Stream<Hallazgo> analizarFichero(Path fichero) {
        try {
            List<String> lineas = Files.readAllLines(fichero, StandardCharsets.UTF_8);
            List<Hallazgo> hallazgos = new ArrayList<>();

            String posibleTipoInstanceof = null;
            int lineaInstanceof = 0;
            int lineasDeDeclaracion = 0;
            int inicioDeclaracion = 0;

            for (int i = 0; i < lineas.size(); i++) {
                String linea = lineas.get(i);
                int numero = i + 1;

                if (CONCATENACION.matcher(linea).find()) {
                    hallazgos.add(new Hallazgo.ConcatenacionMultilinea(fichero, numero));
                }
                if (SWITCH_CLASICO.matcher(linea).find()) {
                    hallazgos.add(new Hallazgo.SwitchClasico(fichero, numero));
                }
                if (FECHAS_ANTIGUAS.matcher(linea).find()) {
                    var m = FECHAS_ANTIGUAS.matcher(linea);
                    m.find();
                    String clase = m.group(1) != null ? m.group(1) : "Calendar";
                    hallazgos.add(new Hallazgo.ApiDeFechasAntigua(fichero, numero, clase));
                }
                if (POOL_FIJO.matcher(linea).find()) {
                    hallazgos.add(new Hallazgo.PoolDePlataforma(fichero, numero));
                }

                // instanceof en una linea, cast al mismo tipo en la siguiente
                var mi = INSTANCEOF_SIN_PATRON.matcher(linea);
                if (mi.find()) {
                    posibleTipoInstanceof = mi.group(1);
                    lineaInstanceof = numero;
                } else if (posibleTipoInstanceof != null) {
                    var mc = CAST.matcher(linea);
                    if (mc.find() && posibleTipoInstanceof.equals(mc.group(1))) {
                        hallazgos.add(new Hallazgo.CastTrasInstanceof(
                                fichero, lineaInstanceof, posibleTipoInstanceof));
                    }
                    posibleTipoInstanceof = null;
                }

                // Declaracion de coleccion seguida de bucle for-each: candidato a stream
                if (DECLARACION_LISTA.matcher(linea).find()) {
                    lineasDeDeclaracion = 1;
                    inicioDeclaracion = numero;
                } else if (lineasDeDeclaracion > 0 && lineasDeDeclaracion < 4) {
                    if (FOR_EACH.matcher(linea).find()) {
                        hallazgos.add(new Hallazgo.BucleAcumulador(fichero, inicioDeclaracion));
                        lineasDeDeclaracion = 0;
                    } else {
                        lineasDeDeclaracion++;
                    }
                } else {
                    lineasDeDeclaracion = 0;
                }
            }

            detectarCandidatoARecord(fichero, lineas).ifPresent(hallazgos::add);
            return hallazgos.stream();

        } catch (IOException e) {
            System.err.println("No se pudo leer " + fichero + ": " + e.getMessage());
            return Stream.empty();
        }
    }

    /** Heuristica: solo campos final, getters, equals, hashCode y toString. */
    private Optional<Hallazgo> detectarCandidatoARecord(Path fichero, List<String> lineas) {
        String contenido = String.join("\n", lineas);

        boolean tieneSetters = contenido.contains("public void set");
        boolean tieneCamposNoFinales = Pattern
                .compile("private\\s+(?!final)\\w+\\s+\\w+\\s*;").matcher(contenido).find();
        boolean tieneEquals = contenido.contains("public boolean equals(Object");
        boolean tieneHashCode = contenido.contains("public int hashCode()");
        boolean extiende = Pattern.compile("class\\s+\\w+\\s+extends").matcher(contenido).find();

        if (!tieneSetters && !tieneCamposNoFinales && tieneEquals && tieneHashCode && !extiende) {
            String nombre = fichero.getFileName().toString().replace(".java", "");
            int linea = 1 + (int) lineas.stream()
                    .takeWhile(l -> !l.contains("class " + nombre))
                    .count();
            return Optional.of(new Hallazgo.CandidatoARecord(fichero, linea, nombre));
        }
        return Optional.empty();
    }

    // ------------------------------------------------------------------

    public String informar(List<Hallazgo> hallazgos, int versionObjetivo) {

        Map<String, List<Hallazgo>> porTipo = hallazgos.stream()
                .collect(Collectors.groupingBy(h -> h.getClass().getSimpleName(),
                         TreeMap::new, Collectors.toList()));

        Map<Integer, Long> porVersion = hallazgos.stream()
                .collect(Collectors.groupingBy(Hallazgo::versionJava,
                         TreeMap::new, Collectors.counting()));

        long aplicables = hallazgos.stream()
                .filter(h -> h.versionJava() <= versionObjetivo)
                .count();

        StringBuilder sb = new StringBuilder();
        sb.append("""
                ════════════════════════════════════════════════════
                  INFORME DE MODERNIZACIÓN -- objetivo: Java %d
                ════════════════════════════════════════════════════
                  Hallazgos totales:     %d
                  Aplicables ya:         %d
                  Requieren migrar:      %d
                """.formatted(versionObjetivo, hallazgos.size(),
                              aplicables, hallazgos.size() - aplicables));

        sb.append("\nPOR VERSIÓN MÍNIMA NECESARIA\n");
        porVersion.forEach((v, n) -> sb.append(String.format(
                "  Java %-3d %4d  %s%s%n", v, n, "▇".repeat((int) Math.min(n, 40)),
                v <= versionObjetivo ? "" : "   (requiere migrar)")));

        sb.append("\nPOR TIPO\n");
        porTipo.forEach((tipo, lista) -> {
            sb.append(String.format("  %-26s %4d%n", tipo, lista.size()));
            lista.stream().limit(3).forEach(h -> sb.append(String.format(
                    "      %s:%d  %s%n",
                    h.fichero().getFileName(), h.linea(), h.sugerencia())));
            if (lista.size() > 3) {
                sb.append(String.format("      ... y %d más%n", lista.size() - 3));
            }
        });

        return sb.toString();
    }

    public static void main(String[] args) throws IOException {
        Path raiz = Path.of(args.length > 0 ? args[0] : "src");
        int objetivo = args.length > 1 ? Integer.parseInt(args[1]) : 21;

        AnalizadorDeModernizacion analizador = new AnalizadorDeModernizacion();
        List<Hallazgo> hallazgos = analizador.analizar(raiz);

        System.out.println(analizador.informar(hallazgos, objetivo));
    }
}
════════════════════════════════════════════════════
  INFORME DE MODERNIZACIÓN -- objetivo: Java 21
════════════════════════════════════════════════════
  Hallazgos totales:     47
  Aplicables ya:         47
  Requieren migrar:      0

POR VERSIÓN MÍNIMA NECESARIA
  Java 8     14  ▇▇▇▇▇▇▇▇▇▇▇▇▇▇
  Java 14     9  ▇▇▇▇▇▇▇▇▇
  Java 15    11  ▇▇▇▇▇▇▇▇▇▇▇
  Java 16     8  ▇▇▇▇▇▇▇▇
  Java 21     5  ▇▇▇▇▇

POR TIPO
  ApiDeFechasAntigua          9
      GestorSesion.java:34  Usa DateTimeFormatter (inmutable y seguro entre hilos)
      AlmacenPrestamos.java:57  Usa LocalDate / LocalDateTime
      RegistroOperaciones.java:22  Usa Instant o LocalDate según el caso
      ... y 6 más
  BucleAcumulador             5
      EstadisticasBiblioTech.java:19  Considera stream().filter(...).map(...).toList()
      MenuBiblioTech.java:88  Considera stream().filter(...).map(...).toList()
      ... y 3 más
  CandidatoARecord            3
      Ficha.java:8  La clase Ficha podría ser un record
      ResumenSesion.java:11  La clase ResumenSesion podría ser un record
      ... y 1 más
  CastTrasInstanceof          8
      CalculadoraPlazosAntigua.java:14  Usa instanceof Libro variable
      ExportadorCatalogoCsv.java:41  Usa instanceof Material variable
      ... y 6 más
  ConcatenacionMultilinea    11
      ConsultasBiblioTech.java:12  Usa un bloque de texto """..."""
      ClienteMetadatos.java:73  Usa un bloque de texto """..."""
      ... y 9 más
  PoolDePlataforma            5
      ServidorCatalogoConPool.java:24  Si las tareas se bloquean, usa Executors.newVirtualThreadPerTaskExecutor()
      ... y 4 más
  SwitchClasico               6
      MenuBiblioTech.java:52  Usa switch de expresión con ->
      ... y 5 más

Comentarios.

El analizador usa lo que analiza. Tipos sellados para las categorías de hallazgo, records para cada una, switch exhaustivo para las sugerencias, bloques de texto para el informe, Files.walk con streams para recorrer el árbol. Es una demostración por construcción.

El límite de las expresiones regulares es real y hay que declararlo. CastTrasInstanceof da falsos positivos con casts legítimos y falsos negativos cuando el cast está tres líneas más abajo. Un analizador serio trabaja sobre el AST, con javax.lang.model (que asomó en 10-02) o con una biblioteca como JavaParser. Este ejercicio es una aproximación pragmática, no una herramienta de producción.

La columna "requiere migrar" es lo más útil del informe. Ordenar los hallazgos por versión mínima convierte el informe en un argumento de negocio: "migrar a Java 17 desbloquea 28 mejoras; a Java 21, otras 5". Eso es más convincente que "deberíamos actualizar".

Y CandidatoARecord es deliberadamente conservador. Solo propone la conversión si no hay setters, ni campos mutables, ni herencia, y sí hay equals y hashCode. Proponer convertir a record una clase con estado mutable produciría una sugerencia incorrecta, y una herramienta que sugiere mal deja de usarse.

Conclusión

Ya sabes de dónde salió todo lo que llevabas usando, y bastante más.

Conoces el calendario de versiones: una cada seis meses desde 2018, con fecha fija, y LTS cada dos o tres años —8, 11, 17, 21, 25— que son las que se usan en producción. Y sabes que las novedades grandes pasan por vista previa con --enable-preview antes de ser definitivas, y que eso no se usa en producción porque el diseño puede cambiar.

Entiendes el sistema de módulos (JPMS) y los tres problemas que atacaba: un JDK monolítico de 60 MB, la ausencia total de encapsulamiento entre paquetes —public significaba "para todo el mundo"— y las dependencias sin comprobar hasta el NoClassDefFoundError. Sabes escribir un module-info.java con requires (y sus variantes transitive y static), exports (con exportación cualificada to) y opens, y tienes clara la distinción que causa la mitad de los problemas: exportar permite el uso normal; solo opens permite setAccessible sobre miembros privados. Por eso el ExportadorAnotado de 10-03 necesitaría opens si BiblioTech se modularizara, y por eso InaccessibleObjectException existe y --add-opens es la vía de escape —una tirita, no una solución—. Conoces el classpath frente al modulepath, los JAR automáticos como puente, y jlink, que reduce un runtime de 315 MB a 44 MB. Y tienes la valoración honesta: JPMS no se ha adoptado en aplicaciones empresariales, sí en el propio JDK, en bibliotecas serias y con jlink/jpackage; modulariza bibliotecas, no aplicaciones.

Repasaste las incorporaciones de API: colecciones inmutables List.of/Map.of —que rechazan null, rechazan duplicados y desordenan a propósito para que nadie dependa del orden—, métodos privados en interfaces, strip frente a trim, repeat, isBlank, lines que devuelve un Stream, Files.readString/writeString, las colecciones secuenciales de Java 21 que unifican getFirst/getLast/reversed, los mensajes útiles de NullPointerException que dicen exactamente qué devolvió null, y UTF-8 por defecto desde Java 18.

Y la sintaxis. var para inferencia local —que no es tipado dinámico, que brilla con tipos genéricos largos y que estorba cuando el tipo no se deduce de la línea—. El switch de expresión con flechas y yield, sin break, sin caída entre casos y con exhaustividad comprobada sobre enum. Los bloques de texto con su regla de sangría común —donde la posición del """ de cierre participa en el cálculo— y formatted(), ideales para el SQL y el JSON de los módulos siguientes. Los record con constructor compacto que valida y normaliza. El instanceof con patrón que elimina el cast redundante y cuyo ámbito se calcula por flujo, funcionando incluso tras una negación con salida temprana.

Y sobre todo, las dos características que cambian cómo se diseña. Las clases selladas con sealed/permits y sus tres opciones obligatorias para las subclases —final, sealed o non-sealed—, que dan lo que no existía: exhaustividad verificada por el compilador. Y el pattern matching para switch de Java 21 con case null, guardas when y patrones de registro que descomponen los componentes directamente, anidándose a varios niveles. Combinados, modelan tipos algebraicos donde los estados imposibles no se pueden construir.

BiblioTech lo demuestra: CalculadoraPlazos pasó de 45 líneas de if-else con casts y un default que ocultaba los casos nuevos, a 20 líneas de switch exhaustivos sin default. Y cuando el ejercicio añadió un quinto material, el compilador produjo cuatro errores en los cuatro sitios exactos que había que actualizar. La versión antigua habría compilado y habría fallado en producción.

Y tienes los hilos virtuales. Sabes qué son —hilos gestionados por la JVM sobre unos pocos portadores— y por qué cambian el cálculo: al bloquearse se desmontan del portador, guardan su pila en el heap y liberan el hilo del sistema operativo. De ahí las cifras: creación en microsegundos en lugar de milisegundos, cientos de bytes en lugar de un megabyte, millones viables en lugar de miles. Diez mil tareas bloqueadas un segundo pasaron de 50 segundos con un pool de 200 a 1,1 segundos; un millón de hilos virtuales arrancaron en menos de tres.

El ServidorCatalogo de 09-03 se ha refactorizado, y lo importante es lo que no cambió: atender() sigue siendo el mismo código secuencial y bloqueante, con try-with-resources, variables locales y trazas de pila legibles. Lo que desapareció es el pool acotado, el tamaño que ajustar, la cola, la política de rechazo y el límite de 50 clientes. Los hilos virtuales devuelven el código sencillo sin pagar en escalabilidad, que es exactamente lo que CompletableFuture no puede dar.

Conoces las cuatro reglas —no agruparlos en pool, uno por tarea, Semaphore para limitar recursos externos, y ReentrantLock en lugar de synchronized para evitar el pinning— y, tan importante, lo que no resuelven: no aceleran el cálculo, no eliminan ni una sola de las trampas del módulo 8 (que se aplican íntegras), no arreglan un pool de veinte conexiones de base de datos, desaconsejan ThreadLocal, y no sustituyen a CompletableFuture en la composición asíncrona. Y sabes lo que viene: concurrencia estructurada con StructuredTaskScope y ScopedValue como sustituto acotado e inmutable de ThreadLocal.

Sabes usar jshell para verificar en cinco segundos, jpackage para instaladores nativos, jdeps para analizar dependencias, y cómo mantenerte al día —los JEP como fuente primaria, las notas de versión para los cambios incompatibles— y cómo decidir la migración: la LTS más reciente que soporten tus dependencias, sabiendo que el salto de 8 a 11 es el difícil (JAXB fuera, API interna bloqueada, herramientas antiguas) y que de 17 a 21 casi todo funciona sin tocar nada. Con la regla de método que evita la mayoría de los desastres: separa migrar de modernizar.

BiblioTech, al terminar esta lección, tiene una jerarquía sellada donde el compilador verifica que has cubierto todos los materiales, estados de préstamo modelados como records donde las combinaciones imposibles no se pueden escribir, SQL y JSON en bloques de texto legibles, y un servidor que atiende decenas de miles de conexiones con el mismo código que atendía cincuenta.

Y queda una última caja negra. Todo lo que has escrito en este módulo se ejecuta sobre una JVM cuyo comportamiento sigues sin conocer. Cuando el ExportadorAnotado cachea la introspección, ¿dónde vive esa caché y quién la libera? Cuando un millón de hilos virtuales guardan su pila en el heap, ¿qué es exactamente el heap y qué pasa cuando se llena? Cuando la medición del apartado 14 de 10-04 dio números distintos antes y después del calentamiento, ¿qué estaba haciendo la JVM en ese rato? Cuando WeakHashMap apareció de pasada, ¿qué significa que una referencia sea "débil"? ¿Por qué la primera petición al servidor siempre es la más lenta? ¿Y por qué una aplicación Java que lleva tres días funcionando de repente empieza a pausarse cada pocos segundos?

En 10-07, Memoria, Recolección de Basura y Rendimiento, la caja se abre del todo y el módulo se cierra. Verás las regiones de memoria —pila por hilo con sus marcos y su StackOverflowError, heap compartido, metaspace, caché de código, memoria nativa— y qué significa cada mensaje distinto de OutOfMemoryError. Verás el modelo generacional, la hipótesis de que la mayoría de los objetos mueren jóvenes, y cómo el recolector decide qué borrar mediante alcanzabilidad desde las raíces —no contando referencias, por lo que los ciclos no importan—. Compararás los recolectores actuales —Serial, Parallel, G1, ZGC, Shenandoah, Epsilon— y sabrás qué tres parámetros se tocan de verdad. Verás que las fugas de memoria en Java existen, con sus cuatro patrones clásicos demostrados y cómo se diagnostican, y las referencias débiles, blandas y fantasma con WeakHashMap y su caso real en la caché de fichas. Aprenderás a medir antes de optimizar con jps, jstat, jmap, jcmd, volcados de heap y Java Flight Recorder, y por qué un microbenchmark casero miente —calentamiento, JIT, eliminación de código muerto— y qué es JMH. Y entenderás el compilador JIT: interpretación, C1, C2, puntos calientes, inlining, desoptimización, y por qué tu código se acelera solo con el tiempo.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados