Java

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.

Publicado el 42 min de lectura Nivel avanzado Por
  • Java
  • Java 21
  • Java 25
  • Project Loom
  • Virtual Threads
  • Concurrencia
  • Spring Boot
  • Rendimiento
Ilustración del artículo: Virtual Threads en Java 21 vs ExecutorService

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.

Veinte años de doctrina que caducan de golpe
La regla antiguaPor qué existíaQué la sustituye
«Los hilos son caros, agrúpalos en pools»1 MB de stack reservado y un objeto del kernel por hiloLos hilos son baratos: uno por tarea
«Dimensiona el pool con cores × (1 + espera/servicio)»El pool era el recurso escasoEl pool desaparece: dimensiona el recurso de destino
«Bloquear es pecado, hazlo reactivo»Un hilo bloqueado desperdiciaba un hilo del sistema operativoBloquear vuelve a estar bien: cuesta un objeto en heap
«Usa ThreadLocal para el contexto de petición»Los hilos eran pocos y longevosSigue funcionando, pero no escala a millones → Scoped Values
«Envuélvelo todo en CompletableFuture»Era la única forma de no bloquearCódigo secuencial + Structured Concurrency

Las tres frases que importan

  1. Los virtual threads hacen que la entrada/salida bloqueante escale. No aportan nada al trabajo intensivo de CPU.
  2. No aceleran una petición aislada. Eliminan el tiempo de cola, que es lo que realmente destroza tu p99.
  3. 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 ilustrativo

Desglose representativo de un endpoint de checkout en una arquitectura de microservicios. En rojo, tiempo de espera; en azul, CPU real.

  • Deserializar petición 2 ms
  • Servicio de auth (HTTP) 40 ms
  • CPU · validar y mapear 1 ms
  • Consulta PostgreSQL 65 ms
  • CPU · reglas de dominio 3 ms
  • Servicio de precios (HTTP) 55 ms
  • Publicar en Kafka 30 ms
  • CPU · serializar respuesta 2 ms
  • Kernel y framework 12 ms
Valores elegidos para explicar la forma de la curva. No son una medición. De 210 ms, unos 8 ms son CPU. Los otros 202 ms son un hilo sentado en una cola de espera del kernel, secuestrando un megabyte de stack reservado y sin aportar nada.

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

TendenciaEfecto sobre la concurrencia
MicroserviciosUna acción de usuario se convierte en 5–30 saltos de red. Cada salto es una espera bloqueante nueva.
Cloud y KubernetesLos 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 RabbitMQLos consumidores son longevos, están casi siempre ociosos y quieres miles.
APIs de IA y LLMLas 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 WebSocketsDecenas de miles de conexiones ociosas el 99,99 % del tiempo.
Presión FinOpsCada 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.

  1. Silicio core · HT · context switch
  2. Hilo del SO stack · scheduler
  3. Platform Thread mapeo 1:1
  4. ExecutorService el pool como apaño
  5. Project Loom continuations
  6. Virtual Thread mapeo M:N
  7. Spring Boot producción
El recorrido del artículo: de cómo el procesador ejecuta una instrucción a cómo Spring Boot atiende diez mil peticiones concurrentes.

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.

  • CPU
  • Scheduler
  • Stack
  • Heap

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)

Paquete de CPU un socket
Core físico 0 ALU · FPU · caché L1/L2 · pipeline
Core físico 1 ALU · FPU · caché L1/L2 · pipeline
SMT / HyperThreading registros duplicados, unidades de ejecución compartidas
hilo HW 0 juego de registros A
hilo HW 1 juego de registros B
hilo HW 2 juego de registros C
hilo HW 3 juego de registros D
availableProcessors() o tu cuota de CPU del cgroup en Kubernetes
4 el techo real de paralelismo
Del silicio al número que la JVM usa para dimensionar su scheduler. Las unidades de ejecución son compartidas: dos hilos hardware no son dos cores.
TérminoQué esCuántas cosas ejecuta en el mismo instante
Core físicoUn motor de ejecución completo: ALU, FPU, caché L1/L2 y pipeline1 flujo de instrucciones
Hilo hardware (SMT)Un juego de registros y contador de programa duplicado que comparte las unidades de ejecución de un core2 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 KubernetesEl techo real de paralelismo
JAVA
// 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)

Compartido por todos los hilos
Heap todos los objetos · gestionado por el GC
Metaspace metadatos de clases
Code cache métodos compilados por el JIT
Descriptores ficheros y sockets
Privado de cada hilo
Hilo 1 stack 1 MB · registro PC · registros
Hilo 2 stack 1 MB · registro PC · registros
Hilo N stack 1 MB · registro PC · registros
Un proceso reparte su memoria en dos categorías. Todo lo compartido vive en el heap; lo privado de cada hilo es su stack, y ahí está el problema de escala.

Stack frente a heap: dónde vive realmente un hilo

StackHeap
PropietarioUn hilo, en exclusivaTodo el proceso
ContenidoFrames: variables locales, pila de operandos, direcciones de retornoObjetos y arrays
AsignaciónLIFO, mover un puntero — gratisGestionada 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
CrecimientoFijo en la creación → StackOverflowErrorElástico → OutOfMemoryError
Quién puede moverloNadie: es memoria del SO ancladaEl 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 StackChunk de ~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.

Dónde vive la pila de ejecución en cada modelo.

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
  1. Hilo A Kernel

    read() sobre un socket: todavía no hay datos

    Cambio de modo usuario → kernel, unos 100–300 ns

  2. Kernel Kernel

    Guarda los registros de A en su task_struct

  3. Kernel Kernel

    Mueve A a la cola de espera del socket

    Estado BLOQUEADO

  4. Kernel Kernel

    Elige B de la run queue y cambia las tablas de páginas

    Invalida o etiqueta la TLB

  5. Kernel Hilo B

    Restaura los registros de B: pasa a EJECUTANDO

    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.

  6. Kernel Hilo A

    Llegan los datos por interrupción: A vuelve a EJECUTABLE

