Día 18 de 30 Fase 4: Seguridad, Concurrencia y Reactividad vs Virtual Threads

Concurrencia en la JVM: De Hilos Clásicos a Virtual Threads en Java 21

Project Loom, Platform Threads vs Virtual Threads, Thread Pools y Concurrencia Estructurada

Para dominar. Al terminar deberías poder hacerlo sin ayuda.

  • 4h de estudio
  • 5 conceptos
  • 3 preguntas
  • 2 katas
Java 21 LTSVirtual ThreadsProject LoomPlatform ThreadsCarrier ThreadsConcurrencia
Al terminar este día sabrás 4
  • Explicar la diferencia de modelo entre hilos de plataforma y Virtual Threads.
  • Describir qué ocurre cuando un hilo virtual se bloquea y cuándo no puede desmontarse.
  • Activar Virtual Threads en Spring Boot y limitar la concurrencia sin pools.
  • Situar Scoped Values y Structured Concurrency y su estado actual.

Platform Threads vs Virtual Threads: Modelo 1:1 frente a Modelo M:N

Un Platform Thread está mapeado directamente a un hilo del sistema operativo kernel (1:1). Un Virtual Thread es una abstracción gestionada por la JVM que se monta sobre un pool pequeño de hilos de soporte del sistema operativo llamados Carrier Threads (modelo M:N).

En una línea Thread.ofVirtual().start(() -> System.out.println("En hilo virtual"));

10.000 tareas bloqueantes con hilos virtuales java
// Java 21: un hilo virtual por tarea. El try cierra el executor y espera a que terminen.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i ->
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));   // simula una llamada de E/S
            return i;
        }));
}
// Tarda ~1 segundo. Con Executors.newFixedThreadPool(200) tardaría ~50 segundos.

// Otras formas de crearlos
Thread t1 = Thread.ofVirtual().name("worker-", 0).start(() -> System.out.println("hola"));
Thread t2 = Thread.startVirtualThread(() -> System.out.println(Thread.currentThread().isVirtual()));
Cómo funciona por dentro

Un hilo de plataforma (Thread clásico) es una envoltura de un hilo del sistema operativo: relación 1 a 1. Cada uno reserva una pila de alrededor de 1 MB y el cambio de contexto lo hace el kernel. Por eso los servidores usan pools de un par de cientos de hilos: crear miles es caro y lento.

Un hilo virtual (Java 21, JEP 444) es un objeto de la JVM. Muchos hilos virtuales (M) se ejecutan sobre unos pocos hilos portadores (N), que son hilos de plataforma de un ForkJoinPool con tantos hilos como núcleos. La pila de un hilo virtual vive en el heap y crece según la necesita, así que ocupa unos cientos de bytes al empezar. Crear un millón es razonable.

Los hilos virtuales no hacen el código más rápido: hacen que esperar sea barato. Brillan en cargas de E/S (servidores web, llamadas a bases de datos y APIs) y no aportan nada en cálculo intensivo de CPU, donde el límite son los núcleos.

Permite que operaciones bloqueantes de red o base de datos liberen el hilo del SO para otros procesos mientras esperan.

Se conecta con Memoria StackPlanificador ForkJoinPoolRendimiento I/O

