Virtual Threads en Java 21 vs ExecutorService
Cómo funcionan los Virtual Threads por dentro, en qué se diferencian de ExecutorService y cuándo migrar tu servicio Spring Boot. Con benchmarks y guía.
Resumen ejecutivo
Si solo tienes noventa segundos, esto es todo lo que necesitas saber para decidir.
Los virtual threads hacen una sola cosa: separan la unidad de concurrencia —un hilo de ejecución— de la unidad de planificación del sistema operativo. Ese único cambio invalida veinte años de buenas prácticas acumuladas.
| La regla antigua | Por qué existía | Qué la sustituye |
|---|---|---|
| «Los hilos son caros, agrúpalos en pools» | 1 MB de stack reservado y un objeto del kernel por hilo | Los hilos son baratos: uno por tarea |
«Dimensiona el pool con cores × (1 + espera/servicio)» | El pool era el recurso escaso | El pool desaparece: dimensiona el recurso de destino |
| «Bloquear es pecado, hazlo reactivo» | Un hilo bloqueado desperdiciaba un hilo del sistema operativo | Bloquear vuelve a estar bien: cuesta un objeto en heap |
«Usa ThreadLocal para el contexto de petición» | Los hilos eran pocos y longevos | Sigue funcionando, pero no escala a millones → Scoped Values |
«Envuélvelo todo en CompletableFuture» | Era la única forma de no bloquear | Código secuencial + Structured Concurrency |
Las tres frases que importan
- Los virtual threads hacen que la entrada/salida bloqueante escale. No aportan nada al trabajo intensivo de CPU.
- No aceleran una petición aislada. Eliminan el tiempo de cola, que es lo que realmente destroza tu p99.
- El cuello de botella no desaparece: se mueve. Pasa de tu thread pool a tu pool de conexiones, a tu base de datos o al servicio de al lado.
La concurrencia es el cuello de botella que nadie dibujó en el diagrama
Dibuja la arquitectura de cualquier backend moderno y saldrán cajas. Ahora fíjate en lo que hacen esas cajas de verdad: esperar.
Un endpoint REST típico en un sistema distribuido dedica entre el 90 % y el 99 % de su tiempo de reloj a esperar: un paquete TCP, un viaje de ida y vuelta a la base de datos, un servicio que a su vez está esperando otra cosa. El trabajo real de CPU —deserializar JSON, aplicar una regla de negocio, serializar JSON— rara vez pasa de unos pocos milisegundos dentro de una petición de 200 ms.
Anatomía de una petición REST de 210 ms
Ejemplo ilustrativoDesglose representativo de un endpoint de checkout en una arquitectura de microservicios. En rojo, tiempo de espera; en azul, CPU real.
Ese es todo el problema en una imagen. Y durante dos décadas la respuesta del
ecosistema Java fue: como los hilos son caros y están casi siempre ociosos,
compártelos. De ahí los thread pools. De ahí ExecutorService. Y de ahí
—cuando los pools dejaron de ser suficientes— todo el movimiento reactivo, que pidió
a los desarrolladores renunciar a la pila de llamadas, al depurador y a las trazas de
error a cambio de escalabilidad.
Por qué esto importa cada año más
| Tendencia | Efecto sobre la concurrencia |
|---|---|
| Microservicios | Una acción de usuario se convierte en 5–30 saltos de red. Cada salto es una espera bloqueante nueva. |
| Cloud y Kubernetes | Los pods son pequeños: 1–2 vCPU y 512 MB–2 GB. Un stack de 1 MB por hilo ya es una fracción significativa del pod. |
| Kafka y RabbitMQ | Los consumidores son longevos, están casi siempre ociosos y quieres miles. |
| APIs de IA y LLM | Las llamadas tardan de 2 a 30 segundos. Un pool de 200 hilos sirve entre 7 y 100 peticiones por segundo. Y punto. |
| Server-Sent Events y WebSockets | Decenas de miles de conexiones ociosas el 99,99 % del tiempo. |
| Presión FinOps | Cada hilo ocioso es memoria que alquilas por horas. |
La fila de los LLM merece una pausa. Si tu servicio llama a un modelo que tarda 10 segundos en responder, un pool clásico de 200 hilos te da un techo duro de 20 peticiones por segundo, por muchos núcleos que compres. Eso es la ley de Little, y no hay CPU que lo arregle. Los virtual threads sí.
Qué vamos a recorrer
Empezamos en el silicio y acabamos en un checklist de migración a producción. Sin saltarse ningún paso y abriendo todas las abstracciones por dentro.
Recorrido en siete etapas: silicio, hilo del sistema operativo, platform thread, ExecutorService, Project Loom, virtual thread y Spring Boot.
- Silicio core · HT · context switch
- Hilo del SO stack · scheduler
- Platform Thread mapeo 1:1
- ExecutorService el pool como apaño
- Project Loom continuations
- Virtual Thread mapeo M:N
- Spring Boot producción
Si trabajas con microservicios Java con Kafka, la mayor parte de lo que sigue se aplica directamente a tus consumidores y a tus clientes HTTP.
Capítulo 01. ¿Qué es realmente un Thread?
No se puede razonar sobre virtual threads sin un modelo mental preciso de aquello que vienen a sustituir. Casi todo desarrollador sénior tiene un modelo útil de lo que es un hilo; muy pocos tienen uno exacto.
La capa física: CPU, core e HyperThreading
Diagrama por capas: Paquete de CPU (Core físico 0, Core físico 1); SMT / HyperThreading (hilo HW 0, hilo HW 1, hilo HW 2, hilo HW 3); availableProcessors() (4)
| Término | Qué es | Cuántas cosas ejecuta en el mismo instante |
|---|---|---|
| Core físico | Un motor de ejecución completo: ALU, FPU, caché L1/L2 y pipeline | 1 flujo de instrucciones |
| Hilo hardware (SMT) | Un juego de registros y contador de programa duplicado que comparte las unidades de ejecución de un core | 2 flujos comparten 1 motor → ~15–30 % de throughput extra, no el doble |
availableProcessors() | Lo que ve la JVM: hilos hardware, o tu cuota de CPU del cgroup en Kubernetes | El techo real de paralelismo |
// Registra esto en cada arranque.
// Ha evitado más incidentes que cualquier dashboard.
Runtime rt = Runtime.getRuntime();
log.info("availableProcessors={} maxHeap={}MB",
rt.availableProcessors(),
rt.maxMemory() / 1_048_576); El paralelismo está limitado por el hardware. La concurrencia no. Esa distinción es el sentido entero de este artículo, y Rob Pike la formuló mejor que nadie: la concurrencia va de gestionar muchas cosas a la vez; el paralelismo va de hacer muchas cosas a la vez. Los virtual threads dan concurrencia ilimitada sobre paralelismo limitado.
Proceso frente a hilo
Diagrama por capas: Compartido por todos los hilos (Heap, Metaspace, Code cache, Descriptores); Privado de cada hilo (Hilo 1, Hilo 2, Hilo N)
Stack frente a heap: dónde vive realmente un hilo
| Stack | Heap | |
|---|---|---|
| Propietario | Un hilo, en exclusiva | Todo el proceso |
| Contenido | Frames: variables locales, pila de operandos, direcciones de retorno | Objetos y arrays |
| Asignación | LIFO, mover un puntero — gratis | Gestionada por el GC, con coste de reserva y recolección |
| Tamaño por defecto (HotSpot x64) | ~1 MB por hilo (-Xss) | -Xmx, típicamente GB |
| Crecimiento | Fijo en la creación → StackOverflowError | Elástico → OutOfMemoryError |
| Quién puede moverlo | Nadie: es memoria del SO anclada | El GC puede reubicar objetos |
La última fila es la bisagra de todo Project Loom. Un stack nativo no se puede mover ni pausar y guardar en otro sitio, porque contiene punteros crudos a sí mismo y pertenece al kernel. Justamente por eso un hilo del sistema operativo no se puede «guardar en un cajón» mientras espera.
Comparación entre Platform Thread y Virtual Thread
Platform Thread
- Stack nativo de 1 MB reservado
- Propiedad del kernel
- No se puede reubicar
- No se puede aparcar mientras espera
Virtual Thread
- Objetos
StackChunkde ~1 KB, que crecen bajo demanda - Vive en el heap de Java
- Se copia dentro y fuera del stack nativo
- Lo recolecta el recolector de basura
Este es el único cambio de diseño del que se derivan todos los demás. Todo lo que sigue en el artículo es una consecuencia de mover el stack al heap.
El scheduler y el context switch
El scheduler del sistema operativo reparte una porción de tiempo a cada hilo ejecutable. Cuando esa porción se agota, o cuando el hilo se bloquea en una operación de entrada/salida, se produce un context switch.
Secuencia de 6 pasos entre Hilo A, Kernel, Hilo B
- Hilo A
- Kernel
- Hilo B
- Hilo A ⟶ Kernel
Cambio de modo usuario → kernel, unos 100–300 ns
- Kernel ⟶ Kernel
- Kernel ⟶ Kernel
Estado BLOQUEADO
- Kernel ⟶ Kernel
Invalida o etiqueta la TLB
- Kernel ⟶ Hilo B
Coste total ~1–10 µs, más un coste oculto que casi nunca se mide: B arranca con las cachés L1 y L2 frías.
- Kernel ⇠ Hilo A
Recuperar esa localidad puede costar decenas de microsegundos de pipeline parado. Por eso un runtime que hace bailar 10.000 hilos del sistema operativo sobre 8 cores rinde muchísimo peor de lo que predice un modelo ingenuo.
| Operación | Coste típico | Relativo |
|---|---|---|
| Acierto en caché L1 | ~1 ns | ×1 |
| Acceso a memoria principal | ~100 ns | ×100 |
| Mount/unmount de virtual thread | ~200–500 ns | ×300 |
| Context switch del SO (caché caliente) | ~1–3 µs | ×2.000 |
| Context switch del SO (caché fría) | ~10–50 µs | ×20.000 |
new Thread().start() | ~50–100 µs | ×70.000 |
Thread.ofVirtual().start() | ~1 µs | ×1.000 |
| Ida y vuelta de red en el mismo CPD | ~500 µs | ×500.000 |
| Ida y vuelta entre regiones | ~100 ms | ×10⁸ |
Capítulo 02. Threads tradicionales en Java
La API de concurrencia de Java evolucionó en tres eras claramente identificables. Saber a qué era pertenece un trozo de código te dice casi todo sobre cómo va a fallar.
Línea temporal: Java 1.0, <code>Thread</code>, <code>Runnable</code>, <code>synchronized</code>, <code>wait</code>/<code>notify</code>; Java 1.4, Selectores NIO; Java 5, <code>java.util.concurrent</code>; Java 7, <code>ForkJoinPool</code> y robo de trabajo; Java 8, <code>CompletableFuture</code> y streams paralelos; Java 9, Flow API · Reactive Streams; 2017, Se anuncia Project Loom; Java 19–20, Virtual Threads en preview; Java 21 LTS, JEP 444 definitivo; Java 24, JEP 491: <code>synchronized</code> deja de provocar pinning; Java 25 LTS, Scoped Values definitivo
- Java 1.0
Thread,Runnable,synchronized,wait/notify - Java 1.4 Selectores NIO
- Java 5
java.util.concurrentExecutorService, Callable, Future, ConcurrentHashMap - Java 7
ForkJoinPooly robo de trabajo - Java 8
CompletableFuturey streams paralelos - Java 9 Flow API · Reactive Streams
- 2017 Se anuncia Project Loom
- Java 19–20 Virtual Threads en preview
- Java 21 LTS JEP 444 definitivo
- Java 24 JEP 491:
synchronizeddeja de provocar pinning - Java 25 LTS Scoped Values definitivo
Thread y Runnable: el metal desnudo
// Era 1. El primer programa concurrente de todo desarrollador Java.
Thread t = new Thread(() -> {
log.info("Ejecutando en {}", Thread.currentThread().getName());
});
t.start();
t.join(); Runnable es una tarea sin resultado y sin excepciones comprobadas. Esto
último es un defecto de diseño real: run() no puede lanzar, así que la
gestión de errores hay que sacarla de contrabando por canales laterales.
| Problema | Consecuencia |
|---|---|
| No devuelve valor | Necesitas estado mutable compartido para recuperar el resultado |
| No propaga excepciones | Los errores se desvanecen en el UncaughtExceptionHandler |
| No hay control de ciclo de vida | Nada limita cuántos creas |
| Coste de creación ~50–100 µs | Inviable por petición |
| Creación sin límite | OutOfMemoryError: unable to create native thread |
Callable y Future: resultados y errores
Callable<Factura> tarea = () -> facturaService.generar(pedidoId); // puede lanzar
Future<Factura> future = executor.submit(tarea);
// Bloquea el hilo actual y envuelve cualquier error en ExecutionException.
Factura factura = future.get(); Future fue un paso adelante y, a la vez, el origen de una década de
dolor. get() bloquea: quemas un hilo esperando a otro
hilo. No hay composición, de modo que no puedes decir «cuando termine esto, haz
aquello» sin bloquear. Y cancel(true) solo interrumpe, no garantiza nada.
ExecutorService: la abstracción que sí ha aguantado
Desacopla el envío de tareas de su ejecución. Es una de las mejores APIs del JDK y —esto es lo importante— sobrevive intacta a la era de los virtual threads.
Diagrama de flujo: Peticiones → BlockingQueue → 4 workers → RejectedExecutionHandler
- Peticiones 10.000 entrando
- BlockingQueue aquí es donde muere tu p99
- 4 workers platform threads reutilizados
- RejectedExecutionHandler abortar, descartar o ejecutar en el llamante
// Desde Java 19, ExecutorService implementa AutoCloseable:
// close() hace un apagado ordenado y espera a la terminación.
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
List<Future<Informe>> futures = regiones.stream()
.map(region -> executor.submit(() -> informeService.generar(region)))
.toList();
for (Future<Informe> f : futures) {
resultados.add(f.get());
}
} // close() → shutdown() + awaitTermination(Long.MAX_VALUE) ThreadPoolExecutor: los siete parámetros que nadie configura bien
Todas las factorías Executors.* son un ThreadPoolExecutor
preconfigurado. Entender el constructor marca la diferencia entre un ingeniero y
alguien copiando de Stack Overflow.
new ThreadPoolExecutor(
8, // corePoolSize se mantienen vivos aunque estén ociosos
32, // maximumPoolSize solo se alcanza si la cola está LLENA
60L, TimeUnit.SECONDS, // keepAliveTime para los hilos por encima del core
new ArrayBlockingQueue<>(500), // workQueue acotada: ver la advertencia
// Desde Java 21 no hace falta Guava para nombrar los hilos:
// Thread.ofPlatform() devuelve una ThreadFactory del propio JDK.
Thread.ofPlatform().name("pricing-", 0).factory(),
new ThreadPoolExecutor.CallerRunsPolicy() // backpressure natural
); | Política | Comportamiento | Cuándo usarla |
|---|---|---|
AbortPolicy (por defecto) | Lanza RejectedExecutionException | APIs que deben fallar rápido con un 503 |
CallerRunsPolicy | El hilo que envía ejecuta la tarea | Mejor opción por defecto: backpressure real y automático |
DiscardPolicy | Descarta en silencio | Casi nunca: pérdida silenciosa de datos |
DiscardOldestPolicy | Descarta la tarea más antigua | Métricas en vivo donde solo importa el «ahora» |
Catálogo de factorías
| Factoría | Configuración real | Riesgo real |
|---|---|---|
newFixedThreadPool(n) | core = max = n, cola sin límite | OOM por crecimiento de la cola; techo duro de throughput |
newCachedThreadPool() | core = 0, max = MAX_VALUE, SynchronousQueue | Creación ilimitada de hilos → OOM en un pico de tráfico |
newSingleThreadExecutor() | 1 hilo, cola sin límite | Orden serie garantizado; una tarea lenta bloquea todo |
newScheduledThreadPool(n) | Tareas diferidas o periódicas | Una excepción no capturada mata en silencio la planificación |
newWorkStealingPool() | ForkJoinPool, paralelismo = cores | Diseñado para CPU; inadecuado para E/S bloqueante |
newVirtualThreadPerTaskExecutor() | Java 21+, un virtual thread por tarea | No es un pool. Sin cola ni límite: lo acotas tú |
ForkJoinPool: divide, vencerás y roba
Es otra bestia: apunta a descomposición recursiva intensiva en CPU, y cada worker tiene su propia deque con robo de trabajo. El dueño saca por la cabeza y el ladrón roba por la cola, lo que evita contención y mejora la localidad.
// Uso de manual: divide y vencerás intensivo en CPU.
class SumaTask extends RecursiveTask<Long> {
private static final int UMBRAL = 10_000;
private final long[] datos;
private final int ini, fin;
SumaTask(long[] datos, int ini, int fin) {
this.datos = datos; this.ini = ini; this.fin = fin;
}
@Override
protected Long compute() {
if (fin - ini <= UMBRAL) {
long suma = 0;
for (int i = ini; i < fin; i++) suma += datos[i];
return suma;
}
int medio = (ini + fin) >>> 1;
SumaTask izq = new SumaTask(datos, ini, medio);
izq.fork(); // encola en la deque del worker
long der = new SumaTask(datos, medio, fin).compute(); // ejecuta en línea: ahorra una tarea
return der + izq.join();
}
} Balance de la era del pooling
| Lo que ExecutorService acertó | Lo que nunca pudo arreglar |
|---|---|
| Desacoplar el envío de la ejecución | La concurrencia queda limitada por el tamaño del pool |
| Reutilizar hilos caros | El tiempo de cola domina el p99 |
| Acotar el uso de recursos | Dimensionar exige conocer ratios de espera/servicio que no tienes |
| API estándar y bien conocida | El modelo hilo-por-petición no pasa de unos pocos miles |
| Excelente para trabajo de CPU | Los pools anidados provocan deadlocks con facilidad pasmosa |
El último punto merece detalle porque muerde de verdad: una tarea del pool A que se bloquea esperando a otra tarea del pool A provoca un deadlock en cuanto todos los workers están igual de bloqueados. Todo incidente del tipo «el servicio se congeló pero la CPU estaba al 0 %» es este bug.
Capítulo 03. ¿Por qué los Platform Threads son caros?
Un platform thread es un envoltorio 1:1 sobre un hilo del sistema operativo. new Thread().start() acaba ejecutando una syscall clone(): cada hilo Java arrastra el peso completo de una entidad planificable del kernel.
Diagrama por capas: JVM (java.lang.Thread, java.lang.Thread, java.lang.Thread); Sistema operativo (task_struct, task_struct, task_struct); Hardware (core 0, core 1)
Coste 1 · Memoria
| Concepto | Coste por hilo | 10.000 hilos |
|---|---|---|
Stack de usuario (virtual reservado, -Xss x64) | 1 MB | ~10 GB virtuales |
| Stack realmente comprometido (profundidad típica en web) | 40–200 KB | 0,4–2 GB residentes |
Stack de kernel + task_struct | 8–16 KB | 80–160 MB no paginables |
Objeto Thread y raíces del GC | ~1 KB | ~10 MB |
| Impacto realista en RSS | ~50–220 KB | ~0,5–2,2 GB |
La memoria virtual reservada suele ser inofensiva porque las páginas se comprometen de forma perezosa. Pero deja de serlo en dos situaciones muy comunes: contenedores con límites duros de memoria, y JVM donde la región reservada choca con el dimensionado del heap. Y la porción comprometida no es virtual: es RAM real que estás pagando.
Coste 2 · Tiempo de creación
// Órdenes de magnitud en un servidor x86-64 Linux moderno:
new Thread(tarea).start(); // ~50–100 µs syscall clone + contabilidad del kernel
Thread.ofVirtual().start(tarea); // ~1 µs una reserva en el heap
pool.submit(tarea); // ~0,1–1 µs encolar
// pero lo pagas en tiempo de cola Crear 10.000 platform threads cuesta del orden de medio segundo a un segundo de puro overhead. Crear 10.000 virtual threads cuesta unos 10 ms.
Coste 3 · Context switching a escala
Throughput frente a número de hilos
Ejemplo ilustrativo8 cores · carga de E/S bloqueante de 100 ms · la forma de la curva, no cifras de laboratorio
Coste 4 · El techo del sistema operativo
# Límites reales y duros con los que te chocarás antes de lo que crees
ulimit -u # máximo de procesos/hilos por usuario (a menudo 4096–63000)
cat /proc/sys/kernel/threads-max
cat /proc/sys/vm/max_map_count # cada stack de hilo = un mapeo de memoria
# por defecto 65530: te capa muy por debajo
# de "un millón de hilos" por mucha RAM que tengas La consecuencia: la ley de Little es tu techo
Con L la concurrencia en vuelo, λ el throughput y
W la latencia, se cumple siempre que L = λ × W.
Despejado para capacity planning: λ máximo = tamaño del pool / latencia
media.
| Tamaño del pool | Latencia media | Throughput máximo | Realidad |
|---|---|---|---|
| 200 | 50 ms | 4.000 req/s | Cómodo |
| 200 | 200 ms | 1.000 req/s | Microservicio típico |
| 200 | 2 s | 100 req/s | Un servicio lento aguas abajo |
| 200 | 10 s | 20 req/s | Una llamada a una API de LLM |
| 10.000 (virtuales) | 10 s | 1.000 req/s | Mismo hardware. Misma latencia. |
El último par de filas es todo el argumento comercial de los virtual threads. La misma máquina, el mismo servicio de destino, 50 veces más throughput, porque la restricción nunca fue la CPU: era cuántos hilos te podías permitir tener esperando.
Capítulo 04. Nace Project Loom
La ironía histórica es que Java ya tenía esto en 1996, lo tiró en 1998 por una razón excelente, y ha tardado veinticinco años en recuperarlo sin renunciar a lo que ganó.
Java 1.0 venía con green threads: hilos de usuario multiplexados por la JVM sobre un único hilo del sistema operativo. Eran un modelo M:1, el antepasado directo de lo que hoy llamamos virtual threads.
Se eliminaron. Las razones eran buenas para 1998: los green threads no podían aprovechar varias CPU, una sola syscall bloqueante congelaba toda la VM y la industria se movía hacia hardware SMP. Pero esa decisión incorporó una premisa que caducó sin avisar: que el número de operaciones concurrentes en un servidor se mantendría en el mismo orden de magnitud que el número de núcleos. Internet tenía otros planes.
Un viaje de ida y vuelta de veintinueve años
Línea temporal: 1996, Java 1.0 con <strong>green threads</strong> (M:1); 1998, Java 1.2 elimina los green threads; 2002, Problema C10K; 2004, Java 5 y <code>java.util.concurrent</code>; 2009, Node.js populariza el event loop; 2011, Java 7 con ForkJoinPool · Go 1.0 lanza las goroutines; 2013, Reactive Streams y RxJava; 2014, Java 8: <code>CompletableFuture</code> y lambdas; 2017, Se anuncia <strong>Project Loom</strong>; 2020, Loom se integra en la línea principal del JDK; 2022, Java 19: JEP 425, Virtual Threads en preview; 2023, Java 21 LTS: <strong>JEP 444 definitivo</strong>; 2025, Java 24: JEP 491 elimina el pinning por <code>synchronized</code>; 2025, Java 25 LTS: JEP 506 Scoped Values definitivo
- 1996 Java 1.0 con green threads (M:1) Hilos de usuario multiplexados por la JVM: el antepasado directo de los virtual threads
- 1998 Java 1.2 elimina los green threads Ganan los hilos nativos 1:1, y era la decisión correcta para el hardware SMP de la época
- 2002 Problema C10K Los servidores deben aguantar diez mil conexiones simultáneas
- 2004 Java 5 y
java.util.concurrentEl pooling se convierte en doctrina - 2009 Node.js populariza el event loop Se instala la idea de que bloquear es pecado
- 2011 Java 7 con ForkJoinPool · Go 1.0 lanza las goroutines
- 2013 Reactive Streams y RxJava La era de los callbacks
- 2014 Java 8:
CompletableFuturey lambdas - 2017 Se anuncia Project Loom Ron Pressler, en Oracle, con el equipo de librerías del núcleo de OpenJDK
- 2020 Loom se integra en la línea principal del JDK
- 2022 Java 19: JEP 425, Virtual Threads en preview
- 2023 Java 21 LTS: JEP 444 definitivo
- 2025 Java 24: JEP 491 elimina el pinning por
synchronized - 2025 Java 25 LTS: JEP 506 Scoped Values definitivo
¿Qué problema estaba resolviendo Oracle realmente?
Hacer que sea sencillo escribir, depurar, perfilar y mantener aplicaciones concurrentes que cumplan los requisitos de hoy.
Fíjate en lo que no aparece en el objetivo declarado de Loom: «hacer Java más rápido». Su tesis era que Java había perdido algo valioso, y que la pérdida era pedagógica y operativa antes que numérica.
| Lo que Java tenía | Lo que se llevó async/reactive | Por qué importaba |
|---|---|---|
| El hilo como unidad de concurrencia | Una tarea fragmentada en callbacks | Podías leer el código de arriba abajo |
| Trazas de pila con sentido | at reactor.core.publisher.MonoFlatMap$… ×80 frames | Depurar incidentes en producción |
| Depuración paso a paso | Breakpoints en una lambda que se ejecuta después y en otro sitio | Incorporación de gente e investigación |
try/catch/finally | onErrorResume(), doFinally() | Gestión de errores integrada en el lenguaje |
| Profilers que atribuyen trabajo a una petición | Trabajo atribuido a reactor-http-nio-3 | Análisis de rendimiento |
Contexto en ThreadLocal | Objetos de contexto propagados a mano | Observabilidad y seguridad |
La intuición de Loom fue que el hilo nunca fue la abstracción equivocada: era una implementación demasiado cara de ella. Así que: conserva la abstracción, cambia la implementación.
El cambio de paradigma
Comparación entre La era del apaño y La era Loom
La era del apaño
- Los hilos son caros → agrúpalos en pools
- Los pools son limitados → nunca bloquees uno
- → Escribe código async o reactivo
- → Pierde trazas, depuración,
try/catch,ThreadLocaly profiling - → Reconstrúyelo todo con Context, hooks y adaptadores de MDC
La era Loom
- Los hilos son baratos → uno por tarea
- Bloquear está bien
- → Escribe código secuencial normal
- → Conserva trazas, depuración,
try/catchy profiling - → Acota la concurrencia donde está la restricción real
El cambio no añade una API nueva que aprender: retira la necesidad de rodear con apaños una que ya existía.
Las tres entregas de Loom
Loom nunca fue solo virtual threads. Es un programa en tres partes y, con Java 25, las tres están sobre la mesa.
| Entrega | Qué resuelve | Estado en Java 25 |
|---|---|---|
| Virtual Threads (JEP 444) | Hilos baratos, de forma que hilo-por-tarea escala | Definitivo desde Java 21 |
| Structured Concurrency (JEP 505) | Vidas de tareas atadas a un ámbito léxico; sin tareas huérfanas | Quinta preview · API rediseñada |
| Scoped Values (JEP 506) | Contexto inmutable y acotado: el sustituto de ThreadLocal | Definitivo en Java 25 |
El orden importa. Los virtual threads abaratan tener millones de hilos; la
concurrencia estructurada evita que esos millones se conviertan en un caos
ingobernable; y los scoped values les dan contexto sin pagar el impuesto de memoria
por hilo de ThreadLocal. Son un mismo diseño entregado en tres plazos.
Capítulo 05. Virtual Threads por dentro
Este es el capítulo que separa a quien usa virtual threads de quien sabe depurarlos a las tres de la mañana. Y es el que preguntan en las entrevistas.
El modelo mental en un diagrama
Diagrama por capas: Heap de Java (VT #1, VT #2, VT #3, VT #1.000.000); Scheduler (paralelismo = availableProcessors()); Carrier threads (carrier-0, carrier-1, carrier-2, carrier-3); Hardware (core 0, core 1, core 2, core 3)
Continuations: la primitiva que hay debajo de todo
Los virtual threads se construyen sobre jdk.internal.vm.Continuation, una
API interna que le da a la JVM la capacidad de capturar y reanudar el estado de
ejecución de una pila de llamadas.
// jdk.internal.vm.Continuation — API INTERNA.
// Se muestra para explicar el mecanismo, nunca para usarla en aplicación.
ContinuationScope scope = new ContinuationScope("demo");
Continuation continuation = new Continuation(scope, () -> {
System.out.println("A");
Continuation.yield(scope); // freeze: copia la pila al heap y vuelve al llamante
System.out.println("B");
Continuation.yield(scope); // freeze otra vez
System.out.println("C");
});
continuation.run(); // imprime A y vuelve
continuation.run(); // imprime B y vuelve
continuation.run(); // imprime C
System.out.println(continuation.isDone()); // true
Dos operaciones de la JVM lo hacen posible. Freeze copia los frames
vivos del stack nativo a objetos StackChunk en el heap, con un coste de
unos 200–500 ns para profundidades típicas. Thaw los copia de vuelta,
y lo hace de forma perezosa: si un hilo tiene 60 frames pero solo necesita los
3 superiores para avanzar, la JVM copia 3. Por eso las pilas profundas no destrozan el
rendimiento como cabría temer.
Mount y unmount: el bucle central
Secuencia de 8 pasos entre Tu código, Virtual Thread, Scheduler, Carrier, Kernel
- Tu código
- Virtual Thread
- Scheduler
- Carrier
- Kernel
- Tu código ⟶ Virtual Thread
- Virtual Thread ⟶ Scheduler
- Scheduler ⟶ Carrier
Thaw: descongela la pila del heap al stack nativo
- Virtual Thread ⟶ Kernel
- Virtual Thread ⟶ Carrier
Freeze: congela el stack nativo en objetos StackChunk del heap
A partir de aquí el virtual thread está APARCADO y consume cero hilos del sistema operativo. Solo es un grafo de objetos en el heap esperando un evento.
- Scheduler ⟶ Carrier
El carrier nunca se queda ocioso
- Kernel ⇠ Scheduler
- Scheduler ⟶ Carrier
El código continúa tras socket.read() con la misma pila
Ciclo de vida
Máquina de estados con 6 estados: NUEVO, EJECUTABLE, MONTADO, APARCADO, PINNED, TERMINADO
NUEVO
inicioCreado y todavía sin arrancar.
- start() EJECUTABLE
EJECUTABLE
Listo para ejecutarse, esperando que el scheduler le asigne un carrier.
- el scheduler asigna carrier MONTADO
MONTADO
Ejecutándose sobre un carrier thread real.
- Thread.yield() EJECUTABLE
- E/S, lock o sleep APARCADO
- bloqueo con frame nativo PINNED
- la tarea retorna TERMINADO
APARCADO
Esperando un evento externo, con la pila congelada en el heap.
- unpark: llega el evento EJECUTABLE
Cero carrier threads usados · ~1 KB en el heap · millones no son problema.
PINNED
Bloqueado sin poder desmontarse del carrier.
- el frame nativo retorna MONTADO
El carrier también se bloquea y el throughput se hunde. En Java ≤23 lo provocaba synchronized; desde Java 24, solo frames nativos.
TERMINADO
finLa tarea retornó o lanzó. El objeto queda listo para el recolector.
El scheduler
El scheduler por defecto es un ForkJoinPool dedicado en modo
FIFO, a diferencia del pool común, que es LIFO y está optimizado para tareas
recursivas. El FIFO importa: en cargas de servidor la equidad gana a la localidad,
porque quieres atender antes la petición más antigua, no la más reciente.
| Propiedad | Valor por defecto | Propiedad de sistema |
|---|---|---|
| Paralelismo (carriers ejecutando a la vez) | availableProcessors() | jdk.virtualThreadScheduler.parallelism |
| Tamaño máximo del pool de carriers | 256 | jdk.virtualThreadScheduler.maxPoolSize |
| Máximo del pool de unparker | 256 | jdk.unparker.maxPoolSize |
| Modo de planificación | FIFO con robo de trabajo | — |
| Expropiación (preemption) | Ninguna. Solo cooperativa | — |
// En una máquina de 4 cores esto congela TODA la actividad
// de virtual threads de la JVM: no hay expropiación que los desaloje.
for (int i = 0; i < 4; i++) {
Thread.ofVirtual().start(() -> {
long x = 0;
while (true) {
x += ThreadLocalRandom.current().nextInt(); // nunca cede
}
});
} Qué hace unmount y qué no
Esta es la tabla más útil del capítulo en el día a día. Es la que responde a la pregunta «¿por qué mi migración no ha mejorado nada?».
| Operación | ¿Hace unmount? | Notas |
|---|---|---|
| Se desmonta correctamente | ||
Thread.sleep() | Sí | Reimplementado sobre el temporizador de Loom |
Socket y ServerSocket | Sí | Reescrito internamente sobre NIO |
java.net.http.HttpClient síncrono | Sí | Totalmente compatible con Loom |
BlockingQueue, ReentrantLock, Semaphore | Sí | Todo java.util.concurrent |
CompletableFuture.get(), CountDownLatch.await() | Sí | |
Object.wait() | Sí, desde Java 24 | Antes provocaba pinning · JEP 491 |
synchronized que bloquea | Sí, desde Java 24 | Provoca pinning en Java 21–23 |
| No se desmonta | ||
E/S de ficheros (FileChannel, Files.read*) | No | Usa un carrier de compensación: la JVM añade hilos temporalmente |
Selector.select() | Parcialmente | |
| Frame nativo o JNI en la pila | Pinning | Inevitable: hay que cambiar la librería |
| Llamada de la Foreign Function API | Pinning | Igual |
| Inicializador de clase en curso | Pinning | Breve, rara vez problemático |
Pinning: el único modo de fallo que hay que entender sí o sí
Ocurre cuando un virtual thread se bloquea y no puede desmontarse, de forma que su carrier se bloquea también.
Diagrama de flujo: Bloqueo normal → Carrier libre
- Bloqueo normal el VT se bloquea leyendo un socket
- Carrier libre ejecuta otro virtual thread
Diagrama de flujo: Bloqueo con frame nativo → Carrier bloqueado → Inanición total
- Bloqueo con frame nativo el VT no puede desmontarse
- Carrier bloqueado no ejecuta nada más
- Inanición total ni los virtual threads sanos pueden ejecutarse
Java 21 hacía pinning con synchronized porque el monitor se registra en el
hilo del sistema operativo, no en el objeto Java. JEP 491, entregado en Java
24, reimplementó los monitores de objeto para que un virtual thread pueda
desmontarse mientras mantiene o espera uno.
| Causa de pinning | Java 21–23 | Java 24 / 25 |
|---|---|---|
Bloqueo dentro de synchronized | Pinning | Resuelto |
Object.wait() | Pinning | Resuelto |
| Entrada a un monitor con contención | Pinning | Resuelto |
| Método nativo o frame JNI | Pinning | Sigue igual |
| Foreign Function & Memory API | Pinning | Sigue igual |
| Inicializador de clase | Pinning | Sigue igual |
// Java 21–23: esto bloquea el carrier durante toda la llamada HTTP.
public synchronized Cotizacion obtener(String simbolo) {
return httpClient.send(request, ofString()); // carrier bloqueado
}
// Solución en Java 21–23: ReentrantLock sí se desmonta limpiamente.
private final ReentrantLock lock = new ReentrantLock();
public Cotizacion obtener(String simbolo) {
lock.lock();
try {
return httpClient.send(request, ofString()); // hace unmount
} finally {
lock.unlock();
}
}
// Java 24+: la versión original con synchronized vuelve a ser correcta. Cómo detectarlo
# jstack NO muestra virtual threads. Usa jcmd.
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
jcmd <pid> Thread.dump_to_file -format=text /tmp/threads.txt
# Detección de pinning · solo Java 21–23
# La propiedad se eliminó en Java 24, donde JEP 491 la dejó obsoleta.
java -Djdk.tracePinnedThreads=full -jar app.jar
# Portable y apto para producción en cualquier versión: JFR
jcmd <pid> JFR.start name=loom settings=profile duration=120s filename=loom.jfr
jfr print --events jdk.VirtualThreadPinned,jdk.VirtualThreadSubmitFailed loom.jfr | Evento JFR | Qué te dice |
|---|---|
jdk.VirtualThreadStart / End | Ritmo de creación y tiempo de vida |
jdk.VirtualThreadPinned | El que importa. Traza de pila de cada pinning por encima del umbral |
jdk.VirtualThreadSubmitFailed | El scheduler rechazó un envío: muy grave, investígalo ya |
Crear virtual threads: las cuatro APIs
// 1 · Dispara y olvida: la forma más directa
Thread vt = Thread.startVirtualThread(() -> log.info("hola"));
// 2 · Builder: nombrarlos importa muchísimo para la observabilidad
Thread.Builder.OfVirtual builder = Thread.ofVirtual().name("pedido-worker-", 0);
Thread t = builder.start(() -> procesarPedido(id)); // pedido-worker-0, -1, -2 …
// 3 · ThreadFactory: para APIs que esperan una
ThreadFactory factory = Thread.ofVirtual().name("ingesta-", 0).factory();
// 4 · Executor: la forma idiomática en producción
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
pedidos.forEach(pedido -> executor.submit(() -> procesar(pedido)));
} // close() espera a que termine cada tarea Diferencias de comportamiento
| Comportamiento | Platform Thread | Virtual Thread |
|---|---|---|
isDaemon() | Configurable | Siempre true: setDaemon(false) lanza excepción |
setPriority() | Se respeta a título orientativo | No hace nada: siempre NORM_PRIORITY |
| Nombre por defecto | Thread-N | Cadena vacía |
stop() / suspend() | Obsoletos o eliminados | Lanzan UnsupportedOperationException |
Aparece en jstack | Sí | No: usa jcmd Thread.dump_to_file |
| Pooling | Necesario | Antipatrón |
Huella de memoria
| Platform Thread | Virtual Thread | |
|---|---|---|
| Dónde vive el stack | Memoria nativa, fuera del heap | Heap de Java (objetos StackChunk) |
| Tamaño inicial | 1 MB reservado | ~200–800 bytes |
| Pila poco profunda (5–15 frames) | 40–100 KB comprometidos | ~1–2 KB |
| Pila profunda (Spring MVC, ~60 frames) | 150–250 KB comprometidos | ~8–16 KB |
| Lo libera | La terminación del hilo | El recolector de basura |
| Máximo realista con 4 GB de heap | ~2.000–8.000 | ~500.000+ |
Capítulo 06. ExecutorService vs Virtual Threads
Lo primero es matar la falsa dicotomía: ExecutorService no es lo contrario de los virtual threads. Executors.newVirtualThreadPerTaskExecutor() es un ExecutorService.
La comparación real es entre dos estrategias de ejecución que comparten una misma interfaz: agrupar hilos en un pool, o crear un hilo por tarea.
10.000 tareas de 100 ms de E/S sobre 8 vCPU
Comparación entre Pool fijo de 200 y Un virtual thread por tarea
Pool fijo de 200
- 10.000 peticiones entran, 9.800 esperan en la cola
- 198 de los 200 hilos están bloqueados en E/S sin hacer nada
- El recurso escaso son los hilos del pool
- Al desbordar: cola y después rechazo
- Makespan
- 5,0 s
- Latencia p99
- 4.900 ms
- Memoria de stacks
- ~220 MB
Un virtual thread por tarea
- 10.000 peticiones, 10.000 virtual threads, sin cola
- 9.992 aparcados en el heap; 8 carriers siempre ocupados
- El recurso escaso pasa a ser el servicio de destino
- Al desbordar: expansión sin límite, salvo que la acotes tú
- Makespan
- 0,18 s
- Latencia p99
- 130 ms
- Memoria de stacks
- ~25 MB
El número decisivo es el p99: 4.900 ms frente a 130 ms. No porque cada petición sea más rápida —cada una sigue costando sus 100 ms reales de E/S— sino porque 9.800 dejaron de hacer cola. El tiempo de cola es invisible en la traza de una petición aislada y dominante bajo carga.
La tabla comparativa completa
| Dimensión | ExecutorService (pool de platform) | Virtual Threads | Gana |
|---|---|---|---|
| Arquitectura | |||
| Modelo de hilos | 1:1 · hilo Java = hilo del SO | M:N · muchos VT sobre pocos carriers | VT |
| Quién planifica | El kernel del SO | La JVM (ForkJoinPool, FIFO) | VT |
| Dónde vive el stack | Memoria nativa, fuera del heap | Heap de Java, elástico | VT |
| Expropiación | Sí, el kernel puede desalojar | Solo cooperativa | Pool |
| Ciclo de vida | Longevo, reutilizado | Efímero, uno por tarea | — |
| Memoria | |||
| Por hilo ocioso | ~1 MB reservado / 50–200 KB comprometidos | ~1 KB | VT ~100× |
| 10.000 concurrentes | ~0,5–2 GB de RSS | ~10–40 MB de heap | VT |
| 1.000.000 concurrentes | Imposible | ~1–4 GB de heap | VT |
| Presión sobre el GC | Baja: los stacks no son objetos | Mayor: los stack chunks son objetos vivos | Pool |
| CPU | |||
| Coste de creación | ~50–100 µs | ~1 µs | VT ~70× |
| Coste de cambio | 1–10 µs (kernel) | 0,2–0,5 µs (usuario) | VT ~10× |
| Throughput con carga de CPU | Óptimo | Idéntico, sin ventaja | Pool |
| Syscalls por cambio | 1 o más | 0 | VT |
| Throughput y latencia | |||
| Techo con carga de E/S | tamaño_pool / latencia | Limitado por CPU o por el destino | VT 10–100× |
| Techo con carga de CPU | cores / tiempo_servicio | Idéntico | Empate |
| p50 sin contención | Igual | Igual | Empate |
| p99 bajo carga | Dominado por el tiempo de cola | Cercano al tiempo de servicio real | VT |
| Comportamiento ante un pico | La cola crece → timeouts en cascada | Se expande → puede saturar el destino | Depende |
| Escalabilidad | |||
| Máximo práctico de hilos | ~2.000–10.000 | 1.000.000+ | VT |
| Conexiones ociosas (SSE/WebSocket) | 1 MB cada una: inasumible | 1 KB cada una: trivial | VT |
| Escalado vertical | Malo por encima de unos miles | Excelente | VT |
| Complejidad y operación | |||
| Requiere dimensionar | Core, max, cola, política, keep-alive | Nada en el executor | VT |
| Riesgo de deadlock | Alto con pools anidados | Muy bajo | VT |
| Backpressure | Incorporado en la cola | Tienes que añadirlo | Pool |
| Protección ante sobrecarga | RejectedExecutionHandler | Manual (Semaphore) | Pool |
| Métricas por hilo | Cardinalidad acotada | Nunca: explosión de cardinalidad | Pool |
| Thread dumps | jstack | jcmd Thread.dump_to_file | Pool |
| Versión mínima de Java | Cualquiera | 21 | Pool |
| Rollback de la migración | — | Trivial: quitar el flag | VT |
| Madurez de la observabilidad | 20 años | 2–3 años | Pool |
Los virtual threads ganan con claridad en escalabilidad, memoria y latencia; los pools conservan una ventaja real en backpressure, previsibilidad y madurez de herramientas. Esa asimetría te dice exactamente qué tienes que construir alrededor de ellos.
La fórmula que ya no necesitas, y la que ahora sí
El dimensionado clásico de Brian Goetz decía que el número de hilos debía ser
N_cpu × U_cpu × (1 + W/C), donde W/C es la relación entre
tiempo de espera y tiempo de cómputo. Para un servicio con 100 ms de E/S y 2 ms de CPU
en 8 núcleos al 80 % de utilización objetivo salen 326 hilos: inasumible a 1 MB cada
uno en un pod pequeño, y erróneo mañana, cuando el servicio de destino se
ralentice y cambie el W/C.
// Ya no dimensionas hilos. Dimensionas el recurso que de verdad es escaso.
private final Semaphore permisosBd = new Semaphore(40); // = máximo de HikariCP
private final Semaphore permisosPagos = new Semaphore(100); // = límite del proveedor
public Recibo comprar(Pedido pedido) throws InterruptedException {
permisosBd.acquire();
try {
pedidoRepository.save(pedido);
} finally {
permisosBd.release();
}
permisosPagos.acquire();
try {
return pasarelaPago.cobrar(pedido);
} finally {
permisosPagos.release();
}
} Cuándo elegir cada uno
Cuatro preguntas para decidir la estrategia de ejecución
-
¿La tarea es intensiva en CPU?
- Sí Pool de platform threads dimensionado al número de núcleos, o ForkJoinPool El paralelismo real sigue limitado por los cores. Los virtual threads no aportan nada y quitan la expropiación.
- No · E/S Pasa a la pregunta 02
-
¿Necesitas orden estricto de ejecución o afinidad de hilo?
- Sí Executor de un solo hilo o pool particionado por clave Es la única forma de garantizar el orden, y sigue siendo válida.
- No Pasa a la pregunta 03
-
¿Estás en Java 21 o superior?
- No Pool dimensionado con CallerRunsPolicy, y planifica la actualización La actualización a Java 21 tiene ahora un caso de negocio cuantificable con la ley de Little.
- Sí Pasa a la pregunta 04
-
¿Hay frames nativos o JNI inevitables en la ruta caliente?
- Sí Mide el pinning antes de decidir Con JFR y jdk.VirtualThreadPinned. Si todos los carriers se bloquean, el throughput puede ser peor que con un pool.
- No Virtual threads, con un Semaphore por recurso de destino Es el caso mayoritario en servicios empresariales de petición y respuesta.
Capítulo 07. Benchmarks
Cada cifra de este capítulo lleva su origen declarado. Si un número no viene de una ejecución real, lo dice en la etiqueta y no en una nota al pie.
| Configuración | Definición |
|---|---|
platform-per-task | new Thread(tarea).start() por tarea, sin límite |
fixed-200 | Executors.newFixedThreadPool(200) |
cached | Executors.newCachedThreadPool() |
forkjoin | ForkJoinPool con paralelismo 7 |
virtual | Executors.newVirtualThreadPerTaskExecutor() |
El harness
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.*;
import java.util.function.Supplier;
public class LoomBenchmark {
/** Simula una llamada bloqueante: 100 ms de espera y 0,05 ms de CPU. */
static void tareaIoSimulada() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
long acc = 0;
for (int i = 0; i < 20_000; i++) acc += i * 31L;
if (acc == Long.MIN_VALUE) System.out.print(""); // evita eliminación de código muerto
}
record Resultado(String config, int tareas, Duration makespan) {
double throughput() {
return tareas / (makespan.toNanos() / 1_000_000_000.0);
}
}
static Resultado run(String nombre, int tareas,
Supplier<ExecutorService> factory) throws Exception {
System.gc();
Thread.sleep(500); // deja que la JVM se estabilice
Instant inicio = Instant.now();
CountDownLatch fin = new CountDownLatch(tareas);
try (ExecutorService executor = factory.get()) {
for (int i = 0; i < tareas; i++) {
executor.submit(() -> { tareaIoSimulada(); fin.countDown(); });
}
fin.await();
}
return new Resultado(nombre, tareas, Duration.between(inicio, Instant.now()));
}
public static void main(String[] args) throws Exception {
// Calentamiento: nunca publiques un número obtenido con la JVM fría.
run("warmup", 500, Executors::newVirtualThreadPerTaskExecutor);
for (int n : new int[]{100, 1_000, 10_000, 100_000}) {
imprimir(run("fixed-200", n, () -> Executors.newFixedThreadPool(200)));
imprimir(run("cached", n, Executors::newCachedThreadPool));
imprimir(run("forkjoin", n, () -> new ForkJoinPool(
Runtime.getRuntime().availableProcessors() - 1)));
imprimir(run("virtual", n, Executors::newVirtualThreadPerTaskExecutor));
}
}
static void imprimir(Resultado r) {
System.out.printf("%-18s n=%-7d %7d ms %9.0f req/s%n",
r.config(), r.tareas(), r.makespan().toMillis(), r.throughput());
}
} 100 tareas: nada está bajo presión
Makespan con 100 tareas
Modelo reproducible8 vCPU · 16 GB · OpenJDK 21 · G1GC · -Xmx4g · tarea = 100 ms de E/S bloqueante + 0,05 ms de CPU
Con 100 tareas todo salvo ForkJoinPool es indistinguible: estás midiendo el
sleep de 100 ms. Y esta es la fila más importante de todo el capítulo, por una
razón poco glamurosa: la mayoría de servicios nunca sale de este régimen, y para
ellos los virtual threads no cambian nada medible. Migrar un servicio cuyo pico
son 50 peticiones concurrentes es una decisión de mantenibilidad, no de rendimiento.
Dilo así en tu RFC.
1.000 tareas: aparece el techo del pool
Makespan con 1.000 tareas
Modelo reproducible8 vCPU · 16 GB · OpenJDK 21 · G1GC · -Xmx4g · tarea = 100 ms de E/S bloqueante + 0,05 ms de CPU
10.000 tareas: la divergencia
Makespan con 10.000 tareas
Modelo reproducible8 vCPU · 16 GB · OpenJDK 21 · G1GC · -Xmx4g · tarea = 100 ms de E/S bloqueante + 0,05 ms de CPU
fixed-200 no está roto: hace exactamente lo que configuraste. Pero su p99 es de 4,9 segundos para una carga cuyo tiempo de servicio real es de 100 ms, y cada milisegundo de esa diferencia es tiempo de cola. 100.000 tareas: el muro de memoria
Makespan con 100.000 tareas
Modelo reproducible8 vCPU · 16 GB · OpenJDK 21 · G1GC · -Xmx4g · tarea = 100 ms de E/S bloqueante + 0,05 ms de CPU
Resumen
| Tareas | fixed-200 | virtual | Mejora | RSS de virtual |
|---|---|---|---|---|
| 100 | 108 ms | 105 ms | 1,0× | 85 MB |
| 1.000 | 510 ms | 118 ms | 4,3× | 110 MB |
| 10.000 | 5.050 ms | 180 ms | 28× | 140 MB |
| 100.000 | 50.200 ms | 1.150 ms | 44× | 520 MB |
Qué significan realmente los números
- La mejora es de concurrencia, no de velocidad. Cada tarea sigue tardando 100 ms. Los virtual threads ganan porque ejecutan 100.000 a la vez en vez de 200. Si tu concurrencia pico está por debajo del tamaño de tu pool, medirás cero.
- El throughput del pool es una línea plana en
pool / latencia: 2.000 req/s a cualquier nivel de carga. Esa línea es la ley de Little y es el único número de capacidad que importa en un servicio con pool. - La memoria es la historia de verdad. 100.000 operaciones concurrentes en 520 MB de heap frente a físicamente imposible. Esto es lo que hace viables SSE, WebSockets, long-polling y llamadas lentas a modelos en pods pequeños.
- El cuello de botella se mueve, siempre. Todos los números anteriores asumen un destino infinitamente escalable. En la realidad, 87.000 req/s golpeando una base de datos con 40 conexiones significa 87.000 hilos haciendo cola en HikariCP en lugar de en tu executor. No eliminaste la cola: la moviste a un sitio que controlas peor.
Capítulo 08. Novedades de Java 25
Java 21 nos dio hilos baratos. Java 22–25 dedicó cuatro versiones a hacerlos seguros a escala. La distinción entre preview y definitivo determina qué puedes llevar a producción.
La tabla de estado que necesitas para tu RFC
| Característica | JEP | Estado en Java 25 | ¿Requiere --enable-preview? | ¿Producción? |
|---|---|---|---|---|
| Virtual Threads | 444 | Definitivo desde Java 21 | No | Sí |
| Scoped Values | 506 | Definitivo en Java 25 | No | Sí |
| Structured Concurrency | 505 | Quinta preview | Sí | No: la API sigue cambiando |
synchronized sin pinning | 491 | Definitivo desde Java 24 | No | Sí |
| Compact Object Headers | 519 | Definitivo en Java 25 | No | Sí (beneficio indirecto) |
| Profiling de CPU con JFR | 509 | Experimental (Linux) | Requiere flag | Solo diagnóstico |
Structured Concurrency (JEP 505)
El problema que resuelve es real y antiguo: cuando una tarea lanza subtareas, nada en el lenguaje ata sus tiempos de vida.
// Sin estructura. Cuatro formas independientes de tener fugas.
Future<Usuario> usuario = executor.submit(() -> usuarioService.buscar(id));
Future<Pedidos> pedidos = executor.submit(() -> pedidoService.buscarDe(id));
Future<Riesgo> riesgo = executor.submit(() -> riesgoService.calcular(id));
// Si usuarioService lanza, pedidos y riesgo siguen ejecutándose —quemando una
// conexión de base de datos cada uno— hasta terminar. Nadie los cancela.
// Si cancelan al llamante, los tres siguen vivos. Esto es una fuga de tareas.
return new Cuadro(usuario.get(), pedidos.get(), riesgo.get());
La concurrencia estructurada hace que la relación padre-hijo sea léxica,
exactamente igual que un bloque try.
// Java 25 · JEP 505 · requiere --enable-preview
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
Cuadro cargarCuadro(ClienteId id) throws Exception {
// awaitAllSuccessfulOrThrow es el Joiner correcto cuando las subtareas
// devuelven tipos distintos: espera a todas, propaga el primer fallo y no
// intenta agregar resultados heterogéneos en un único Stream.
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow())) {
Subtask<Usuario> usuario = scope.fork(() -> usuarioService.buscar(id));
Subtask<Pedidos> pedidos = scope.fork(() -> pedidoService.buscarDe(id));
Subtask<Riesgo> riesgo = scope.fork(() -> riesgoService.calcular(id));
scope.join(); // espera a todas; al PRIMER fallo cancela el resto
return new Cuadro(usuario.get(), pedidos.get(), riesgo.get());
} // close() garantiza que ninguna subtarea sobreviva a este bloque
} | Joiner | Semántica | Caso de uso |
|---|---|---|
awaitAllSuccessfulOrThrow() | Espera a todas y propaga el primer fallo. No agrega resultados: cada Subtask se consulta después | Fan-out con tipos distintos, que es el caso más común |
allSuccessfulOrThrow() | Como el anterior, pero join() devuelve un Stream de subtareas | Fan-out homogéneo: N tareas que devuelven el mismo tipo |
anySuccessfulResultOrThrow() | Devuelve el primer éxito y cancela el resto | Carreras entre réplicas o proveedores espejo |
awaitAll() | Espera a todas y nunca cancela | Recolección best-effort, donde un fallo parcial es aceptable |
Joiner propio | Tu política: quórum, primeros N… | Avanzado |
// Carrera entre dos proveedores: gana la primera respuesta, la otra se cancela.
Tipo mejorTipo(String par) throws Exception {
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<Tipo>anySuccessfulResultOrThrow())) {
scope.fork(() -> bloombergClient.tipo(par));
scope.fork(() -> reutersClient.tipo(par));
return scope.join(); // devuelve el ganador
}
} Comparación entre Sin estructura y Estructurada
Sin estructura
- La tarea padre lanza tres subtareas y pierde el control
- Si A falla, B y C siguen ejecutándose
- Si cancelan al padre, las tres sobreviven
- En el thread dump aparecen como hilos anónimos sueltos
Estructurada
- El ámbito es dueño de sus subtareas
- Si A falla, el ámbito cancela B y C
close()garantiza que nada sobreviva al bloque- En el dump JSON aparecen como un árbol con seis hijos
La concurrencia estructurada también arregla la observabilidad: una petición que se abrió en seis servicios se ve como un nodo con seis hijos, no como seis hilos perdidos en un volcado de 200.000 entradas.
Scoped Values (JEP 506): definitivo en Java 25
ThreadLocal tiene tres defectos que los virtual threads convierten de
molestias en problemas de arquitectura: es mutable desde cualquier sitio, su vida no
está acotada y se copia al heredarse. Los scoped values arreglan los tres.
// Java 25 · API definitiva, sin --enable-preview
public final class ContextoPeticion {
public static final ScopedValue<TenantId> TENANT = ScopedValue.newInstance();
public static final ScopedValue<TraceId> TRACE = ScopedValue.newInstance();
private ContextoPeticion() {}
}
// Se enlaza en el borde: un filtro de servlet, un listener, un job programado.
ScopedValue.where(ContextoPeticion.TENANT, tenantId)
.where(ContextoPeticion.TRACE, traceId)
.run(() -> atenderPeticion(request));
// Se lee en cualquier punto de la pila. Sin propagar parámetros, sin mutación.
public Factura generar(PedidoId id) {
TenantId tenant = ContextoPeticion.TENANT.get(); // garantizado enlazado aquí
return facturaRepository.buscarPara(tenant, id);
}
// Con valor de retorno.
Informe informe = ScopedValue.where(ContextoPeticion.TENANT, tenantId)
.call(() -> informeService.generar(criterios)); | ThreadLocal | ScopedValue | |
|---|---|---|
| Mutabilidad | Mutable desde cualquier sitio | Inmutable |
| Tiempo de vida | Hasta remove(), o para siempre | Exactamente el bloque que lo encierra |
| Herencia | Se copia por hilo hijo | Se comparte por referencia, sin copia |
| Memoria con 1M de hilos | 1.000.000 de copias | 1 instancia |
| Riesgo de fuga | Alto | Nulo por construcción |
| Acceso sin enlazar | Devuelve null en silencio | Lanza NoSuchElementException: falla en voz alta |
| Encaja con concurrencia estructurada | A duras penas | Diseñado para ella |
Las otras mejoras que importan
JEP 491, en Java 24, es estratégicamente el cambio más consecuente después de la propia JEP 444, porque eliminó el motivo por el que la mayoría de bases de código heredadas no podían adoptar virtual threads.
Compact Object Headers reduce las cabeceras de objeto de 12 o 16 bytes
a 8. No es una funcionalidad de Loom, pero con cientos de miles de objetos
StackChunk y Thread vivos las cabeceras pasan a ser una
porción medible del heap.
Las mejoras de JFR hacen que jdk.VirtualThreadPinned
informe de qué monitor causó el pinning, y que perfilar una JVM con 500.000 hilos sea
viable, algo que en Java 21 sencillamente no lo era.
De Java 21 a Java 25
Línea temporal: Java 21 LTS, <strong>Virtual Threads definitivos</strong>; Java 22–23, Iteran las previews; Java 24, <strong>JEP 491</strong>: <code>synchronized</code> deja de provocar pinning; Java 25 LTS, <strong>Scoped Values definitivos</strong>
- Java 21 LTS Virtual Threads definitivos Structured Concurrency y Scoped Values en preview · synchronized todavía provoca pinning
- Java 22–23 Iteran las previews Ajustes del scheduler y de los temporizadores
- Java 24 JEP 491:
synchronizeddeja de provocar pinning El mayor obstáculo para adoptar Loom en código heredado desaparece - Java 25 LTS Scoped Values definitivos Structured Concurrency en su quinta preview con API nueva · Compact Object Headers · profiling de CPU con JFR
Capítulo 09. Spring Boot
Aquí es donde la teoría se convierte en un cambio de una línea. Y donde un cambio de una línea se convierte en un incidente si te saltas el resto del capítulo.
# application.properties — Spring Boot 3.2+ sobre Java 21+
spring.threads.virtual.enabled=true Qué configura realmente ese flag
| Componente | Con el flag desactivado | Con el flag activado |
|---|---|---|
| Peticiones en Tomcat | ThreadPoolExecutor, 200 hilos | VirtualThreadExecutor: un VT por petición |
| Peticiones en Jetty | QueuedThreadPool | Executor de virtual threads |
| Undertow | Worker XNIO | No se configura |
@Async | ThreadPoolTaskExecutor (8 de core) | SimpleAsyncTaskExecutor con virtual threads |
@Scheduled | ThreadPoolTaskScheduler con 1 hilo | SimpleAsyncTaskScheduler: ahora concurrente |
| Listeners de RabbitMQ y Kafka | Platform threads | Virtual threads |
| Spring Data Redis | Platform threads | Virtual threads |
| WebFlux / Reactor Netty | Event loop | Sin cambios: sigue el event loop |
Beans ThreadPoolTaskExecutor propios | Tu configuración | Sin cambios: manda tu bean |
Tomcat en detalle
Diagrama de flujo: 10.000 peticiones → VirtualThreadExecutor → DispatcherServlet → max-connections
- 10.000 peticiones
- VirtualThreadExecutor un VT por petición, sin cola
- DispatcherServlet tu código de siempre, bloqueante
- max-connections el control de admisión que sustituye a threads.max
# Esta propiedad queda SIN EFECTO con virtual threads activados.
server.tomcat.threads.max=200
# ESTE es ahora tu verdadero control de admisión.
server.tomcat.max-connections=10000
server.tomcat.accept-count=200
server.tomcat.connection-timeout=20s Undertow y Jetty
Jetty sí lo configura el flag. Undertow no: su modelo de workers XNIO no está cableado por la autoconfiguración de Boot, así que o despachas explícitamente o te pasas a Tomcat o Jetty. El camino soportado por Boot es Tomcat o Jetty; cualquier otra cosa es una integración propia que tendrás que mantener y probar tú.
MVC frente a WebFlux
Qué stack elegir en Spring
-
¿Hay entrada/salida bloqueante en tu stack? (JDBC, JPA, HTTP bloqueante)
- Sí Pasa a la pregunta 02
- No · drivers reactivos Pasa a la pregunta 03
-
¿Estás en Java 21 o superior?
- Sí MVC + virtual threads Código simple de leer y escalado excelente. Es la respuesta por defecto para casi cualquier servicio empresarial.
- No MVC con pool dimensionado, y planifica el salto a Java 21
-
¿Necesitas streaming, backpressure de extremo a extremo o cien mil conexiones ociosas?
- Sí WebFlux El backpressure y el streaming siguen siendo su terreno, y no hay equivalente en el modelo bloqueante.
- No MVC + virtual threads No pagues el impuesto de complejidad reactiva sin recibir nada a cambio.
// Un pipeline reactivo con una llamada bloqueante inevitable.
private final Scheduler schedulerBloqueante =
Schedulers.fromExecutor(Executors.newVirtualThreadPerTaskExecutor());
public Mono<Enriquecido> enriquecer(Evento evento) {
return Mono.fromCallable(() -> repositorioJdbcLegado.buscar(evento.clave()))
.subscribeOn(schedulerBloqueante) // sin el tope de boundedElastic
.map(datos -> enriquecerCon(evento, datos));
} Schedulers.boundedElastic() está limitado a
10 × availableProcessors() hilos: 80 en una máquina de 8 núcleos. Un
scheduler de virtual threads elimina ese techo para llamadas genuinamente bloqueantes.
@Async y TaskExecutor
@Configuration
@EnableAsync
class AsyncConfig {
@Bean(name = "applicationTaskExecutor")
AsyncTaskExecutor applicationTaskExecutor() {
SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("async-vt-");
executor.setVirtualThreads(true); // Spring Framework 6.1+
executor.setConcurrencyLimit(500); // AÑADE ESTO. Por favor.
return executor;
}
// Conserva un pool de platform threads para CPU:
// los virtual threads no aportan nada aquí.
@Bean("cpuBoundExecutor")
ThreadPoolTaskExecutor cpuBoundExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int cores = Runtime.getRuntime().availableProcessors();
executor.setCorePoolSize(cores);
executor.setMaxPoolSize(cores);
executor.setQueueCapacity(1_000);
executor.setThreadNamePrefix("cpu-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
@Service
class InformeService {
@Async // → virtual threads
CompletableFuture<Void> enviarPorEmail(InformeId id) { /* SMTP bloqueante */ }
@Async("cpuBoundExecutor") // → pool de platform. Explícito y correcto.
CompletableFuture<Pdf> renderizarPdf(InformeId id) { /* render intensivo */ }
} Un controlador completo y realista
@RestController
@RequestMapping("/api/clientes")
class CuadroClienteController {
private final UsuarioClient usuarios;
private final PedidoClient pedidos;
private final RiesgoClient riesgo;
// Un bulkhead por destino. Esta es la nueva disciplina de dimensionado.
private final Semaphore permisosRiesgo = new Semaphore(50);
CuadroClienteController(UsuarioClient u, PedidoClient p, RiesgoClient r) {
this.usuarios = u; this.pedidos = p; this.riesgo = r;
}
@GetMapping("/{id}/cuadro")
Cuadro cuadro(@PathVariable ClienteId id) throws Exception {
// Fan-out con aspecto secuencial, sobre el virtual thread de la petición.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Usuario> u = executor.submit(() -> usuarios.buscar(id));
Future<Pedidos> p = executor.submit(() -> pedidos.recientesDe(id));
Future<Riesgo> r = executor.submit(() ->
conPermiso(permisosRiesgo, () -> riesgo.calcular(id)));
return new Cuadro(u.get(), p.get(), r.get());
}
}
private static <T> T conPermiso(Semaphore permisos, Callable<T> tarea) throws Exception {
permisos.acquire();
try { return tarea.call(); } finally { permisos.release(); }
}
}
Tres peticiones, tres virtual threads, un kilobyte cada uno y una traza de pila que se
lee como el código. La versión equivalente con CompletableFuture tiene
treinta líneas más y sus trazas mencionan
ForkJoinPool.commonPool-worker-3.
JDBC, HikariCP y el cuello de botella que se mueve
# El pool de conexiones es ahora tu límite REAL de concurrencia.
spring.datasource.hikari.maximum-pool-size=40
spring.datasource.hikari.minimum-idle=10
# Falla rápido: no dejes que diez mil hilos esperen treinta segundos.
spring.datasource.hikari.connection-timeout=3000
spring.datasource.hikari.leak-detection-threshold=20000 Diagrama de flujo: 10.000 peticiones → 10.000 virtual threads → HikariCP · 40 conexiones → PostgreSQL
- 10.000 peticiones concurrentes
- 10.000 virtual threads ~15 MB de heap
- HikariCP · 40 conexiones 9.960 hilos bloqueados aquí
- PostgreSQL max_connections = 100
Observabilidad
| Métrica | Por qué importa ahora |
|---|---|
hikaricp.connections.pending | La señal número 1. Sustituye a la profundidad de cola del pool |
hikaricp.connections.acquire (p99) | Dónde se ha ido realmente tu latencia |
jvm.threads.live | Ahora debería ser bajo y plano: solo carriers |
jvm.memory.used{area=heap} | Incluye los stack chunks: espera una línea base más alta |
app.*.permisos.encolados | Tus propios bulkheads: los tienes que instrumentar tú |
Peticiones activas en http.server.requests | Peticiones en vuelo, ya no acotadas por el pool |
Capítulo 10. Guía de migración
Seis fases. La primera decide si hay que hacer las otras cinco, y es la que casi todo el mundo se salta.
Diagrama de flujo: Fase 0 · Medir → Fase 1 · Auditar → Fase 2 · Preproducción → Fase 3 · Acotar → Fase 4 · Canary → Fase 5 · Redimensionar
- Fase 0 · Medir ¿es el pool el cuello de botella de verdad?
- Fase 1 · Auditar synchronized · JNI · ThreadLocal · @Scheduled
- Fase 2 · Preproducción flag + carga + JFR de pinning
- Fase 3 · Acotar Semaphore por destino · Hikari · timeouts
- Fase 4 · Canary 5 % → 25 % → 50 % → 100 %
- Fase 5 · Redimensionar reduce pods y cobra el ahorro
Fase 0 · ¿Deberías migrar siquiera?
Responde con honestidad a estas cinco preguntas. Tres o más «no» y lo estás haciendo por el motivo equivocado.
| Pregunta | Cómo responderla |
|---|---|
| ¿El servicio está dominado por E/S? | Uso de CPU por debajo del 40 % con latencia alta → sí |
| ¿El thread pool es la restricción? | tamaño_pool / latencia_p50 ≈ pico de RPS observado → sí |
| ¿El p99 es muchísimo mayor que el p50? | Una diferencia de 10× es tiempo de cola, y eso es lo que se arregla |
| ¿Estás en Java 21 o puedes llegar? | Innegociable |
| ¿Tienes pruebas de carga y dashboards? | Migrar a ciegas es la forma de enterarte en producción |
Fase 1 · La auditoría
# 1 · synchronized alrededor de E/S: la causa nº 1 de pinning en Java 21–23.
# Ignóralo por completo si estás en Java 24+ (JEP 491).
grep -rn "synchronized" --include="*.java" src/main/java
# 2 · ThreadLocal: cada uno de estos se convierte en N copias.
grep -rn "ThreadLocal\|InheritableThreadLocal" --include="*.java" src/main/java
# 3 · Beans de executor explícitos: el flag NO los toca.
grep -rn "ThreadPoolTaskExecutor\|newFixedThreadPool\|newCachedThreadPool" \
--include="*.java" src/main/java
# 4 · parallelStream sobre trabajo bloqueante: un bug preexistente que ahora notarás.
grep -rn "parallelStream()" --include="*.java" src/main/java
# 5 · Librerías nativas: harán pinning sea cual sea la versión de Java.
grep -rn "System.loadLibrary\|native " --include="*.java" src/main/java | Hallazgo | Java 21–23 | Java 24–25 | Acción |
|---|---|---|---|
synchronized + E/S | Pinning | Sin problema | Actualizar, o ReentrantLock |
synchronized + CPU puro y corto | Bien | Bien | Déjalo |
ThreadLocal por petición | Funciona | Funciona | Vale; plantea ScopedValue más adelante |
ThreadLocal cacheando un objeto pesado | N copias | N copias | Arréglalo ya: singleton o pool de objetos |
ThreadPoolTaskExecutor explícito | Intacto | Intacto | Decide bean a bean: CPU se queda, E/S se convierte |
parallelStream() + bloqueo | Bug | Bug | Sustituir por executor de virtual threads |
| JNI en la ruta caliente | Pinning | Pinning | Aislar tras un pool de platform |
@Scheduled no reentrante | Ahora concurrente | Ahora concurrente | Añadir ShedLock o un lock propio |
Fase 2 · Validar en preproducción
# Java 21–23: traza de pinning
java -Djdk.tracePinnedThreads=full -jar app.jar
# Cualquier versión: JFR es la respuesta apta para producción
java -XX:StartFlightRecording=duration=300s,filename=loom.jfr,settings=profile -jar app.jar
jfr print --events jdk.VirtualThreadPinned loom.jfr | head -100 | Métrica | Antes | Después | Criterio de aceptación |
|---|---|---|---|
| Throughput a la concurrencia objetivo | ≥ antes | ||
| Latencia p50 | ≈ antes (±10 %) | ||
| Latencia p99 | Debe bajar notablemente: es el objetivo | ||
| RSS pico | ↓ o igual | ||
jvm.threads.live | ↓ drásticamente | ||
| Heap tras GC completo | ↑ aceptable por los stack chunks | ||
| Pausa de GC p99 | ≈ antes | ||
hikaricp.connections.pending | Vigílalo: el cuello se movió aquí | ||
Eventos jdk.VirtualThreadPinned/min | ≈ 0 | ||
| Tasa de error al doble de carga | ≤ antes |
Fase 3 · Reconstruir la seguridad que has quitado
/**
* Un bulkhead por recurso de destino. Esta clase sustituye la protección
* accidental que el thread pool proporcionaba gratis.
*/
@Component
public class BulkheadsDestino {
private final Map<String, Semaphore> permisos = Map.of(
"bd", new Semaphore(40), // = maximum-pool-size de HikariCP
"pagos", new Semaphore(100), // = límite contractual del proveedor
"busqueda", new Semaphore(200), // = lo que tolera Elasticsearch
"llm", new Semaphore(20) // = cuota del proveedor de modelos
);
public <T> T llamar(String recurso, Duration timeout, Callable<T> tarea) throws Exception {
Semaphore semaforo = permisos.get(recurso);
// tryAcquire CON timeout, nunca acquire() a secas: bajo sobrecarga
// quieres un 503 rápido, no cincuenta mil hilos esperando un permiso.
if (!semaforo.tryAcquire(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
throw new RecursoAgotadoException(recurso); // → HTTP 503
}
try {
return tarea.call();
} finally {
semaforo.release();
}
}
} Fase 4 · Despliegue canary
// Haz que el cambio se controle en tiempo de ejecución
// para que el rollback sea inmediato.
@Configuration
class ExecutorConfig {
@Bean
AsyncTaskExecutor applicationTaskExecutor(
@Value("${app.virtual-threads.enabled:false}") boolean virtual) {
if (virtual) {
SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("vt-");
executor.setVirtualThreads(true);
executor.setConcurrencyLimit(1_000);
return executor;
}
ThreadPoolTaskExecutor legado = new ThreadPoolTaskExecutor();
legado.setCorePoolSize(50);
legado.setMaxPoolSize(200);
legado.setQueueCapacity(500);
legado.setThreadNamePrefix("pool-");
legado.initialize();
return legado;
}
}
Despliega 5 % → 25 % → 50 % → 100 %, manteniendo cada escalón al menos un ciclo
completo de tráfico, normalmente veinticuatro horas. Vigila el p99,
hikaricp.connections.pending, la tasa de error y el heap.
Chuleta de migración
| Antes | Después | Notas |
|---|---|---|
newFixedThreadPool(n) para E/S | newVirtualThreadPerTaskExecutor() | Añade un Semaphore |
newCachedThreadPool() | newVirtualThreadPerTaskExecutor() | Mejor en todos los sentidos |
newFixedThreadPool(cores) para CPU | Déjalo | Migrar no aporta nada |
new Thread(r).start() | Thread.ofVirtual().start(r) | Recuerda: siempre daemon |
ThreadPoolTaskExecutor (E/S) | SimpleAsyncTaskExecutor + setVirtualThreads(true) | Pon concurrencyLimit |
synchronized + E/S (Java ≤23) | ReentrantLock | Innecesario en Java 24+ |
ThreadLocal de contexto propio | ScopedValue (Java 25) | El contexto del framework puede quedarse |
parallelStream() + bloqueo | Executor de virtual threads | Arregla un bug latente |
Cadena de CompletableFuture bloqueantes | Código secuencial en un virtual thread | Gran ganancia de legibilidad |
@Async sobre CPU | @Async("cpuBoundExecutor") | Sé explícito |
Capítulo 11. Buenas prácticas
Diez reglas, un checklist de producción y la arquitectura de tres capas que resume toda la migración.
1 · Nunca hagas pool de virtual threads
// Absurdo: los pools existen para reutilizar cosas caras. Estas son baratas.
ExecutorService mal = Executors.newFixedThreadPool(100, Thread.ofVirtual().factory());
// Correcto
ExecutorService bien = Executors.newVirtualThreadPerTaskExecutor(); 2 · Acota el recurso, no los hilos
// Reintroduce el techo que acabas de quitar
ExecutorService mal = Executors.newFixedThreadPool(50);
// Hilos ilimitados, acceso limitado a lo que de verdad es escaso
Semaphore permisosBd = new Semaphore(40); 3 · Ponles nombre siempre
Thread.ofVirtual().name("ingesta-pedidos-", 0).factory(). Sin nombre, tu
columna %thread sale en blanco y el thread dump es un muro de entradas
anónimas.
4 · Usa siempre try-with-resources con los executors
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
tareas.forEach(executor::submit);
} // close() espera. Sin él, son daemon y mueren al salir la JVM. 5 · Pon timeouts en todo
// Con un pool, el pool acotaba tu radio de explosión.
// Ahora no lo hace nada: los timeouts dejan de ser opcionales.
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(5)) // timeout por petición
.build(); 6 · Mantén un pool de platform threads para trabajo de CPU
Un newFixedThreadPool(availableProcessors()) sigue siendo la respuesta
correcta para render, cifrado o serialización pesada. Y con expropiación, que los
virtual threads no tienen.
7 · Vigila el pinning de forma continua
JFR continuo con jdk.VirtualThreadPinned y alerta sobre la frecuencia del
evento, no sobre su existencia puntual.
8 · Ajusta tu pool de conexiones antes que ninguna otra cosa
Es el límite real desde el momento en que activas el flag. Todo lo demás es secundario hasta que ese número esté bien.
9 · Prefiere ReentrantLock para secciones críticas largas
Es más justo, interrumpible, admite tryLock con timeout y en Java 23 o
anterior no provoca pinning.
10 · Haz pruebas de carga al triple del pico esperado
Los virtual threads cambian los modos de fallo, no solo el rendimiento. Necesitas ver el nuevo modo de fallo en preproducción, porque no se parece al antiguo: en lugar de una cola que crece, verás un servicio de destino que empieza a devolver 429.
Checklist de producción
- ✓ Java 21+ (24 o superior muy recomendable: sin pinning por synchronized)
- ✓ Spring Boot 3.2 o superior
- ✓ spring.threads.virtual.enabled detrás de un flag de runtime
- ✓ Semaphore o Bulkhead de Resilience4j por cada recurso de destino
- ✓ HikariCP dimensionado con criterio y connection-timeout de 3 s o menos
- ✓ Timeouts en todos los clientes HTTP y sentencias de base de datos
- ✓ server.tomcat.max-connections fijado explícitamente
- ✓ Métodos @Scheduled auditados en cuanto a reentrancia
- ✓ Beans ThreadPoolTaskExecutor explícitos revisados uno a uno
- ✓ JFR continuo con jdk.VirtualThreadPinned y alerta sobre su frecuencia
- ✓ Dashboard con hikaricp.connections.pending, cola de permisos y peticiones en vuelo
- ✓ Ninguna métrica etiquetada por hilo
- ✓ Thread dumps con jcmd Thread.dump_to_file documentados en el runbook
- ✓ Prueba de carga al triple del pico completada
- ✓ Procedimiento de rollback probado, no solo escrito
- ✓ Perfil de GC vuelto a medir: los stack chunks viven en el heap
La arquitectura recomendada
Diagrama por capas: Borde · control de admisión (Rate limiter, server.tomcat.max-connections); Aplicación · concurrencia libre (1 virtual thread por petición); Bulkheads · uno por recurso (Semaphore(40), Semaphore(100), Semaphore(20), Pool(cores)); Destinos (PostgreSQL, API de pagos, API de modelos, CPU local)
Capítulo 12. Errores habituales
Ordenados por frecuencia real en revisiones de código. Los tres primeros explican casi todas las migraciones que no dan el resultado esperado.
1 · Hacer pool de virtual threads
// Has pagado el coste de la migración y te has quedado el techo de 200 hilos.
// Todos los inconvenientes, ninguna ventaja.
Executors.newFixedThreadPool(200, Thread.ofVirtual().factory()); Solución: newVirtualThreadPerTaskExecutor() y un
Semaphore para acotar el recurso escaso.
2 · ThreadLocal con objetos pesados
// 1.000.000 de hilos × buffer de 8 KB = 8 GB. Adiós.
private static final ThreadLocal<byte[]> BUFFER =
ThreadLocal.withInitial(() -> new byte[8192]); Solución: reserva local —el análisis de escape a menudo la hace
gratuita—, un pool de objetos real, o ScopedValue para contexto inmutable.
La regla: ThreadLocal vale para contexto pequeño e inmutable y es
letal para cachés grandes y mutables.
3 · synchronized alrededor de E/S en Java 23 o anterior
Bloquea el carrier durante toda la llamada. Solución: ReentrantLock, o saltar a Java 24, donde la JEP 491 lo convierte en un
no-problema.
4 · Dar por hecho que todo bloqueo se desmonta
La entrada/salida de ficheros no se desmonta: compensa haciendo crecer el pool de carriers. Los frames nativos y JNI provocan pinning directamente. Solución: consultar la tabla del capítulo 5 y medir con JFR.
5 · Trabajo de CPU sobre virtual threads
// 10.000 virtual threads compitiendo por 8 carriers, sin expropiación
// y con peor localidad de caché que un pool dimensionado.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
imagenes.forEach(img -> executor.submit(() -> redimensionar(img)));
} 6 · Olvidar que son daemon
public static void main(String[] args) {
Thread.startVirtualThread(() -> trabajoCritico());
} // La JVM termina al instante. El trabajo nunca se ejecutó. 7 · No poner timeouts
Un pool acotaba tu exposición a un destino colgado en tamaño_pool. Ahora
cien mil hilos pueden quedarse colgados en el mismo endpoint muerto.
8 · Métricas por hilo
// 100.000 hilos → 100.000 series temporales → tu Prometheus se cae.
Metrics.counter("peticiones", "thread", Thread.currentThread().getName())
.increment(); 9 · Ignorar que el cuello de botella se ha movido
Activas el flag, lo celebras, y descubres connection is not available, request
timed out after 30000ms. Solución: dimensionar HikariCP, poner
un connection-timeout de dos o tres segundos y añadir bulkheads
antes del despliegue.
10 · Fan-out anidado sin límite
// 1.000 peticiones × 100 elementos = 100.000 llamadas concurrentes
// a una API externa. La multiplicación es invisible en una revisión
// de código y letal en producción.
try (var externo = Executors.newVirtualThreadPerTaskExecutor()) {
peticiones.forEach(p -> externo.submit(() -> {
try (var interno = Executors.newVirtualThreadPerTaskExecutor()) {
p.elementos().forEach(e -> interno.submit(() -> apiSocio.llamar(e)));
}
}));
} Solución: un único Semaphore compartido, dimensionado al
límite del proveedor, no al de tu bucle.
11 · Jobs @Scheduled no reentrantes
El scheduler por defecto era de un solo hilo. Con virtual threads, las ejecuciones solapadas se vuelven posibles de la noche a la mañana.
12 · Depurar con jstack
Muestra carriers, no virtual threads. Verás ocho hilos y concluirás que todo va bien mientras doscientos mil están atascados.
| Error | Síntoma | Solución |
|---|---|---|
| Pool de virtual threads | Ninguna mejora tras migrar | newVirtualThreadPerTaskExecutor() + Semaphore |
ThreadLocal pesado | OOM; el heap crece con el número de hilos | ScopedValue o reserva local |
synchronized + E/S en Java ≤23 | Poco throughput y mucho VirtualThreadPinned | ReentrantLock, o Java 24+ |
| E/S de ficheros | El pool de carriers crece en silencio | Cuéntalo con ello; pool de platform para lotes de fichero |
| Trabajo de CPU sobre virtual threads | Sin ganancia y peor localidad de caché | Pool de platform dimensionado |
| Sorpresa daemon | Tareas que nunca llegan a ejecutarse | join() o try-with-resources |
| Sin timeouts | Bloqueo en cascada ante un destino colgado | Timeouts de conexión y lectura en todas partes |
| Métricas por hilo | Se cae el backend de métricas | Agregar siempre; nunca etiquetar por hilo |
| Cuello de botella desplazado | Timeouts de Hikari y p99 de 30 s | Dimensionar, fallar rápido, bulkheads |
| Fan-out anidado | 429 del proveedor o caída del destino | Un Semaphore compartido |
Solape en @Scheduled | Corrupción de datos | ShedLock o lock propio |
Depurar con jstack | «Solo 8 hilos, todo bien» | jcmd Thread.dump_to_file -format=json |
Capítulo Caso. Caso práctico: migrar checkout-orchestrator
Un compuesto de varias migraciones reales de Spring Boot, anonimizadas y unificadas en un servicio representativo. Las cifras son las de esos proyectos; el nombre del servicio no.
Diagrama de flujo: Gateway → checkout-orchestrator → 5 microservicios + PostgreSQL
- Gateway
- checkout-orchestrator el servicio que migramos
- 5 microservicios + PostgreSQL camino crítico ~400 ms · pago externo 250 ms
Antes
@Service
class CheckoutService {
@Autowired @Qualifier("checkoutExecutor")
private ThreadPoolTaskExecutor executor; // core 50, max 200, cola 1.000, AbortPolicy
public ResultadoCompra comprar(SolicitudCompra solicitud) {
CompletableFuture<Cliente> cliente = CompletableFuture.supplyAsync(
() -> clienteClient.buscar(solicitud.clienteId()), executor);
CompletableFuture<Stock> stock = CompletableFuture.supplyAsync(
() -> inventarioClient.reservar(solicitud.lineas()), executor);
CompletableFuture<Promocion> promocion = CompletableFuture.supplyAsync(
() -> promocionClient.mejorPara(solicitud), executor);
return CompletableFuture.allOf(cliente, stock, promocion)
.thenCompose(v -> {
Pedido pedido = pedidoFactory.crear(
cliente.join(), stock.join(), promocion.join());
return CompletableFuture
.supplyAsync(() -> pagoClient.cobrar(pedido), executor)
.thenCombine(
CompletableFuture.supplyAsync(
() -> envioClient.cotizar(pedido), executor),
(pago, envio) -> pedidoRepository.guardar(pedido, pago, envio))
.thenApply(ResultadoCompra::de);
})
.exceptionally(this::mapearFallo)
.join();
}
} Doce pods de 2 vCPU y 2 GB. En pico: 2.400 req/s, p50 de 310 ms, p95 de 1.850 ms y p99 de 6.200 ms. El p99 lo cuenta todo: el trabajo real son unos 400 ms de camino crítico, así que 5,8 segundos son puro tiempo de cola.
En Black Friday la cola se llenó, saltó la AbortPolicy y los clientes
vieron 503 en el proceso de compra: el error más caro que puede servir un retailer.
Después
@Service
class CheckoutService {
// Un bulkhead por destino, dimensionado a la capacidad real de cada uno.
private static final Semaphore PERMISOS_PAGO = new Semaphore(150); // contrato
private static final Semaphore PERMISOS_INVENTARIO = new Semaphore(400);
private static final Semaphore PERMISOS_BD = new Semaphore(40); // = Hikari
public ResultadoCompra comprar(SolicitudCompra solicitud) throws Exception {
try (var scope = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Cliente> cliente = scope.submit(
() -> clienteClient.buscar(solicitud.clienteId()));
Future<Stock> stock = scope.submit(() -> acotado(PERMISOS_INVENTARIO,
() -> inventarioClient.reservar(solicitud.lineas())));
Future<Promocion> promocion = scope.submit(
() -> promocionClient.mejorPara(solicitud));
Pedido pedido = pedidoFactory.crear(
cliente.get(), stock.get(), promocion.get());
Future<Pago> pago = scope.submit(() -> acotado(PERMISOS_PAGO,
() -> pagoClient.cobrar(pedido)));
Future<Envio> envio = scope.submit(() -> envioClient.cotizar(pedido));
// Se resuelven las dos llamadas de red ANTES de pedir el permiso
// de base de datos. Adquirirlo primero y esperar dentro mantendría
// ocupada una de las 40 conexiones durante los 250 ms de la
// pasarela de pago: es la forma más rápida de agotar el pool sin
// que ninguna métrica de base de datos lo explique.
Pago pagoRealizado = pago.get();
Envio envioCotizado = envio.get();
return acotado(PERMISOS_BD, () -> ResultadoCompra.de(
pedidoRepository.guardar(pedido, pagoRealizado, envioCotizado)));
}
}
private static <T> T acotado(Semaphore permisos, Callable<T> tarea) throws Exception {
if (!permisos.tryAcquire(2, TimeUnit.SECONDS)) {
throw new RecursoAgotadoException(); // → 503 rápido
}
try { return tarea.call(); } finally { permisos.release(); }
}
} spring.threads.virtual.enabled=true
server.tomcat.max-connections=8000
spring.datasource.hikari.maximum-pool-size=40
spring.datasource.hikari.connection-timeout=2000
Fíjate en lo que ha desaparecido: el bean del executor,
CompletableFuture, thenCompose, thenCombine,
exceptionally y join(). Lo que lo sustituye se lee de arriba
abajo, lanza excepciones de verdad y produce trazas que mencionan
CheckoutService.comprar.
Resultados tras cuatro semanas
- Throughput en pico Antes 2.400 req/s Después 9.600 req/s+300 %
- Latencia p50 Antes 310 ms Después 295 ms−5 %
- Latencia p95 Antes 1.850 ms Después 410 ms−78 %
- Latencia p99 Antes 6.200 ms Después 680 ms−89 %
- RSS por pod Antes 1.600 MB Después 540 MB−66 %
- Hilos vivos por pod Antes 215 Después 11−95 %
- Pods necesarios en pico Antes 12 Después 4−67 %
- Coste mensual Antes 2.900 € Después 980 €−66 %
- Líneas en CheckoutService Antes 84 Después 41−51 %
Qué salió mal por el camino
Tres incidentes reales, porque un caso práctico sin fallos es publicidad.
Semana 1 · El driver JDBC bloqueaba los carriers
En Java 21, los bloques synchronized dentro del driver de Oracle
provocaban pinning en todos los carriers bajo carga. El throughput era peor
que con el pool. El evento jdk.VirtualThreadPinned de JFR lo localizó en
veinte minutos. La solución fue actualizar a Java 24, que a su vez se convirtió en la
justificación que el equipo necesitaba para la actualización de LTS que llevaba dos
veces aplazada.
Semana 2 · El proveedor de pagos nos limitó
Quitar el techo de 200 hilos supuso 3.000 llamadas concurrentes a un proveedor
contractualmente limitado a 200 por segundo. Nos llovieron 429 y una llamada de
teléfono. PERMISOS_PAGO nació de esa conversación.
Semana 3 · Las pausas de GC crecieron un 40 %
Cientos de miles de objetos StackChunk vivos cambiaron el perfil del heap:
más promoción a la generación antigua y recolecciones mixtas de G1 más largas. Cambiar
a ZGC llevó la pausa p99 de GC de 180 ms a 4 ms. Es el efecto de
segundo orden del capítulo 5, encontrado en la vida real.
Capítulo Extra. Comparativas y la teoría para elegir bien
Las cuatro comparaciones que siempre se preguntan, y las dos leyes que determinan qué techo estás intentando romper.
Virtual Threads frente a WebFlux
| Dimensión | Virtual Threads | WebFlux / Reactor |
|---|---|---|
| Modelo de programación | Imperativo, secuencial | Declarativo, pipelines funcionales |
| Curva de aprendizaje | Horas | Semanas o meses |
| Trazas de pila | Completas y útiles | Internos de Reactor, 80+ frames |
| Depuración paso a paso | Funciona | Prácticamente inviable |
try/catch/finally | Nativo | onErrorResume / doFinally |
| Backpressure | Manual (Semaphore) | Integrado, de extremo a extremo |
| Streaming (SSE, chunked) | Se puede, no es idiomático | Nativo |
| Drivers bloqueantes | Todo funciona | Envenenan el event loop |
| Memoria con 100k concurrentes | ~500 MB de heap | ~100 MB de heap |
| Techo de throughput bruto | Muy alto | Ligeramente superior |
| Incorporación de gente | Cualquier desarrollador Java | Especialistas |
| Operadores de composición | Código normal | flatMap, zip, retryWhen, window… |
Virtual Threads frente a CompletableFuture
// CompletableFuture: composición asíncrona
CompletableFuture<Cuadro> cuadro =
CompletableFuture.supplyAsync(() -> usuarioService.buscar(id), executor)
.thenCombine(
CompletableFuture.supplyAsync(() -> pedidoService.buscarDe(id), executor),
(usuario, pedidos) -> new Parcial(usuario, pedidos))
.thenCompose(parcial ->
CompletableFuture.supplyAsync(() -> riesgoService.calcular(id), executor)
.thenApply(riesgo -> new Cuadro(parcial, riesgo)))
.exceptionally(this::respaldo);
// Virtual threads: misma concurrencia, forma secuencial
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Usuario> usuario = executor.submit(() -> usuarioService.buscar(id));
Future<Pedidos> pedidos = executor.submit(() -> pedidoService.buscarDe(id));
Future<Riesgo> riesgo = executor.submit(() -> riesgoService.calcular(id));
return new Cuadro(usuario.get(), pedidos.get(), riesgo.get());
} catch (Exception e) {
return respaldo(e);
} | CompletableFuture | Virtual Threads | |
|---|---|---|
| Composición sin bloqueo | Su razón de existir | Bloqueas, pero es barato |
| Legibilidad | Se degrada rápido pasadas 3 etapas | Lineal |
| Gestión de errores | Desenvolver CompletionException | try/catch normal |
| Cancelación | Débil y se pierde sin avisar | Sólida con concurrencia estructurada |
| Hilos consumidos | 0 mientras espera | 1 virtual thread (~1 KB) |
| Integración con APIs de callbacks | Ideal | Necesita un puente |
Se combinan. CompletableFuture sigue siendo el adaptador
correcto para APIs de callbacks, y puedes hacerle join() desde un virtual
thread sin penalización. Úsalo en la frontera y código normal en el
centro.
Virtual Threads frente a las corrutinas de Kotlin
| Virtual Threads | Corrutinas de Kotlin | |
|---|---|---|
| Implementación | Continuations a nivel de JVM | Transformación CPS a nivel de compilador |
| Cambios en el lenguaje | Ninguno | Palabra clave suspend |
| «Funciones de color» | No: sirve cualquier método | Sí: suspend es contagioso |
| Librerías existentes | Cualquier librería bloqueante | Necesita envoltorios o Dispatchers.IO |
| Concurrencia estructurada | En preview (JEP 505) | Madura desde 2018 |
| Cancelación | Basada en interrupciones | Cooperativa y de primera clase: superior |
| Flujos / streams | No incluido | Flow: excelente |
| Disponibilidad | Todos los lenguajes de la JVM | Solo Kotlin |
Virtual Threads frente a las goroutines de Go
| Virtual Threads (Java) | Goroutines (Go) | |
|---|---|---|
| Modelo | M:N sobre carrier threads | M:N sobre hilos del SO (GMP) |
| Stack inicial | ~1 KB en el heap | 2 KB, contiguo, crece copiando |
| Scheduler | ForkJoinPool, FIFO | Runtime propio con robo de trabajo |
| Expropiación | Solo cooperativa | Asíncrona desde Go 1.14 |
| Comunicación | BlockingQueue, java.util.concurrent | Canales y select |
| Cancelación | Interrupciones o concurrencia estructurada | context.Context: idiomático y universal |
| Protección ante fugas | Concurrencia estructurada (preview) | Las fugas de goroutines son un problema conocido |
| Ecosistema | 25 años de librerías bloqueantes que se benefician sin recompilar | Asíncrono de nacimiento |
CPU Bound frente a IO Bound
| CPU Bound | IO Bound | |
|---|---|---|
| Cuello de botella | Ciclos de procesador | Esperar a un sistema externo |
| Ejemplos | Cifrado, compresión, inferencia, procesado de imágenes | HTTP, JDBC, ficheros, Kafka, Redis, APIs de LLM |
| Uso de CPU | 90–100 % | 5–30 % |
| Número óptimo de hilos | ≈ número de núcleos | Tantos como tolere el destino |
| ¿Ayudan los virtual threads? | No | Muchísimo |
| Herramienta correcta | Pool de platform dimensionado o ForkJoinPool | Virtual threads con bulkheads |
// Heurística barata para clasificar una carga: ratio de espera alto ⇒ IO bound.
//
// IMPORTANTE: ejecútala sobre un PLATFORM THREAD. ThreadMXBean no está
// pensado para virtual threads; desde uno, getCurrentThreadCpuTime() mide
// el carrier y el resultado no significa lo que parece. Mídelo antes de
// migrar, que además es cuando necesitas la respuesta.
ThreadMXBean mx = ManagementFactory.getThreadMXBean();
if (!mx.isCurrentThreadCpuTimeSupported() || Thread.currentThread().isVirtual()) {
log.warn("Medición de CPU por hilo no disponible en este contexto");
return;
}
long inicio = System.nanoTime();
long cpuInicio = mx.getCurrentThreadCpuTime();
hacerElTrabajo();
double relojMs = (System.nanoTime() - inicio) / 1e6;
double cpuMs = (mx.getCurrentThreadCpuTime() - cpuInicio) / 1e6;
log.info("reloj={}ms cpu={}ms ratio={}", relojMs, cpuMs, cpuMs / relojMs);
// ratio < 0,1 → IO bound → virtual threads
// ratio > 0,8 → CPU bound → pool de platform dimensionado Ley de Amdahl: el techo del paralelismo
Con p la fracción paralelizable y n el número de
procesadores, la aceleración es 1 / ((1 − p) + p/n).
| Fracción paralela | Con 8 núcleos | Con 64 | Con infinitos |
|---|---|---|---|
| 50 % | 1,78× | 1,97× | 2× |
| 75 % | 2,91× | 3,77× | 4× |
| 90 % | 4,71× | 7,89× | 10× |
| 95 % | 6,02× | 15,4× | 20× |
| 99 % | 7,48× | 39,3× | 100× |
Ley de Little: la fórmula que hay que memorizar
L = λ × W, es decir, concurrencia igual a throughput por latencia. El
capítulo 3 la usó para calcular el techo de un
pool; aquí interesa despejarla en las otras dos direcciones, que son las que aparecen
en una conversación de capacidad real.
| Lo que sabes | Lo que buscas | Cálculo |
|---|---|---|
| Pool de 200, latencia 250 ms | Throughput máximo | 200 / 0,25 = 800 req/s |
| Necesito 5.000 req/s con latencia 200 ms | Concurrencia necesaria | 5000 × 0,2 = 1.000 hilos |
| 300 hilos, 4.000 req/s | Latencia implícita | 300 / 4000 = 75 ms |
La fila del medio es la letal: 1.000 platform threads son ~1 GB de stacks; 1.000 virtual threads son ~1 MB. La fórmula no ha cambiado. Lo que se ha desplomado, tres órdenes de magnitud, es el coste de satisfacerla.
Backpressure
Es un consumidor diciéndole a un productor que vaya más despacio. Con un pool venía gratis en la cola acotada; con virtual threads hay que ponerlo a mano.
Diagrama de flujo: Productor → Fan-out ilimitado → Destino sobrecargado
- Productor
- Fan-out ilimitado un virtual thread por tarea
- Destino sobrecargado 429, timeouts, caída
Diagrama de flujo: Productor → Semaphore / Bulkhead → Destino estable
- Productor
- Semaphore / Bulkhead sin permiso → 503 rápido
- Destino estable trabaja a su capacidad real
| Mecanismo | Backpressure | Granularidad |
|---|---|---|
Cola acotada + CallerRunsPolicy | Implícito | Global |
request(n) de Reactive Streams | Explícito, de extremo a extremo | Por suscripción |
Semaphore | Explícito | Por recurso |
Bulkhead de Resilience4j | Explícito y con métricas | Por recurso |
| Virtual threads a secas | Ninguno | — |
Cuándo lo reactivo sigue siendo lo correcto
- Backpressure de extremo a extremo cruzando fronteras de proceso: Reactive Streams o RSocket.
- Streaming: SSE, respuestas troceadas, flujos infinitos de eventos.
- Composición rica de flujos: ventanas, throttling, buffering,
retryWhencon backoff. - Números extremos de conexiones con un presupuesto de memoria estricto.
- Una base de código reactiva sana que ya existe. No reescribas lo que funciona.
Capítulo 13. Preguntas frecuentes
Veintiséis preguntas reales, de las que aparecen en revisiones de arquitectura y en entrevistas técnicas.
¿Los Virtual Threads hacen mi aplicación más rápida?
No por petición. Una llamada que tarda 100 ms sigue tardando 100 ms. Lo que aumentan es el throughput y lo que recortan es el percentil 99 bajo carga, porque eliminan el tiempo que las peticiones pasaban esperando en la cola del pool. Si tu servicio no está encolando, no medirás ninguna mejora.
¿Sustituyen a ExecutorService?
No. Executors.newVirtualThreadPerTaskExecutor() es un ExecutorService. Lo que sustituyen es la estrategia de agrupar hilos en pools para tareas bloqueantes de entrada/salida, no la interfaz.
¿Sigo necesitando thread pools?
Sí, en tres casos: trabajo intensivo de CPU, situaciones que exigen orden estricto de ejecución y cualquier punto donde un límite duro de concurrencia sea un requisito y no un efecto colateral.
¿Cuántos Virtual Threads puedo crear?
Millones. El límite deja de ser el sistema operativo y pasa a ser el heap de la JVM: alrededor de 1 KB por hilo aparcado con pila poco profunda, más si la pila es profunda. Un heap de 4 GB soporta cientos de miles sin dificultad.
¿Qué es un carrier thread?
Un platform thread del ForkJoinPool del scheduler que ejecuta temporalmente un virtual thread. Por defecto hay tantos como devuelve Runtime.availableProcessors().
¿Qué es el pinning en Virtual Threads?
Ocurre cuando un virtual thread se bloquea y no puede desmontarse de su carrier thread, de forma que el carrier queda bloqueado también y deja de ejecutar cualquier otro virtual thread. Hasta Java 23 lo provocaban los bloques synchronized. Desde Java 24, con JEP 491, solo lo provocan los frames nativos, las llamadas de la Foreign Function API y los inicializadores de clase.
¿Tengo que eliminar todos los synchronized?
En Java 24 y posteriores, no. En Java 21, 22 y 23, solo allí donde la sección crítica realiza entrada/salida bloqueante. Las secciones críticas cortas y de CPU pura no dan problemas en ninguna versión.
¿ThreadLocal sigue siendo utilizable?
Sí, pero deja de amortizarse entre hilos reutilizados. Guardar contexto pequeño e inmutable es correcto; cachear objetos grandes y mutables se convierte en un problema de memoria en cuanto hay cientos de miles de hilos. La alternativa moderna son los Scoped Values, definitivos en Java 25.
¿Los Virtual Threads funcionan con JDBC?
Sí. El límite real pasa a ser el pool de conexiones. Conviene dimensionar HikariCP de forma deliberada y bajar el connection-timeout a dos o tres segundos para fallar rápido. En Java 23 o anterior hay que comprobar además si el driver usa synchronized alrededor de la entrada/salida.
¿Cómo activo Virtual Threads en Spring Boot?
Con la propiedad spring.threads.virtual.enabled=true en Spring Boot 3.2 o superior, ejecutando sobre Java 21 o superior. Esto configura el manejo de peticiones de Tomcat y Jetty, las tareas @Async y las tareas programadas.
¿Funcionan con WebFlux?
La propiedad no toca el event loop de Reactor Netty, y hace bien: un event loop de cuatro hilos ya es óptimo para trabajo no bloqueante. Donde sí ayudan es en los bordes bloqueantes de un pipeline reactivo, usando Schedulers.fromExecutor sobre un executor de virtual threads.
¿Debería migrar de WebFlux a MVC con Virtual Threads?
Solo si la complejidad reactiva te está costando dinero real y no necesitas backpressure de extremo a extremo ni streaming. Reescribir un servicio reactivo que funciona rara vez compensa. Para servicios nuevos la decisión se toma desde cero.
¿Ayudan con trabajo intensivo de CPU?
No. El paralelismo sigue limitado por el número de núcleos disponibles. Para carga de CPU la opción correcta es un pool de platform threads dimensionado al número de núcleos, o un ForkJoinPool con divide y vencerás.
¿Puedo hacer pool de Virtual Threads?
Se puede, y es un antipatrón. Los pools existen para reutilizar objetos caros de crear, y estos son baratos. Para limitar la concurrencia frente a un recurso escaso se usa un Semaphore, no un pool.
¿Qué versión de Java necesito?
Java 21 como mínimo. Java 24 o superior es muy recomendable porque JEP 491 eliminó el pinning provocado por synchronized. Java 25 LTS es hoy el mejor destino.
¿Cómo depuro un servicio con Virtual Threads?
Con jcmd y el volcado en JSON: jcmd PID Thread.dump_to_file -format=json. La herramienta jstack solo muestra los carrier threads. En producción, los eventos de JFR jdk.VirtualThreadPinned y jdk.VirtualThreadSubmitFailed son las señales que hay que vigilar.
¿Cambian el comportamiento del recolector de basura?
Sí. Las pilas son objetos del heap, así que un servicio con cientos de miles de hilos aparcados mantiene un conjunto vivo bastante mayor y esos stack chunks acaban promocionando a la generación antigua. Conviene volver a medir el perfil de GC y valorar ZGC.
¿Structured Concurrency está lista para producción?
Todavía no. En Java 25 va por su quinta preview mediante JEP 505 y la API se ha vuelto a rediseñar, de forma que el código escrito contra la versión de Java 21 no compila. Conviene usarla detrás de una abstracción propia y no extender --enable-preview por todo el parque de producción.
¿Y los Scoped Values?
Sí, son definitivos en Java 25 mediante JEP 506. No necesitan ningún flag de preview.
¿Qué pasa en un pico de tráfico?
Sin bulkheads, el fan-out no tiene límite y satura los servicios de destino: en la práctica te haces un ataque de denegación de servicio a ti mismo. Con bulkheads, el exceso se descarta como respuestas 503 rápidas. Es la diferencia operativa más importante frente a un thread pool.
¿Por qué mi número de hilos es tan bajo después de migrar?
Porque la métrica jvm.threads.live pasa a contar únicamente carriers y platform threads. Bajar de doscientos hilos a una docena es la señal de que la migración funciona. A partir de ahí hay que medir peticiones en vuelo, no hilos.
¿Los Virtual Threads tienen prioridades?
No. El método setPriority no tiene efecto, siempre son NORM_PRIORITY y siempre son daemon, de modo que setDaemon(false) lanza excepción.
¿Un Virtual Thread puede reanudarse en otro carrier?
Sí, y ocurre constantemente. Nunca hay que construir lógica que dependa de la identidad del carrier thread.
¿Qué ocurre con la entrada/salida de ficheros?
No provoca desmontaje. La JVM compensa añadiendo carriers temporalmente, hasta el máximo del scheduler. El resultado es correcto, pero la ganancia es mucho menor que con entrada/salida de red.
¿Esto aplica a Kotlin, Scala o Clojure?
Sí, porque es una característica de la JVM y no del lenguaje. Todos los lenguajes de la plataforma se benefician sin cambios. Kotlin puede incluso despachar sus corrutinas sobre virtual threads.
¿Cuál es la forma más rápida de saber si me compensa migrar?
Calcular el tamaño del pool dividido por la latencia mediana y compararlo con el pico de peticiones por segundo observado. Si ambos números se parecen, el pool es el cuello de botella y la migración compensa. Si la CPU ya está por encima del 70 por ciento, no.
El futuro de la concurrencia en Java
Java se pasó veinte años enseñando que los hilos son un bien preciado. Loom se pasó ocho dejando esa lección obsoleta. Lo que queda no es una API nueva que aprender, sino una antigua que dejar de rodear con apaños.
Los tres cambios
| Cambio | De | A |
|---|---|---|
| Mental | «Los hilos escasean, agrúpalos» | «Los hilos son baratos, uno por tarea» |
| Arquitectónico | Un límite global: el pool | Un límite por recurso: bulkheads |
| Cultural | «Bloquear es pecado, hazlo reactivo» | «Bloquear está bien; lo reactivo es para streaming y backpressure» |
El árbol de decisión maestro
El capítulo 6 traía un árbol para decidir la estrategia de ejecución de una tarea concreta. Este es el otro: elige el modelo de concurrencia de un servicio entero, y por eso aquí sí entran el streaming y lo reactivo.
Seis preguntas para elegir modelo de concurrencia
-
¿La carga es intensiva en CPU?
- Sí Pool de platform threads dimensionado al número de núcleos, o ForkJoinPool para divide y vencerás El paralelismo sigue limitado por el hardware, y el pool conserva la expropiación que los virtual threads no tienen.
- No · dominada por E/S Pasa a la pregunta 02
-
¿Tienes Java 21 o superior?
- No Pool dimensionado con cola acotada y CallerRunsPolicy, y planifica la actualización El cálculo con la ley de Little del capítulo 3 es el caso de negocio que necesitas para justificarla.
- Sí Pasa a la pregunta 03
-
¿Necesitas backpressure de extremo a extremo o streaming?
- Sí WebFlux o Reactor SSE, RSocket, flujos infinitos y request(n) entre fronteras de proceso siguen siendo su terreno exclusivo.
- No Pasa a la pregunta 04
-
¿Millones de conexiones ociosas con un presupuesto de memoria estricto?
- Sí WebFlux, o Netty directamente Un millón de WebSockets ociosos cabe en menos memoria sin un hilo por conexión.
- No Pasa a la pregunta 05
-
¿Hay frames nativos o JNI inevitables en la ruta caliente?
- Sí Mide el pinning con JFR antes de decidir, y aísla lo nativo en un pool de platform Si todos los carriers se bloquean, el throughput puede ser peor que con un pool.
- No Pasa a la pregunta 06
-
¿Puedes añadir bulkheads por recurso?
- No Quédate con el pool de momento: construye el backpressure primero y migra después Quitar tu único limitador sin poner otro convierte cualquier pico en una caída del servicio de destino.
- Sí Virtual Threads spring.threads.virtual.enabled=true, un Semaphore por recurso, timeouts en todas partes y alertas de pinning con JFR.
Recomendaciones por perfil
| Si eres… | Haz esto |
|---|---|
| Un equipo en Java 8 u 11 | La actualización a Java 21 ya tiene un caso de negocio cuantificable. Usa el cálculo con la ley de Little del capítulo 3 para escribirlo. |
| Estás en Java 17 | Ve directo a Java 25 LTS: consigues virtual threads, sin pinning por synchronized y Scoped Values definitivos de un solo salto. |
| Estás en Java 21 | Activa el flag en tu servicio más dominado por E/S, audita synchronized alrededor de entrada/salida y planifica el salto a 24 o superior. |
| Tienes WebFlux funcionando bien | No cambies nada. Usa virtual threads en los bordes bloqueantes y evalúa MVC para servicios nuevos. |
| Empiezas un servicio nuevo | Spring Boot + MVC + virtual threads es el valor por defecto. Ve a lo reactivo solo si el árbol de decisión te lleva ahí. |
| Tu carga es de CPU | Este artículo no va contigo. Optimiza algoritmos, localidad de datos y vectorización. |
| Preparas entrevistas | Los capítulos 5 y 6 son los que caen. Prepárate para explicar mount/unmount, pinning y por qué mejora el p99 pero no el p50. |
Hacia dónde va esto
La hoja de ruta que le queda a Loom es legible: finalizar la concurrencia estructurada —le hace falta, cinco previews son muchas—, reducir los casos de pinning conforme madure la Foreign Function API, mejorar el profiling para heaps de millones de hilos y, sobre todo, que los ecosistemas de librerías abandonen sus supuestos sobre thread pools.
Los frameworks que interioricen que los hilos son gratis parecerán una generación por delante de los que no.
Referencias
Todo lo que se afirma en esta guía se puede contrastar en alguna de estas fuentes.
JEPs
| JEP | Título | Versión | Estado |
|---|---|---|---|
| JEP 425 | Virtual Threads (Preview) | 19 | Sustituida |
| JEP 436 | Virtual Threads (Second Preview) | 20 | Sustituida |
| JEP 444 | Virtual Threads | 21 | Definitiva |
| JEP 446 | Scoped Values (Preview) | 21 | Sustituida |
| JEP 453 | Structured Concurrency (Preview) | 21 | Sustituida |
| JEP 462 | Structured Concurrency (Second Preview) | 22 | Sustituida |
| JEP 464 | Scoped Values (Second Preview) | 22 | Sustituida |
| JEP 480 | Structured Concurrency (Third Preview) | 23 | Sustituida |
| JEP 481 | Scoped Values (Third Preview) | 23 | Sustituida |
| JEP 487 | Scoped Values (Fourth Preview) | 24 | Sustituida |
| JEP 491 | Synchronize Virtual Threads without Pinning | 24 | Definitiva |
| JEP 499 | Structured Concurrency (Fourth Preview) | 24 | Sustituida |
| JEP 505 | Structured Concurrency (Fifth Preview) | 25 | Preview |
| JEP 506 | Scoped Values | 25 | Definitiva |
| JEP 519 | Compact Object Headers | 25 | Definitiva |
| JEP 509 | JFR CPU-Time Profiling (Experimental) | 25 | Experimental |
Documentación oficial
- Java Platform Documentation — Virtual Threads
- API de java.lang.Thread
- Resumen del paquete java.util.concurrent
- Java Language Specification — Threads and Locks (JLS §17)
- Java Virtual Machine Specification
Project Loom y OpenJDK
- Project Loom — OpenJDK
- Archivo de la lista de correo loom-dev
- Ron Pressler — State of Loom
- OpenJDK Wiki — Networking I/O with Virtual Threads
- JDK Bug System
Spring
- Spring Boot Reference — Task Execution and Scheduling
- Spring Boot 3.2 Release Notes — soporte de Virtual Threads
- Spring Framework — Task Execution and Scheduling
- Referencia de Reactor — Schedulers
Continúa en este sitio
- Java Performance La página pilar de rendimiento y escalabilidad de la JVM.
- Observabilidad en Microservicios El montaje de Micrometer y OpenTelemetry del que depende esta migración.
- Arquitectura Hexagonal desde cero Aislar el dominio para que cambios de infraestructura como este se queden en los bordes.
- Consultoría Spring Boot Arquitectura, rendimiento y buenas prácticas sobre Spring Boot.
Para seguir leyendo
- Brian Goetz y otros — Java Concurrency in Practice. Sigue siendo el texto definitivo de la era del pooling.
- Doug Lea — A Java Fork/Join Framework (2000). El artículo original de
ForkJoinPool. - Rob Pike — Concurrency is not Parallelism (2012).
- Neil Gunther — Guerrilla Capacity Planning. La ley de Little y la Universal Scalability Law aplicadas.
¿Tu servicio Java está limitado por el thread pool y no por la CPU?
Te ayudo a medirlo, a decidir si los Virtual Threads compensan en tu caso y a ejecutar la migración con backpressure y observabilidad desde el primer día.