Un context switch completo. El coste publicado son 1–10 µs; el coste real es peor porque el hilo entrante llega con las cachés vacías.

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.

Órdenes de magnitud en un servidor x86-64 moderno
OperaciónCoste típicoRelativo
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⁸
Valores de referencia habituales en la literatura de rendimiento, no una medición propia. Sirven para comparar órdenes de magnitud, no como cifras exactas de tu hardware.

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.

  • ExecutorService
  • ForkJoinPool
  • Future

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

Era 1 · Manual 1996–2004
  1. Java 1.0
    Thread, Runnable, synchronized, wait/notify
  2. Java 1.4
    Selectores NIO
Era 2 · Pooling 2004–2017
  1. Java 5
    java.util.concurrent ExecutorService, Callable, Future, ConcurrentHashMap
  2. Java 7
    ForkJoinPool y robo de trabajo
  3. Java 8
    CompletableFuture y streams paralelos
  4. Java 9
    Flow API · Reactive Streams
Era 3 · Virtual 2017–hoy
  1. 2017
    Se anuncia Project Loom
  2. Java 19–20
    Virtual Threads en preview
  3. Java 21 LTS
    JEP 444 definitivo
  4. Java 24
    JEP 491: synchronized deja de provocar pinning
  5. Java 25 LTS
    Scoped Values definitivo
Tres eras de concurrencia en Java. Cada una nace de una limitación de la anterior, no de una moda.

Thread y Runnable: el metal desnudo

JAVA
// 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.

Por qué este patrón murió en producción
ProblemaConsecuencia
No devuelve valorNecesitas estado mutable compartido para recuperar el resultado
No propaga excepcionesLos errores se desvanecen en el UncaughtExceptionHandler
No hay control de ciclo de vidaNada limita cuántos creas
Coste de creación ~50–100 µsInviable por petición
Creación sin límiteOutOfMemoryError: unable to create native thread

Callable y Future: resultados y errores

JAVA
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

  1. Peticiones 10.000 entrando
  2. BlockingQueue aquí es donde muere tu p99
  3. 4 workers platform threads reutilizados
  4. RejectedExecutionHandler abortar, descartar o ejecutar en el llamante
Anatomía de un thread pool. La cola es el componente que casi nadie dimensiona y el que decide tu percentil 99.
JAVA
// 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.

JAVA
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íticas de saturación
PolíticaComportamientoCuándo usarla
AbortPolicy (por defecto)Lanza RejectedExecutionExceptionAPIs que deben fallar rápido con un 503
CallerRunsPolicyEl hilo que envía ejecuta la tareaMejor opción por defecto: backpressure real y automático
DiscardPolicyDescarta en silencioCasi nunca: pérdida silenciosa de datos
DiscardOldestPolicyDescarta la tarea más antiguaMétricas en vivo donde solo importa el «ahora»

Catálogo de factorías

FactoríaConfiguración realRiesgo real
newFixedThreadPool(n)core = max = n, cola sin límiteOOM por crecimiento de la cola; techo duro de throughput
newCachedThreadPool()core = 0, max = MAX_VALUE, SynchronousQueueCreación ilimitada de hilos → OOM en un pico de tráfico
newSingleThreadExecutor()1 hilo, cola sin límiteOrden serie garantizado; una tarea lenta bloquea todo
newScheduledThreadPool(n)Tareas diferidas o periódicasUna excepción no capturada mata en silencio la planificación
newWorkStealingPool()ForkJoinPool, paralelismo = coresDiseñado para CPU; inadecuado para E/S bloqueante
newVirtualThreadPerTaskExecutor()Java 21+, un virtual thread por tareaNo 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.

JAVA
// 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ónLa concurrencia queda limitada por el tamaño del pool
Reutilizar hilos carosEl tiempo de cola domina el p99
Acotar el uso de recursosDimensionar exige conocer ratios de espera/servicio que no tienes
API estándar y bien conocidaEl modelo hilo-por-petición no pasa de unos pocos miles
Excelente para trabajo de CPULos 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.

  • Memoria
  • Ley de Little
  • Capacity planning

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)

JVM
java.lang.Thread objeto en el heap
java.lang.Thread objeto en el heap
java.lang.Thread objeto en el heap
Sistema operativo cada hilo Java arrastra una entidad planificable del kernel
task_struct kernel 8–16 KB + stack 1 MB
task_struct kernel 8–16 KB + stack 1 MB
task_struct kernel 8–16 KB + stack 1 MB
Hardware
core 0
core 1
El mapeo 1:1. Ni la JVM ni tú podéis mover, pausar ni archivar ese task_struct mientras espera una respuesta de red.

Coste 1 · Memoria

ConceptoCoste por hilo10.000 hilos
Stack de usuario (virtual reservado, -Xss x64)1 MB~10 GB virtuales
Stack realmente comprometido (profundidad típica en web)40–200 KB0,4–2 GB residentes
Stack de kernel + task_struct8–16 KB80–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

JAVA
// Ó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 ilustrativo

8 cores · carga de E/S bloqueante de 100 ms · la forma de la curva, no cifras de laboratorio

  • 100 hilos 950 req/s
  • 500 hilos 1.900 req/s Zona óptima: más hilos siguen solapando más esperas.
  • 1.000 hilos 1.850 req/s
  • 2.000 hilos 1.500 req/s Empieza el thrashing del scheduler.
  • 5.000 hilos 900 req/s
  • 10.000 hilos 400 req/s El coste de context switching destruye activamente el throughput.
  • 50.000 hilos OutOfMemoryError: unable to create native thread.
Valores elegidos para explicar la forma de la curva. No son una medición. La curva tiene tres regiones: subida —más hilos solapan más esperas—, meseta —el recurso de destino está saturado— y colapso, donde el coste de cambiar de contexto y la presión de memoria destruyen el throughput hasta que la JVM muere.

Coste 4 · El techo del sistema operativo