Cuatro bloques cronometrados. No pases al siguiente sin tener el resultado esperado.

  1. Bloque 1: Comparativa Práctica Platform vs Virtual (0 - 25 min)

    Qué estudiar. Ejecución de un bucle de 10.000 tareas con Executors.newVirtualThreadPerTaskExecutor() vs Platform Threads.

    Pregunta a responder. ¿Cuánto tiempo y memoria consume lanzar 10.000 hilos de cada tipo?

    Resultado esperado. Script Java 21 que demuestre que 10.000 Virtual Threads terminan en segundos sin agotar memoria.

  2. Bloque 2: Detección y Mitigación de Pinning (25 - 50 min)

    Qué estudiar. Uso de la propiedad -Djdk.tracePinnedThreads=full y refactorización de synchronized a ReentrantLock.

    Pregunta a responder. ¿Cómo saber si una librería de terceros ancla tus Carrier Threads?

    Resultado esperado. Código de ejemplo refactorizado con ReentrantLock probado con la flag de pinning.

  3. Bloque 3: Integración en Spring Boot 3.2+ (50 - 80 min)

    Qué estudiar. Configuración de spring.threads.virtual.enabled=true y verificación del nombre del hilo en logs (VirtualThread[#...]/runnable@ForkJoinPool-1-worker-X).

    Pregunta a responder. ¿Cómo verificar que los controladores REST atienden peticiones en hilos virtuales?

    Resultado esperado. Endpoint REST que imprima Thread.currentThread().isVirtual() devolviendo true.

  4. Cierre y Consolidación (80 - 90 min)

    Qué estudiar. Repaso de las 5 reglas de oro de concurrencia en Java 21.

    Pregunta a responder. ¿Cuándo NO usar Virtual Threads?

    Resultado esperado. Resumen en notas personales con el balance de pros/contras.

Tres formas de comprobar si lo has entendido, de menos a más exigente: explicarlo con tus palabras, responder preguntas trampa y resolver el reto del tutor.

Explícalo desde cero

Lee cada ficha y explícala en voz alta antes de seguir: si puedes contárselo a alguien que empieza, lo has entendido.

Por qué Virtual Threads elimina la necesidad de librerías reactivas para I/O simple

Lo que debes entender

La programación reactiva (WebFlux/Reactor) nació para resolver un problema de los Platform Threads: no bloquear hilos del sistema operativo. Pero obligaba a escribir código en cadenas complejas (mono.flatMap(...)) difícil de depurar. Con Virtual Threads, puedes volver a escribir código simple secuencial (var res = httpClient.send(...)) y la JVM se encarga de no bloquear el hilo del SO por debajo.

Analogía para contarlo

Platform Threads son como 100 camiones pesados en una autopista. Virtual Threads son como 100.000 bicicletas ligeras que van por el mismo carril: si una bicicleta frena a esperar, no traba a las demás.

Lo que suele explicarse mal

Decir que los Virtual Threads hacen que un hilo individual vaya "más rápido". No aceleran la latencia individual; multiplican el throughput (capacidad de peticiones concurrentes simultáneas).

Señal de que aún no lo dominas

Si propones poner Virtual Threads en una aplicación de cálculo numérico o minería de datos intensiva en CPU esperando que vaya 10 veces más rápido.

Preguntas trampa

Responde de memoria, en voz alta o por escrito, y después abre la respuesta razonada: lo importante es el porqué, no la palabra exacta.

Nivel 1 (Básico) ¿Por qué NUNCA se debe utilizar un Executors.newFixedThreadPool(200) para gestionar Virtual Threads? Ver respuesta

Respuesta razonada

Porque los pools existen para reutilizar recursos caros, y un hilo virtual no lo es: crearlo cuesta casi lo mismo que crear un objeto. Un pool fijo de 200 hilos virtuales reintroduce justo el límite artificial que los hilos virtuales vienen a eliminar: la tarea 201 espera aunque haya recursos de sobra, y además pierdes la semántica de «un hilo por tarea», que facilita depurar y propagar contexto.

Si lo que buscas con ese 200 es limitar cuántas operaciones llegan a la vez a un recurso (una base de datos, un proveedor externo), la herramienta correcta es un Semaphore con 200 permisos alrededor de esa llamada, combinado con newVirtualThreadPerTaskExecutor().

Qué está evaluando la pregunta

Evalúa la comprensión del ciclo de vida de los hilos virtuales. Los thread pools existen para amortizar el altísimo coste de creación de un Platform Thread del sistema operativo (~1MB de stack, syscall del kernel). Un Virtual Thread cuesta apenas unos cientos de bytes en Heap y se crea en microsegundos; agruparlos en un pool limita artificialmente su escalabilidad.

Nivel 2 (Intermedio) ¿Qué es el "Thread Pinning" en Java 21 y por qué los drivers JDBC antiguos causaban problemas con Virtual Threads? Ver respuesta

Respuesta razonada

El pinning ocurre cuando un hilo virtual se bloquea mientras está dentro de un bloque synchronized o de código nativo: la JVM de Java 21 no puede desmontarlo y el hilo portador queda bloqueado con él. Como solo hay tantos portadores como núcleos, unos pocos hilos clavados pueden paralizar toda la aplicación.

Los drivers JDBC antiguos protegían el acceso al socket de la conexión con synchronized. Cada consulta clavaba su hilo virtual durante toda la espera a la base de datos, así que con 8 núcleos solo había 8 consultas avanzando a la vez y el resto de hilos virtuales se quedaba sin portador. Los fabricantes cambiaron synchronized por ReentrantLock (el driver de PostgreSQL lo hizo en la versión 42.6) y Java 24 eliminó el problema de raíz.

Qué está evaluando la pregunta

El pinning ocurre cuando un hilo virtual no puede desmontarse de su Carrier Thread debido a que está dentro de un bloque synchronized nativo o frame de JNI. Muchos drivers JDBC antiguos utilizaban synchronized en cada operación de socket, secuestrando los Carrier Threads de la máquina.

Nivel 3 (Entrevista Senior) Si una aplicación con Virtual Threads hace 50.000 peticiones simultáneas a una base de datos PostgreSQL, ¿por qué el Connection Pool de HikariCP puede convertirse en el cuello de botella? Ver respuesta

Respuesta razonada

Porque los hilos virtuales eliminan el límite de hilos, pero no el de conexiones. Las 50.000 peticiones llegan a la vez al pool de HikariCP, que tiene, digamos, 20 conexiones. Veinte consultas avanzan y 49.980 hilos virtuales quedan esperando una conexión libre. Si no la consiguen en connection-timeout, fallan con SQLTransientConnectionException.

Aumentar el pool no lo arregla: PostgreSQL tiene un límite de conexiones (max_connections, 100 por defecto) y cada una consume memoria en el servidor; con cientos de consultas a la vez rinde peor. Las soluciones son limitar la concurrencia de entrada (semáforo, bulkhead, rate limiting) para devolver errores rápidos o encolar, reducir el trabajo por petición (caché, consultas más eficientes, lotes) y, si de verdad hace falta, escalar la base de datos o añadir réplicas de lectura.

Qué está evaluando la pregunta

Evalúa la visión holística de arquitectura. Virtual Threads permite que tu servidor Java maneje 50.000 peticiones concurrentes sin inmutarse, pero tu base de datos relacional (PostgreSQL/MySQL) no puede manejar 50.000 conexiones concurrentes sin colapsar. HikariCP limitará las conexiones a su tamaño máximo (e.g. 50 conexiones), por lo que 49.950 hilos virtuales quedarán esperando un recurso de base de datos. Se debe dimensionar el pool y proteger la BD con semáforos o colas.

Reto del tutor

Un equipo propone migrar su aplicación de Java 17 a Java 21 y activar spring.threads.virtual.enabled=true. El líder de infraestructura pregunta: "¿Esto significa que podemos quitar los límites de memoria RAM de nuestros pods en Kubernetes?". Responde como un ingeniero senior de rendimiento.

Lo que una respuesta suele tener bien

Explica que los Virtual Threads no eliminan el consumo de memoria y que el heap puede crecer si hay demasiadas peticiones vivas al mismo tiempo.

Lo que suele quedarse en la superficie

Decir que sí porque "los hilos virtuales no pesan nada".

El concepto de fondo que suele faltar

No entender que cada petición concurrente viva retiene objetos en el Heap (DTOs, resultados de queries, buffers de red). Si antes 200 hilos retenían 200 peticiones simultáneas, ahora 20.000 hilos virtuales activos retendrán 20.000 peticiones vivas en el Heap, disparando la presión sobre el Garbage Collector y pudiendo provocar un OutOfMemoryError si no hay límites de concurrencia.

Cómo lo respondería alguien senior en una entrevista

Bajo ningún concepto se deben quitar los límites de memoria; de hecho, se deben monitorizar con mayor rigor. Aunque cada hilo virtual consume casi cero stack, la carga concurrente en el Heap aumenta proporcionalmente al número de peticiones simultáneas en vuelo (cada petición tiene su árbol de objetos en memoria esperando respuestas de base de datos o APIs externas). Además, se debe verificar que las librerías de terceros no causen Thread Pinning y que el pool de base de datos (HikariCP) esté calibrado para no saturar el servidor de PostgreSQL. Virtual Threads democratiza la concurrencia masiva, pero traslada la presión de los hilos del SO al Garbage Collector y a los sistemas downstream.

Proyecto «Pedidos» · paso 18 de 30

Virtual threads en el servicio

Atender más peticiones concurrentes sin cambiar el estilo del código.

De qué va el proyecto

El backend de una tienda online: clientes, catálogo de productos y pedidos con sus líneas, pagos y envíos. Empieza como un proyecto Maven con records y termina como un sistema con API REST, seguridad, mensajería, contenedores y observabilidad. Cada paso construye sobre el anterior, así que trabaja siempre en el mismo repositorio.

Qué hacer

  1. Activa spring.threads.virtual.enabled=true con Java 21.
  2. Busca bloques synchronized en la reserva de stock y sustitúyelos por ReentrantLock si hacen I/O dentro.
  3. Mide con una carga sencilla (por ejemplo, 500 peticiones concurrentes) la latencia antes y después.

Está listo cuando

  • Tienes anotada la latencia p95 con y sin virtual threads.
  • Con -Djdk.tracePinnedThreads=full no aparecen avisos de pinning en tu código.

Katas: ejercicios cortos

Reglas de oro

  • Nunca uses Thread Pools con Virtual Threads; utiliza siempre Executors.newVirtualThreadPerTaskExecutor().
  • Los Virtual Threads están diseñados para tareas limitadas por I/O (red, disco, base de datos), NO para cálculo masivo intensivo en CPU.
  • Reemplaza bloques synchronized por ReentrantLock en secciones críticas que realicen operaciones I/O bloqueantes.
  • En Spring Boot 3.2+, activar spring.threads.virtual.enabled=true transforma automáticamente Tomcat a hilos virtuales.
  • Controla siempre la concurrencia hacia recursos downstream limitados (bases de datos relacionales) mediante Semaphore o dimensionamiento adecuado de HikariCP.

Java 17 frente a Java 21

CaracterísticaJava 17 LTSJava 21 LTS
Tamaño en Memoria ~1 MB por Platform Thread (Stack SO) < 1 KB por Virtual Thread (en Heap)
Límite Típico Concurrencia ~1.000 a 3.000 hilos antes de saturar SO Millones de hilos concurrentes
Bloqueo I/O Bloquea el hilo del sistema operativo Desmonta el hilo virtual (Carrier Thread queda libre)
Spring Boot 3.2+ Config No soportado spring.threads.virtual.enabled=true
Se guarda automáticamente

Fuentes oficiales

Revisado el . Este día en Markdown