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.
1
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íneaThread.ofVirtual().start(() -> System.out.println("En hilo virtual"));
10.000 tareas bloqueantes con hilos virtualesjava
// Java 21: un hilo virtual por tarea. El try cierra el executor y espera a que terminen.try (varexecutor= Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // simula una llamada de E/Sreturn i;
}));
}
// Tarda ~1 segundo. Con Executors.newFixedThreadPool(200) tardaría ~50 segundos.// Otras formas de crearlosThreadt1= Thread.ofVirtual().name("worker-", 0).start(() -> System.out.println("hola"));
Threadt2= 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.
Crear un Thread Pool (ThreadPoolExecutor) para Virtual Threads; los hilos virtuales son tan baratos que se crean y descartan bajo demanda sin reutilizarlos.
Exclusivo de Java 21 LTS en adelante.
Se conecta conMemoria StackPlanificador ForkJoinPoolRendimiento I/O
2
Desmontaje Automático en Bloqueos I/O (Unmounting)
Cuando un Virtual Thread ejecuta una operación bloqueante (leer de base de datos con JDBC, hacer una llamada HTTP con HttpClient, o Thread.sleep()), la JVM guarda el estado del hilo virtual en el Heap y desmonta el hilo del Carrier Thread del SO.
En una líneatry (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() -> consultarBD()); }
Ver el cambio de portadorjava
Runnabletarea= () -> {
System.out.println("antes: " + Thread.currentThread());
try {
Thread.sleep(100); // punto de desmontaje
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("después: " + Thread.currentThread());
};
try (varex= Executors.newVirtualThreadPerTaskExecutor()) {
for (inti=0; i < 4; i++) ex.submit(tarea);
}
// antes: VirtualThread[#23]/runnable@ForkJoinPool-1-worker-1// después: VirtualThread[#23]/runnable@ForkJoinPool-1-worker-3 <- otro portador
Cómo funciona por dentro
Cuando un hilo virtual ejecuta una operación bloqueante del JDK (leer de un socket, Thread.sleep, esperar un Lock o una BlockingQueue), la JVM guarda su pila en el heap como una continuación y lo desmonta de su portador. El portador queda libre y toma otro hilo virtual listo para ejecutarse. Cuando la operación termina, el hilo virtual vuelve a la cola y se monta en el primer portador libre, que puede ser otro distinto.
Para que esto funcione, el JDK se reescribió por dentro: las APIs de red y de concurrencia de java.util.concurrent saben desmontarse. Tu código sigue siendo síncrono y secuencial, con trazas de pila legibles y depuración normal, pero se comporta como código asíncrono en cuanto a uso de recursos.
Esto es lo que lo diferencia de la programación reactiva: obtienes la escalabilidad sin reescribir la lógica en cadenas de callbacks u operadores.
El Carrier Thread queda inmediatamente libre para ejecutar otro hilo virtual. Cuando la respuesta de red llega, la JVM vuelve a montar el hilo virtual en cualquier Carrier Thread libre.
Creer que Virtual Threads aceleran operaciones intensivas de CPU; solo escalan operaciones limitadas por I/O (red, disco, base de datos).
Revolución introducida en Java 21.
Se conecta conSockets no bloqueantesJDBC sincrónicoThroughput masivo
3
El Peligro del Pinning: synchronized vs ReentrantLock
Si un Virtual Thread entra en un bloque synchronized y realiza una operación de I/O bloqueante dentro de él, el hilo virtual queda "anclado" (pinned) a su Carrier Thread del sistema operativo, impidiendo que la JVM lo desmonte.
En una líneaprivate final ReentrantLock lock = new ReentrantLock(); lock.lock(); try { ... } finally { lock.unlock(); }
Evitar el pinning en Java 21java
// Problema en Java 21: E/S dentro de synchronized clava el hilo virtualclassClienteLegado {
synchronizedStringconsultar(String url)throws IOException {
return http.send(peticion(url), BodyHandlers.ofString()).body(); // portador bloqueado
}
}
// Solución: ReentrantLock permite desmontar mientras esperaclassCliente {
privatefinalReentrantLocklock=newReentrantLock();
Stringconsultar(String url)throws IOException, InterruptedException {
lock.lock();
try {
return http.send(peticion(url), BodyHandlers.ofString()).body();
} finally {
lock.unlock();
}
}
}
// Diagnóstico// java -Djdk.tracePinnedThreads=full -jar app.jar (imprime la pila al clavarse)// jcmd <pid> JFR.start name=pin settings=profile (evento jdk.VirtualThreadPinned)
Cómo funciona por dentro
Un hilo virtual queda «clavado» (pinned) a su portador cuando no puede desmontarse durante un bloqueo. En Java 21 ocurre en dos casos: mientras está dentro de un bloque o método synchronized, y mientras ejecuta código nativo (JNI) o una llamada de vuelta desde él. Si en esa situación se bloquea en E/S, el portador también se bloquea.
Con pocos portadores (uno por núcleo), unos cuantos hilos clavados a la vez pueden dejar sin portadores al resto de la aplicación: el rendimiento se desploma o parece un bloqueo. Por eso se recomendaba sustituir synchronized alrededor de operaciones de E/S por ReentrantLock, que sí permite desmontar. Muchas librerías (drivers JDBC, clientes HTTP) se actualizaron por este motivo.
Java 24 (JEP 491) eliminó el pinning por synchronized, pero en Java 21, la LTS que usarás en producción, sigue existiendo. Se detecta con el evento JFR jdk.VirtualThreadPinned o con -Djdk.tracePinnedThreads=full.
El pinning prolongado puede agotar los Carrier Threads y degradar el rendimiento a niveles peores que los hilos clásicos.
Hacer llamadas remotas o queries SQL dentro de métodos con la palabra clave synchronized.
En Java 21 se diagnostica con la flag -Djdk.tracePinnedThreads=full. Se soluciona reemplazando synchronized por ReentrantLock.
Se conecta conReentrantLockDiagnóstico JVMThread Dumps
4
Activación de Virtual Threads en Spring Boot 3.2+
En Spring Boot 3.2+ corriendo sobre Java 21, basta con una única propiedad en application.properties: spring.threads.virtual.enabled=true.
En una líneaspring.threads.virtual.enabled=true
Activar y limitar la concurrenciajava
// application.yml// spring:// threads:// virtual:// enabled: true@ServiceclassCatalogoExterno {
privatefinal RestClient cliente;
privatefinalSemaphoremaxConcurrentes=newSemaphore(20); // el proveedor admite 20 a la vez
CatalogoExterno(RestClient.Builder builder) {
this.cliente = builder.baseUrl("https://catalogo.proveedor.com").build();
}
publicProductoobtener(String sku)throws InterruptedException {
maxConcurrentes.acquire(); // el hilo virtual espera barato, sin bloquear portadortry {
return cliente.get().uri("/productos/{sku}", sku).retrieve().body(Producto.class);
} finally {
maxConcurrentes.release();
}
}
}
@GetMapping("/debug/hilo")Stringhilo() { return Thread.currentThread().toString(); } // VirtualThread[#81]/runnable@...
Cómo funciona por dentro
En Spring Boot 3.2+ con Java 21, spring.threads.virtual.enabled=true cambia de golpe todos los ejecutores que Boot autoconfigura: Tomcat o Jetty atienden cada petición en un hilo virtual, y @Async, @Scheduled y los listeners de Kafka y RabbitMQ también los usan.
El cambio de mentalidad es importante: antes, el tamaño del pool de hilos limitaba de forma implícita cuántas peticiones llegaban a la vez a la base de datos o a un servicio externo. Con hilos virtuales ese freno desaparece, y la presión se traslada a los recursos de verdad limitados. Si necesitas limitar la concurrencia hacia un recurso, no crees un pool de hilos virtuales: usa un Semaphore, o un bulkhead de Resilience4j.
Comprueba en tus logs o con Thread.currentThread().isVirtual() que realmente se están usando, y vigila el uso de ThreadLocal costosos (cachés por hilo), que con millones de hilos dejan de ser una buena idea.
Automáticamente, Tomcat embebido pasa a atender cada petición HTTP entrante con un Virtual Thread nuevo, y los ejecutores de @Async y @Scheduled usan ejecutores de hilos virtuales sin modificar ni una sola línea de código Java.
Activar la propiedad en un servidor que corre sobre Java 17; la propiedad será ignorada o provocará un error de arranque.
Solo posible con Java 21 y Spring Boot 3.2+.
Se conecta conTomcat EmbebidoSpring Boot 3.2Zero-Code Migration
5
Scoped Values y Concurrencia Estructurada (Preview / Futuro)
En Java 21, ThreadLocal sigue funcionando en Virtual Threads, pero tener millones de hilos con ThreadLocals pesados puede saturar el Heap. Los ScopedValue permiten compartir datos inmutables y de corta duración entre hilos de forma más ligera y segura.
En una líneaScopedValue.where(USUARIO_ACTUAL, usuario).run(() -> procesarOrden());
Structured Concurrency (preview en Java 21)java
// Compilar y ejecutar con --enable-preview --release 21recordPanel(Usuario usuario, List<Tarea> tareas, Estadisticas stats) {}
PanelcargarPanel(long usuarioId)throws InterruptedException, ExecutionException {
try (varscope=newStructuredTaskScope.ShutdownOnFailure()) {
Subtask<Usuario> usuario= scope.fork(() -> usuarios.buscar(usuarioId));
Subtask<List<Tarea>> tareas= scope.fork(() -> tareasRepo.deUsuario(usuarioId));
Subtask<Estadisticas> stats= scope.fork(() -> analitica.de(usuarioId));
scope.join() // espera a las tres...
.throwIfFailed(); // ...y si una falló, cancela el resto y propaga el errorreturnnewPanel(usuario.get(), tareas.get(), stats.get());
} // al salir del bloque no queda ningún hilo vivo
}
// Equivalente estable en Java 21 sin previewtry (varex= Executors.newVirtualThreadPerTaskExecutor()) {
varu= ex.submit(() -> usuarios.buscar(usuarioId));
vart= ex.submit(() -> tareasRepo.deUsuario(usuarioId));
returnnewPanel(u.get(), t.get(), analitica.de(usuarioId));
}
Cómo funciona por dentro
Dos APIs complementan a los hilos virtuales y en Java 21 están en preview (necesitan --enable-preview). Scoped Values (JEP 446) es una alternativa a ThreadLocal para pasar datos de contexto (usuario, id de traza) a lo largo de una llamada: el valor es inmutable, solo existe dentro de un ámbito delimitado y se hereda de forma barata a los subprocesos. Se publicó como definitiva en Java 25.
Structured Concurrency (JEP 453) trata un grupo de tareas concurrentes como una unidad: si lanzas tres consultas en paralelo dentro de un StructuredTaskScope, el ámbito no se cierra hasta que terminan todas, y si una falla puedes cancelar las demás automáticamente. Evita hilos huérfanos y fugas. Su API ha cambiado entre versiones y sigue en preview.
En producción con Java 21 lo prudente es conocerlas y no depender de ellas. Mientras tanto, CompletableFuture o un executor de hilos virtuales cubren los casos de paralelismo de E/S.
Prepara el camino para la concurrencia estructurada, donde tareas padre coordinan el ciclo de vida de múltiples tareas hijas concurrentes.
Almacenar objetos gigantescos en ThreadLocal cuando se usan millones de hilos virtuales.
Preview en 21, evolución en curso en 22 y 23.
Se conecta conThreadLocalConcurrencia EstructuradaGarbage Collector
Cuatro bloques cronometrados. No pases al siguiente sin tener el resultado esperado.
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.
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.
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.
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
Activa spring.threads.virtual.enabled=true con Java 21.
Busca bloques synchronized en la reserva de stock y sustitúyelos por ReentrantLock si hacen I/O dentro.
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ística
Java 17 LTS
Java 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)