BASH
# 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 poolLatencia mediaThroughput máximoRealidad
20050 ms4.000 req/sCómodo
200200 ms1.000 req/sMicroservicio típico
2002 s100 req/sUn servicio lento aguas abajo
20010 s20 req/sUna llamada a una API de LLM
10.000 (virtuales)10 s1.000 req/sMismo 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ó.

  • Historia
  • OpenJDK
  • JEP 444

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

  1. 1996
    Java 1.0 con green threads (M:1) Hilos de usuario multiplexados por la JVM: el antepasado directo de los virtual threads
  2. 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
  3. 2002
    Problema C10K Los servidores deben aguantar diez mil conexiones simultáneas
  4. 2004
    Java 5 y java.util.concurrent El pooling se convierte en doctrina
  5. 2009
    Node.js populariza el event loop Se instala la idea de que bloquear es pecado
  6. 2011
    Java 7 con ForkJoinPool · Go 1.0 lanza las goroutines
  7. 2013
    Reactive Streams y RxJava La era de los callbacks
  8. 2014
    Java 8: CompletableFuture y lambdas
  9. 2017
    Se anuncia Project Loom Ron Pressler, en Oracle, con el equipo de librerías del núcleo de OpenJDK
  10. 2020
    Loom se integra en la línea principal del JDK
  11. 2022
    Java 19: JEP 425, Virtual Threads en preview
  12. 2023
    Java 21 LTS: JEP 444 definitivo
  13. 2025
    Java 24: JEP 491 elimina el pinning por synchronized
  14. 2025
    Java 25 LTS: JEP 506 Scoped Values definitivo
De los green threads de 1996 a los virtual threads de 2023. Los hitos marcados en azul y verde son los que cambian el rumbo.

¿Qué problema estaba resolviendo Oracle realmente?

Hacer que sea sencillo escribir, depurar, perfilar y mantener aplicaciones concurrentes que cumplan los requisitos de hoy.
Objetivo declarado de Project Loom · OpenJDK

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íaLo que se llevó async/reactivePor qué importaba
El hilo como unidad de concurrenciaUna tarea fragmentada en callbacksPodías leer el código de arriba abajo
Trazas de pila con sentidoat reactor.core.publisher.MonoFlatMap$… ×80 framesDepurar incidentes en producción
Depuración paso a pasoBreakpoints en una lambda que se ejecuta después y en otro sitioIncorporación de gente e investigación
try/catch/finallyonErrorResume(), doFinally()Gestión de errores integrada en el lenguaje
Profilers que atribuyen trabajo a una peticiónTrabajo atribuido a reactor-http-nio-3Análisis de rendimiento
Contexto en ThreadLocalObjetos de contexto propagados a manoObservabilidad 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, ThreadLocal y 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/catch y 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.

EntregaQué resuelveEstado en Java 25
Virtual Threads (JEP 444)Hilos baratos, de forma que hilo-por-tarea escalaDefinitivo desde Java 21
Structured Concurrency (JEP 505)Vidas de tareas atadas a un ámbito léxico; sin tareas huérfanasQuinta preview · API rediseñada
Scoped Values (JEP 506)Contexto inmutable y acotado: el sustituto de ThreadLocalDefinitivo 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.

  • Continuation
  • Carrier
  • Mount
  • Pinning
  • JFR

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)

Heap de Java un millón de virtual threads aparcados, ~1 KB cada uno
VT #1 EJECUTANDO
VT #2 APARCADO
VT #3 APARCADO
VT #1.000.000 EJECUTABLE
Scheduler ForkJoinPool en modo FIFO
paralelismo = availableProcessors() por defecto, cuatro en un pod de 4 vCPU
Carrier threads platform threads reales
carrier-0
carrier-1
carrier-2
carrier-3
Hardware
core 0
core 1
core 2
core 3
Un millón de virtual threads, cuatro carrier threads, cuatro cores. El heap guarda el estado, los carriers aportan la ejecución y el scheduler los empareja.

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.

JAVA
// 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
  1. Tu código Virtual Thread

    Thread.ofVirtual().start(tarea)

  2. Virtual Thread Scheduler

    Envía la continuation al scheduler

  3. Scheduler Carrier

    Elige un carrier libre y hace MOUNT

    Thaw: descongela la pila del heap al stack nativo

  4. Virtual Thread Kernel

    socket.read(): llamada bloqueante

  5. Virtual Thread Carrier

    El JDK intercepta el bloqueo y hace UNMOUNT

    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.

  6. Scheduler Carrier

    El carrier queda libre y monta otro virtual thread

    El carrier nunca se queda ocioso

  7. Kernel Scheduler

    Llegan los datos: el poller avisa y encola el VT como EJECUTABLE

  8. Scheduler Carrier

    MOUNT de nuevo, posiblemente en otro carrier

    El código continúa tras socket.read() con la misma pila

El ciclo completo. Las dos reglas que se deducen: un virtual thread no está atado a un carrier, y un virtual thread aparcado no consume ningún hilo del sistema operativo.

Ciclo de vida

Máquina de estados con 6 estados: NUEVO, EJECUTABLE, MONTADO, APARCADO, PINNED, TERMINADO

NUEVO

inicio

Creado 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

fin

La tarea retornó o lanzó. El objeto queda listo para el recolector.

Los seis estados de un virtual thread. APARCADO es donde vive el 99 % del tiempo en un servicio real; PINNED es el único que hay que vigilar.

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.

PropiedadValor por defectoPropiedad de sistema
Paralelismo (carriers ejecutando a la vez)availableProcessors()jdk.virtualThreadScheduler.parallelism
Tamaño máximo del pool de carriers256jdk.virtualThreadScheduler.maxPoolSize
Máximo del pool de unparker256jdk.unparker.maxPoolSize
Modo de planificaciónFIFO con robo de trabajo
Expropiación (preemption)Ninguna. Solo cooperativa
JAVA
// 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()Reimplementado sobre el temporizador de Loom
Socket y ServerSocketReescrito internamente sobre NIO
java.net.http.HttpClient síncronoTotalmente compatible con Loom
BlockingQueue, ReentrantLock, SemaphoreTodo java.util.concurrent
CompletableFuture.get(), CountDownLatch.await()
Object.wait()Sí, desde Java 24Antes provocaba pinning · JEP 491
synchronized que bloqueaSí, desde Java 24Provoca pinning en Java 21–23
No se desmonta
E/S de ficheros (FileChannel, Files.read*)NoUsa un carrier de compensación: la JVM añade hilos temporalmente
Selector.select()Parcialmente
Frame nativo o JNI en la pilaPinningInevitable: hay que cambiar la librería
Llamada de la Foreign Function APIPinningIgual
Inicializador de clase en cursoPinningBreve, 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

  1. Bloqueo normal el VT se bloquea leyendo un socket
  2. Carrier libre ejecuta otro virtual thread
Comportamiento sano: el carrier se libera y sigue trabajando.

Diagrama de flujo: Bloqueo con frame nativo → Carrier bloqueado → Inanición total

  1. Bloqueo con frame nativo el VT no puede desmontarse
  2. Carrier bloqueado no ejecuta nada más
  3. Inanición total ni los virtual threads sanos pueden ejecutarse
Pinning: si ocurre en tantos virtual threads como carriers hay, la JVM entra en inanición total y deja de progresar.

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 pinningJava 21–23Java 24 / 25
Bloqueo dentro de synchronizedPinningResuelto
Object.wait()PinningResuelto
Entrada a un monitor con contenciónPinningResuelto
Método nativo o frame JNIPinningSigue igual
Foreign Function & Memory APIPinningSigue igual
Inicializador de clasePinningSigue igual
JAVA
// 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

BASH
# 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 JFRQué te dice
jdk.VirtualThreadStart / EndRitmo de creación y tiempo de vida
jdk.VirtualThreadPinnedEl que importa. Traza de pila de cada pinning por encima del umbral
jdk.VirtualThreadSubmitFailedEl scheduler rechazó un envío: muy grave, investígalo ya

Crear virtual threads: las cuatro APIs

JAVA
// 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

ComportamientoPlatform ThreadVirtual Thread
isDaemon()ConfigurableSiempre true: setDaemon(false) lanza excepción
setPriority()Se respeta a título orientativoNo hace nada: siempre NORM_PRIORITY
Nombre por defectoThread-NCadena vacía
stop() / suspend()Obsoletos o eliminadosLanzan UnsupportedOperationException
Aparece en jstackNo: usa jcmd Thread.dump_to_file
PoolingNecesarioAntipatrón

Huella de memoria

Platform ThreadVirtual Thread
Dónde vive el stackMemoria nativa, fuera del heapHeap de Java (objetos StackChunk)
Tamaño inicial1 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 liberaLa terminación del hiloEl 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.

  • Comparativa
  • Ley de Little
  • Semaphore

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.

Cifras derivadas del modelo del capítulo 7, no de una medición propia.

La tabla comparativa completa

Ocho dimensiones, treinta comparaciones
DimensiónExecutorService (pool de platform)Virtual ThreadsGana
Arquitectura
Modelo de hilos1:1 · hilo Java = hilo del SOM:N · muchos VT sobre pocos carriersVT
Quién planificaEl kernel del SOLa JVM (ForkJoinPool, FIFO)VT
Dónde vive el stackMemoria nativa, fuera del heapHeap de Java, elásticoVT
ExpropiaciónSí, el kernel puede desalojarSolo cooperativaPool
Ciclo de vidaLongevo, reutilizadoEfímero, uno por tarea
Memoria
Por hilo ocioso~1 MB reservado / 50–200 KB comprometidos~1 KBVT ~100×
10.000 concurrentes~0,5–2 GB de RSS~10–40 MB de heapVT
1.000.000 concurrentesImposible~1–4 GB de heapVT
Presión sobre el GCBaja: los stacks no son objetosMayor: los stack chunks son objetos vivosPool
CPU
Coste de creación~50–100 µs~1 µsVT ~70×
Coste de cambio1–10 µs (kernel)0,2–0,5 µs (usuario)VT ~10×
Throughput con carga de CPUÓptimoIdéntico, sin ventajaPool
Syscalls por cambio1 o más0VT
Throughput y latencia
Techo con carga de E/Stamaño_pool / latenciaLimitado por CPU o por el destinoVT 10–100×
Techo con carga de CPUcores / tiempo_servicioIdénticoEmpate
p50 sin contenciónIgualIgualEmpate
p99 bajo cargaDominado por el tiempo de colaCercano al tiempo de servicio realVT
Comportamiento ante un picoLa cola crece → timeouts en cascadaSe expande → puede saturar el destinoDepende
Escalabilidad
Máximo práctico de hilos~2.000–10.0001.000.000+VT
Conexiones ociosas (SSE/WebSocket)1 MB cada una: inasumible1 KB cada una: trivialVT
Escalado verticalMalo por encima de unos milesExcelenteVT
Complejidad y operación
Requiere dimensionarCore, max, cola, política, keep-aliveNada en el executorVT
Riesgo de deadlockAlto con pools anidadosMuy bajoVT
BackpressureIncorporado en la colaTienes que añadirloPool
Protección ante sobrecargaRejectedExecutionHandlerManual (Semaphore)Pool
Métricas por hiloCardinalidad acotadaNunca: explosión de cardinalidadPool
Thread dumpsjstackjcmd Thread.dump_to_filePool
Versión mínima de JavaCualquiera21Pool
Rollback de la migraciónTrivial: quitar el flagVT
Madurez de la observabilidad20 años2–3 añosPool
«Gana» indica qué estrategia es preferible en esa dimensión concreta, no en conjunto. Fíjate en que el pool conserva ventaja en todo lo relacionado con contención, previsibilidad y herramientas: eso no es casualidad, es lo que tendrás que reconstruir a mano.

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.

JAVA
// 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

  1. ¿La tarea es intensiva en CPU?

    • 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
  2. ¿Necesitas orden estricto de ejecución o afinidad de hilo?

    • 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
  3. ¿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.
    • Pasa a la pregunta 04
  4. ¿Hay frames nativos o JNI inevitables en la ruta caliente?

    • 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.
Recórrelo por tarea, no por aplicación: un mismo servicio puede tener endpoints de E/S que quieran virtual threads y trabajos de CPU que quieran un pool.

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.

  • Metodología
  • Ley de Little
  • JMH
Los cinco contendientes
ConfiguraciónDefinición
platform-per-tasknew Thread(tarea).start() por tarea, sin límite
fixed-200Executors.newFixedThreadPool(200)
cachedExecutors.newCachedThreadPool()
forkjoinForkJoinPool con paralelismo 7
virtualExecutors.newVirtualThreadPerTaskExecutor()

El harness

JAVA
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 reproducible

8 vCPU · 16 GB · OpenJDK 21 · G1GC · -Xmx4g · tarea = 100 ms de E/S bloqueante + 0,05 ms de CPU

  • fixed-200 108 ms
  • virtual 105 ms
  • cached 110 ms
  • platform-per-task 112 ms
  • forkjoin (7) 1.500 ms Sus 7 workers se bloquean los 100 ms completos: 100 ÷ 7 × 100 ms ≈ 1,5 s.
Cifras derivadas de la ley de Little y de costes por operación publicados, no de una ejecución propia. El harness se incluye para que las reproduzcas.

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 reproducible

8 vCPU · 16 GB · OpenJDK 21 · G1GC · -Xmx4g · tarea = 100 ms de E/S bloqueante + 0,05 ms de CPU

  • virtual 118 ms
  • cached 185 ms ~1.000 hilos vivos, 600 MB–1,1 GB de RSS.
  • platform-per-task 190 ms
  • fixed-200 510 ms Exactamente 5 tandas: ⌈1000/200⌉ × 100 ms. Ley de Little, aritmética, ninguna sorpresa.
  • forkjoin (7) 14.300 ms
Cifras derivadas de la ley de Little y de costes por operación publicados, no de una ejecución propia. El harness se incluye para que las reproduzcas.

10.000 tareas: la divergencia

Makespan con 10.000 tareas

Modelo reproducible

8 vCPU · 16 GB · OpenJDK 21 · G1GC · -Xmx4g · tarea = 100 ms de E/S bloqueante + 0,05 ms de CPU

  • virtual 180 ms ~140 MB de RSS. 10.000 VT sobre 8 carriers.
  • cached ~900 ms Alta varianza: 600–1.400 ms según el thrashing. ~2,2 GB de RSS.
  • platform-per-task ~900 ms ~2,2 GB de RSS: al borde del muro de memoria.
  • fixed-200 5.050 ms p99 ≈ 4,9 s para una carga cuyo tiempo de servicio real es de 100 ms.
  • forkjoin (7) 143.000 ms Dos minutos y medio.
Cifras derivadas de la ley de Little y de costes por operación publicados, no de una ejecución propia. El harness se incluye para que las reproduzcas. Este es el régimen donde se decide la discusión. 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 reproducible

8 vCPU · 16 GB · OpenJDK 21 · G1GC · -Xmx4g · tarea = 100 ms de E/S bloqueante + 0,05 ms de CPU

  • virtual 1.150 ms ~520 MB de heap. 100.000 VT concurrentes.
  • fixed-200 50.200 ms Cincuenta segundos.
  • forkjoin (7) ~24 min
  • platform-per-task OutOfMemoryError: unable to create native thread, entre 15.000 y 40.000 hilos.
  • cached El mismo fallo, y de forma no determinista bajo carga.
Cifras derivadas de la ley de Little y de costes por operación publicados, no de una ejecución propia. El harness se incluye para que las reproduzcas.

Resumen

Tareasfixed-200virtualMejoraRSS de virtual
100108 ms105 ms1,0×85 MB
1.000510 ms118 ms4,3×110 MB
10.0005.050 ms180 ms28×140 MB
100.00050.200 ms1.150 ms44×520 MB
Modelo reproducible sobre 8 vCPU. La columna «Mejora» es la relación entre ambos makespan.

Qué significan realmente los números

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

  • JEP 505
  • JEP 506
  • JEP 491
  • LTS

La tabla de estado que necesitas para tu RFC

CaracterísticaJEPEstado en Java 25¿Requiere --enable-preview?¿Producción?
Virtual Threads444Definitivo desde Java 21No
Scoped Values506Definitivo en Java 25No
Structured Concurrency505Quinta previewNo: la API sigue cambiando
synchronized sin pinning491Definitivo desde Java 24No
Compact Object Headers519Definitivo en Java 25NoSí (beneficio indirecto)
Profiling de CPU con JFR509Experimental (Linux)Requiere flagSolo 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.

JAVA
// 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
// 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
}
El Joiner decide la política
JoinerSemánticaCaso de uso
awaitAllSuccessfulOrThrow()Espera a todas y propaga el primer fallo. No agrega resultados: cada Subtask se consulta despuésFan-out con tipos distintos, que es el caso más común
allSuccessfulOrThrow()Como el anterior, pero join() devuelve un Stream de subtareasFan-out homogéneo: N tareas que devuelven el mismo tipo
anySuccessfulResultOrThrow()Devuelve el primer éxito y cancela el restoCarreras entre réplicas o proveedores espejo
awaitAll()Espera a todas y nunca cancelaRecolección best-effort, donde un fallo parcial es aceptable
Joiner propioTu política: quórum, primeros N…Avanzado
JAVA
// 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
// 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));
ThreadLocalScopedValue
MutabilidadMutable desde cualquier sitioInmutable
Tiempo de vidaHasta remove(), o para siempreExactamente el bloque que lo encierra
HerenciaSe copia por hilo hijoSe comparte por referencia, sin copia
Memoria con 1M de hilos1.000.000 de copias1 instancia
Riesgo de fugaAltoNulo por construcción
Acceso sin enlazarDevuelve null en silencioLanza NoSuchElementException: falla en voz alta
Encaja con concurrencia estructuradaA duras penasDiseñ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>

  1. Java 21 LTS
    Virtual Threads definitivos Structured Concurrency y Scoped Values en preview · synchronized todavía provoca pinning
  2. Java 22–23
    Iteran las previews Ajustes del scheduler y de los temporizadores
  3. Java 24
    JEP 491: synchronized deja de provocar pinning El mayor obstáculo para adoptar Loom en código heredado desaparece
  4. Java 25 LTS
    Scoped Values definitivos Structured Concurrency en su quinta preview con API nueva · Compact Object Headers · profiling de CPU con JFR
Cuatro versiones convirtiendo una funcionalidad terminada en una funcionalidad operable.

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.

  • Spring Boot 3.2+
  • Tomcat
  • HikariCP
  • Micrometer
PROPERTIES
# application.properties — Spring Boot 3.2+ sobre Java 21+

spring.threads.virtual.enabled=true

Qué configura realmente ese flag

ComponenteCon el flag desactivadoCon el flag activado
Peticiones en TomcatThreadPoolExecutor, 200 hilosVirtualThreadExecutor: un VT por petición
Peticiones en JettyQueuedThreadPoolExecutor de virtual threads
UndertowWorker XNIONo se configura
@AsyncThreadPoolTaskExecutor (8 de core)SimpleAsyncTaskExecutor con virtual threads
@ScheduledThreadPoolTaskScheduler con 1 hiloSimpleAsyncTaskScheduler: ahora concurrente
Listeners de RabbitMQ y KafkaPlatform threadsVirtual threads
Spring Data RedisPlatform threadsVirtual threads
WebFlux / Reactor NettyEvent loopSin cambios: sigue el event loop
Beans ThreadPoolTaskExecutor propiosTu configuraciónSin cambios: manda tu bean

Tomcat en detalle

Diagrama de flujo: 10.000 peticiones → VirtualThreadExecutor → DispatcherServlet → max-connections

  1. 10.000 peticiones
  2. VirtualThreadExecutor un VT por petición, sin cola
  3. DispatcherServlet tu código de siempre, bloqueante
  4. max-connections el control de admisión que sustituye a threads.max
Con el flag activado, la petición 10.000 se ejecuta al momento en lugar de esperar en la cola de accept. Pero el límite que te protegía ha desaparecido.
PROPERTIES
# 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

  1. ¿Hay entrada/salida bloqueante en tu stack? (JDBC, JPA, HTTP bloqueante)

  2. ¿Estás en Java 21 o superior?

    • No MVC con pool dimensionado, y planifica el salto a Java 21
  3. ¿Necesitas streaming, backpressure de extremo a extremo o cien mil conexiones ociosas?

    • WebFlux El backpressure y el streaming siguen siendo su terreno, y no hay equivalente en el modelo bloqueante.
La respuesta cambia por servicio, no por organización.
JAVA
// 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

JAVA
@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

JAVA
@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

PROPERTIES
# 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

  1. 10.000 peticiones concurrentes
  2. 10.000 virtual threads ~15 MB de heap
  3. HikariCP · 40 conexiones 9.960 hilos bloqueados aquí
  4. PostgreSQL max_connections = 100
El diagrama más importante del capítulo: los virtual threads no han hecho más rápida tu base de datos, han quitado todo obstáculo para apuntarle diez mil peticiones concurrentes.

Observabilidad

MétricaPor qué importa ahora
hikaricp.connections.pendingLa 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.liveAhora 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.encoladosTus propios bulkheads: los tienes que instrumentar tú
Peticiones activas en http.server.requestsPeticiones 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.

  • Playbook
  • Canary
  • Bulkhead

Diagrama de flujo: Fase 0 · Medir → Fase 1 · Auditar → Fase 2 · Preproducción → Fase 3 · Acotar → Fase 4 · Canary → Fase 5 · Redimensionar

  1. Fase 0 · Medir ¿es el pool el cuello de botella de verdad?
  2. Fase 1 · Auditar synchronized · JNI · ThreadLocal · @Scheduled
  3. Fase 2 · Preproducción flag + carga + JFR de pinning
  4. Fase 3 · Acotar Semaphore por destino · Hikari · timeouts
  5. Fase 4 · Canary 5 % → 25 % → 50 % → 100 %
  6. Fase 5 · Redimensionar reduce pods y cobra el ahorro
El orden importa: acotar los destinos (fase 3) va antes que el despliegue progresivo (fase 4), no después.

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.

PreguntaCó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

BASH
# 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
HallazgoJava 21–23Java 24–25Acción
synchronized + E/SPinningSin problemaActualizar, o ReentrantLock
synchronized + CPU puro y cortoBienBienDéjalo
ThreadLocal por peticiónFuncionaFuncionaVale; plantea ScopedValue más adelante
ThreadLocal cacheando un objeto pesadoN copiasN copiasArréglalo ya: singleton o pool de objetos
ThreadPoolTaskExecutor explícitoIntactoIntactoDecide bean a bean: CPU se queda, E/S se convierte
parallelStream() + bloqueoBugBugSustituir por executor de virtual threads
JNI en la ruta calientePinningPinningAislar tras un pool de platform
@Scheduled no reentranteAhora concurrenteAhora concurrenteAñadir ShedLock o un lock propio

Fase 2 · Validar en preproducción

BASH
# 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
La tabla de comparación a rellenar. No sigas sin ella.
MétricaAntesDespuésCriterio de aceptación
Throughput a la concurrencia objetivo≥ antes
Latencia p50≈ antes (±10 %)
Latencia p99Debe 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.pendingVigí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

JAVA
/**
 * 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

JAVA
// 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

AntesDespuésNotas
newFixedThreadPool(n) para E/SnewVirtualThreadPerTaskExecutor()Añade un Semaphore
newCachedThreadPool()newVirtualThreadPerTaskExecutor()Mejor en todos los sentidos
newFixedThreadPool(cores) para CPUDéjaloMigrar 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)ReentrantLockInnecesario en Java 24+
ThreadLocal de contexto propioScopedValue (Java 25)El contexto del framework puede quedarse
parallelStream() + bloqueoExecutor de virtual threadsArregla un bug latente
Cadena de CompletableFuture bloqueantesCódigo secuencial en un virtual threadGran 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.

  • Producción
  • Checklist
  • Arquitectura

1 · Nunca hagas pool de virtual threads

JAVA
// 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

JAVA
// 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

JAVA
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

JAVA
// 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)

Borde · control de admisión decide cuánto trabajo entra en el sistema
Rate limiter
server.tomcat.max-connections
Aplicación · concurrencia libre aquí los hilos son gratis y no hay que racionarlos
1 virtual thread por petición sin pool, sin cola, sin dimensionado
Bulkheads · uno por recurso cada destino según SU capacidad real, no según una media
Semaphore(40) base de datos
Semaphore(100) pagos
Semaphore(20) modelo LLM
Pool(cores) trabajo de CPU
Destinos
PostgreSQL
API de pagos
API de modelos
CPU local
Tres capas, tres responsabilidades. Es la forma que resume toda la lección arquitectónica de la migración.

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.

  • Antipatrones
  • Troubleshooting

1 · Hacer pool de virtual threads

JAVA
// 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

JAVA
// 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

JAVA
// 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

JAVA
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

JAVA
// 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

JAVA
// 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.


Referencia rápida
ErrorSíntomaSolución
Pool de virtual threadsNinguna mejora tras migrarnewVirtualThreadPerTaskExecutor() + Semaphore
ThreadLocal pesadoOOM; el heap crece con el número de hilosScopedValue o reserva local
synchronized + E/S en Java ≤23Poco throughput y mucho VirtualThreadPinnedReentrantLock, o Java 24+
E/S de ficherosEl pool de carriers crece en silencioCuéntalo con ello; pool de platform para lotes de fichero
Trabajo de CPU sobre virtual threadsSin ganancia y peor localidad de cachéPool de platform dimensionado
Sorpresa daemonTareas que nunca llegan a ejecutarsejoin() o try-with-resources
Sin timeoutsBloqueo en cascada ante un destino colgadoTimeouts de conexión y lectura en todas partes
Métricas por hiloSe cae el backend de métricasAgregar siempre; nunca etiquetar por hilo
Cuello de botella desplazadoTimeouts de Hikari y p99 de 30 sDimensionar, fallar rápido, bulkheads
Fan-out anidado429 del proveedor o caída del destinoUn Semaphore compartido
Solape en @ScheduledCorrupción de datosShedLock 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.

  • Retail
  • Spring Boot
  • Black Friday

Diagrama de flujo: Gateway → checkout-orchestrator → 5 microservicios + PostgreSQL

  1. Gateway
  2. checkout-orchestrator el servicio que migramos
  3. 5 microservicios + PostgreSQL camino crítico ~400 ms · pago externo 250 ms
Un POST /checkout se abre en seis destinos. El camino crítico son ~400 ms de trabajo real.

Antes

JAVA
@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

JAVA
@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(); }
    }
}
PROPERTIES
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 %
Medido en producción sobre el mismo hardware y con el mismo tráfico. La reducción de coste viene de necesitar un tercio de los pods, no de una tarifa distinta.

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.

  • WebFlux
  • Kotlin
  • Go
  • Little
  • Amdahl

Virtual Threads frente a WebFlux

DimensiónVirtual ThreadsWebFlux / Reactor
Modelo de programaciónImperativo, secuencialDeclarativo, pipelines funcionales
Curva de aprendizajeHorasSemanas o meses
Trazas de pilaCompletas y útilesInternos de Reactor, 80+ frames
Depuración paso a pasoFuncionaPrácticamente inviable
try/catch/finallyNativoonErrorResume / doFinally
BackpressureManual (Semaphore)Integrado, de extremo a extremo
Streaming (SSE, chunked)Se puede, no es idiomáticoNativo
Drivers bloqueantesTodo funcionaEnvenenan el event loop
Memoria con 100k concurrentes~500 MB de heap~100 MB de heap
Techo de throughput brutoMuy altoLigeramente superior
Incorporación de genteCualquier desarrollador JavaEspecialistas
Operadores de composiciónCódigo normalflatMap, zip, retryWhen, window

Virtual Threads frente a CompletableFuture

JAVA
// 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);
}
CompletableFutureVirtual Threads
Composición sin bloqueoSu razón de existirBloqueas, pero es barato
LegibilidadSe degrada rápido pasadas 3 etapasLineal
Gestión de erroresDesenvolver CompletionExceptiontry/catch normal
CancelaciónDébil y se pierde sin avisarSólida con concurrencia estructurada
Hilos consumidos0 mientras espera1 virtual thread (~1 KB)
Integración con APIs de callbacksIdealNecesita 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 ThreadsCorrutinas de Kotlin
ImplementaciónContinuations a nivel de JVMTransformación CPS a nivel de compilador
Cambios en el lenguajeNingunoPalabra clave suspend
«Funciones de color»No: sirve cualquier métodoSí: suspend es contagioso
Librerías existentesCualquier librería bloqueanteNecesita envoltorios o Dispatchers.IO
Concurrencia estructuradaEn preview (JEP 505)Madura desde 2018
CancelaciónBasada en interrupcionesCooperativa y de primera clase: superior
Flujos / streamsNo incluidoFlow: excelente
DisponibilidadTodos los lenguajes de la JVMSolo Kotlin

Virtual Threads frente a las goroutines de Go

Virtual Threads (Java)Goroutines (Go)
ModeloM:N sobre carrier threadsM:N sobre hilos del SO (GMP)
Stack inicial~1 KB en el heap2 KB, contiguo, crece copiando
SchedulerForkJoinPool, FIFORuntime propio con robo de trabajo
ExpropiaciónSolo cooperativaAsíncrona desde Go 1.14
ComunicaciónBlockingQueue, java.util.concurrentCanales y select
CancelaciónInterrupciones o concurrencia estructuradacontext.Context: idiomático y universal
Protección ante fugasConcurrencia estructurada (preview)Las fugas de goroutines son un problema conocido
Ecosistema25 años de librerías bloqueantes que se benefician sin recompilarAsíncrono de nacimiento

CPU Bound frente a IO Bound

CPU BoundIO Bound
Cuello de botellaCiclos de procesadorEsperar a un sistema externo
EjemplosCifrado, compresión, inferencia, procesado de imágenesHTTP, JDBC, ficheros, Kafka, Redis, APIs de LLM
Uso de CPU90–100 %5–30 %
Número óptimo de hilos≈ número de núcleosTantos como tolere el destino
¿Ayudan los virtual threads?NoMuchísimo
Herramienta correctaPool de platform dimensionado o ForkJoinPoolVirtual threads con bulkheads
JAVA
// 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 paralelaCon 8 núcleosCon 64Con infinitos
50 %1,78×1,97×
75 %2,91×3,77×
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 sabesLo que buscasCálculo
Pool de 200, latencia 250 msThroughput máximo200 / 0,25 = 800 req/s
Necesito 5.000 req/s con latencia 200 msConcurrencia necesaria5000 × 0,2 = 1.000 hilos
300 hilos, 4.000 req/sLatencia implícita300 / 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

  1. Productor
  2. Fan-out ilimitado un virtual thread por tarea
  3. Destino sobrecargado 429, timeouts, caída
Sin backpressure: los virtual threads convierten un pico de tráfico en un ataque de denegación de servicio contra tus propios destinos.

Diagrama de flujo: Productor → Semaphore / Bulkhead → Destino estable

  1. Productor
  2. Semaphore / Bulkhead sin permiso → 503 rápido
  3. Destino estable trabaja a su capacidad real
Con backpressure explícito: el exceso se descarta rápido y el destino trabaja a su capacidad real.
MecanismoBackpressureGranularidad
Cola acotada + CallerRunsPolicyImplícitoGlobal
request(n) de Reactive StreamsExplícito, de extremo a extremoPor suscripción
SemaphoreExplícitoPor recurso
Bulkhead de Resilience4jExplícito y con métricasPor recurso
Virtual threads a secasNinguno

Cuándo lo reactivo sigue siendo lo correcto

  1. Backpressure de extremo a extremo cruzando fronteras de proceso: Reactive Streams o RSocket.
  2. Streaming: SSE, respuestas troceadas, flujos infinitos de eventos.
  3. Composición rica de flujos: ventanas, throttling, buffering, retryWhen con backoff.
  4. Números extremos de conexiones con un presupuesto de memoria estricto.
  5. 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.

  • FAQ
  • Entrevistas
¿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.

  • Conclusión
  • Árbol de decisión

Los tres cambios

CambioDeA
Mental«Los hilos escasean, agrúpalos»«Los hilos son baratos, uno por tarea»
ArquitectónicoUn límite global: el poolUn 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

  1. ¿La carga es intensiva en CPU?

    • 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
  2. ¿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.
    • Pasa a la pregunta 03
  3. ¿Necesitas backpressure de extremo a extremo o streaming?

    • 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
  4. ¿Millones de conexiones ociosas con un presupuesto de memoria estricto?

    • 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
  5. ¿Hay frames nativos o JNI inevitables en la ruta caliente?

    • 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
  6. ¿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.
Recórrelo por servicio, no por organización. La mayoría de servicios empresariales de petición y respuesta terminan en la última rama.

Recomendaciones por perfil

Si eres…Haz esto
Un equipo en Java 8 u 11La 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 17Ve directo a Java 25 LTS: consigues virtual threads, sin pinning por synchronized y Scoped Values definitivos de un solo salto.
Estás en Java 21Activa 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 bienNo cambies nada. Usa virtual threads en los bordes bloqueantes y evalúa MVC para servicios nuevos.
Empiezas un servicio nuevoSpring 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 CPUEste artículo no va contigo. Optimiza algoritmos, localidad de datos y vectorización.
Preparas entrevistasLos 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

JEPTítuloVersiónEstado
JEP 425Virtual Threads (Preview)19Sustituida
JEP 436Virtual Threads (Second Preview)20Sustituida
JEP 444Virtual Threads21Definitiva
JEP 446Scoped Values (Preview)21Sustituida
JEP 453Structured Concurrency (Preview)21Sustituida
JEP 462Structured Concurrency (Second Preview)22Sustituida
JEP 464Scoped Values (Second Preview)22Sustituida
JEP 480Structured Concurrency (Third Preview)23Sustituida
JEP 481Scoped Values (Third Preview)23Sustituida
JEP 487Scoped Values (Fourth Preview)24Sustituida
JEP 491Synchronize Virtual Threads without Pinning24Definitiva
JEP 499Structured Concurrency (Fourth Preview)24Sustituida
JEP 505Structured Concurrency (Fifth Preview)25Preview
JEP 506Scoped Values25Definitiva
JEP 519Compact Object Headers25Definitiva
JEP 509JFR CPU-Time Profiling (Experimental)25Experimental

Documentación oficial

Project Loom y OpenJDK

Spring

Continúa en este sitio

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.
Fotografía de Javier García Pérez, Arquitecto de Software Java freelance
SOBRE EL AUTOR

Javier García Pérez

Arquitecto de Software Java freelance · Madrid, España

Más de 15 años diseñando y modernizando plataformas Java críticas en banca, retail, seguros, aerolíneas y administración pública, con proyectos para BBVA, Iberia, Carrefour, Tendam, Ocaso y el Govern de les Illes Balears.

Trabajo a diario con Spring Boot, Quarkus, arquitectura hexagonal, Domain-Driven Design, microservicios event-driven sobre Kafka, AWS, Kubernetes, observabilidad con OpenTelemetry e integración de IA generativa con Spring AI y arquitecturas RAG. Escribo sobre lo que aplico en producción, no sobre teoría.

¿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.