Hilos virtuales en Java
Hilos ligeros planificados por la JVM (Java 21+) para aplicaciones concurrentes de alto rendimiento: qué corrigen y cómo cambian el dimensionado.
Cada capítulo de esta parte del libro hasta ahora ha descrito un hilo de plataforma — un Thread de Java que se asigna uno a uno a un hilo del sistema operativo. Los hilos de plataforma son potentes pero costosos: cada uno ocupa cerca de 1 MB de pila nativa, y el sistema operativo limita un proceso a decenas de miles de ellos. Para trabajo intensivo en CPU eso es suficiente. Para trabajo intensivo en I/O — un servidor web con un hilo por solicitud que mayormente espera a una base de datos — es un límite infranqueable que ha sido la tensión central en el diseño de servidores Java durante dos décadas.
Java 21 introdujo los hilos virtuales para resolver exactamente este caso. Un hilo virtual es un Thread de Java planificado por la JVM (no por el sistema operativo) sobre un pequeño grupo de hilos portadores a nivel del sistema operativo. Son baratos — millones por JVM son habituales — y bloquear en I/O estaciona el hilo virtual sin estacionar el portador. El código tiene el mismo aspecto que antes; el modelo de coste es diferente.
Qué cambia (y qué no)
Los hilos virtuales son java.lang.Threads. La clase es la misma; los métodos son los mismos; Thread.currentThread() sigue funcionando. Lo que es diferente es cómo se planifican y cuánto cuestan:
- Un hilo de plataforma cuesta alrededor de 1 MB de pila nativa y es planificado por el sistema operativo.
- Un hilo virtual cuesta alrededor de 1 KB inicialmente (crece según sea necesario) y es planificado por la JVM.
- Bloquear un hilo de plataforma bloquea el hilo del sistema operativo subyacente.
- Bloquear un hilo virtual estaciona el hilo virtual; el hilo portador del sistema operativo pasa a ejecutar un hilo virtual diferente.
Ese cuarto punto es el titular. Cuando un hilo virtual llama a Socket.read(), Thread.sleep(), BlockingQueue.take(), Lock.lock(), o básicamente cualquier API bloqueante del JDK, la JVM lo desconecta de su portador y el portador toma otro hilo virtual para ejecutar. El hilo virtual bloqueado no consume casi nada mientras espera.
Crear hilos virtuales
Tres formas:
// 1. Direct
Thread t = Thread.ofVirtual().start(() -> doWork());
// 2. Builder
Thread t2 = Thread.ofVirtual().name("vt-", 0).start(this::work); // names "vt-0", "vt-1", ...
// 3. Executor — the production form
try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
es.submit(() -> handleRequest());
}
}La forma con executor es la que usan prácticamente todos los servidores. Entrega un hilo virtual por tarea enviada; no hay ningún grupo que dimensionar porque el grupo de portadores se dimensiona solo.
También puedes obtener un hilo de plataforma cuando necesitas uno específicamente:
Thread t = Thread.ofPlatform().name("compute").start(() -> doCpu());Útil para trabajo genuinamente intensivo en CPU, donde la asignación uno a uno al sistema operativo es lo que quieres.
Cuándo ganan los hilos virtuales
La forma para la que están optimizados los hilos virtuales:
- Muchas tareas concurrentes (cientos, miles, millones).
- Cada tarea pasa la mayor parte del tiempo bloqueada en I/O, colas o bloqueos.
- El trabajo no está dominado por la CPU.
Esta es exactamente la forma de un servidor web: cada solicitud es una tarea que mayormente espera a una base de datos, un servicio externo o el cliente. Con hilos de plataforma, un servidor con 1000 solicitudes lentas concurrentes necesita 1000 hilos de plataforma — 1 GB de pila nativa y una carga significativa en el planificador del sistema operativo. Con hilos virtuales, la misma carga de trabajo se ejecuta sobre 8 portadores aproximadamente; los 1000 hilos virtuales cuestan unos pocos MB en total.
El modelo mental: deja de pensar en grupos de hilos para trabajo de I/O. Envía un hilo virtual por solicitud y deja que el entorno de ejecución gestione el resto.
Cuándo no ganan los hilos virtuales
Algunos casos en que no ayudan o incluso perjudican:
- Trabajo intensivo en CPU. Un hilo virtual realizando cómputo puro no puede estacionarse — tiene que ejecutarse en un portador todo el tiempo. No serás más rápido que el número de portadores, que es el número de CPUs. Para trabajo de CPU, los hilos de plataforma (y fork/join) siguen siendo la herramienta correcta.
- Bloques synchronized alrededor de I/O. Un hilo virtual dentro de
synchronized (obj) { blockingIO(); }se ancla a su portador — la JVM no puede desmontarlo durante la llamada bloqueante porque el monitor está ligado al hilo del sistema operativo. Esta es una trampa real: un servidor que usasynchronizedpara proteger una llamada a base de datos no escalará con hilos virtuales. La solución es usarReentrantLocken su lugar (que la maquinaria de hilos virtuales maneja correctamente). - Almacenamiento
ThreadLocalcon muchos hilos. Los hilos virtuales soportanThreadLocal, pero el número puede dispararse — millones de hilos virtuales × N thread-locals × tamaño del valor = mucha memoria. Java 21 añadió los valores con ámbito (ScopedValue) como alternativa estructurada. - Código que asume que los hilos son escasos (p. ej., que construye una conexión por hilo). Una conexión por hilo virtual es una conexión por solicitud, lo que la base de datos detesta. Usa un pool de conexiones real.
El resumen: los hilos virtuales hacen que la concurrencia intensiva en I/O sea barata, pero no transforman el trabajo intensivo en CPU y exponen rutas de código que se anclan a los portadores.
Anclaje: lo único que hay que vigilar
Un hilo virtual anclado no puede desmontarse. Las dos causas de anclaje:
- Bloques
synchronizedque incluyen una llamada bloqueante. - Llamadas a métodos nativos que bloquean en JNI.
Puedes detectar el anclaje mediante la propiedad del sistema:
java -Djdk.tracePinnedThreads=full ...Si un hilo virtual se bloquea mientras está anclado, la JVM imprime una traza de pila. En producción, la solución es reemplazar synchronized con ReentrantLock alrededor de la región bloqueante. Los JDKs futuros están trabajando en desanclar synchronized (JEP 491 en progreso); por ahora, trata cualquier synchronized alrededor de una llamada de I/O como un antipatrón para hilos virtuales.
¿Qué pasa con wait, notify y join?
Todos funcionan — los hilos virtuales pueden esperar en monitores intrínsecos, ser notificados y ser unidos con join. El entorno de ejecución maneja el estacionamiento y el desmontaje de la manera correcta. La restricción es solo en los bloques sincronizados: mantener el monitor durante una llamada bloqueante dentro del bloque ancla; llamar a wait() para liberar el monitor y estacionar está bien.
synchronized (lock) {
lock.wait(); // OK — releases monitor, parks, no pin
}
synchronized (lock) {
socket.read(buf); // BAD — holds monitor through blocking read; pins
}Dimensionado del grupo — no hay grupo
El cambio conceptual que habilitan los hilos virtuales: deja de dimensionar. Cada executor que has configurado en este libro tenía un parámetro de número de hilos. Con newVirtualThreadPerTaskExecutor, el número es "tantas solicitudes como estén en vuelo." El grupo de portadores (que no configuras directamente) se dimensiona solo en función del número de CPUs; los hilos virtuales son contabilidad.
En un servidor que usa hilos virtuales:
- Los pools de conexiones siguen siendo importantes. Un hilo virtual esperando una conexión está bien; lanzar 10.000 de ellos queriendo un pool de 5 conexiones solo hace visible el cuello de botella.
- Los límites de tasa siguen siendo importantes. Los hilos virtuales eliminan el límite de hilos, no el límite del servicio posterior.
- La memoria sigue siendo importante. Cada hilo virtual tiene una pila y los
ThreadLocals que tenga. Millones de ellos son millones de pilas.
Los hilos virtuales eliminan el techo de número de hilos; no eliminan las restricciones subyacentes que el techo estaba ocultando.
Un ejemplo práctico: un millón de hilos virtuales frente a un hilo de plataforma
El programa siguiente duerme 100.000 tareas durante 200 ms cada una, en paralelo. Con hilos de plataforma (limitados a un número razonable) esto tarda mucho tiempo y usa mucha RAM. Con hilos virtuales termina en apenas más tiempo que el tiempo de espera por tarea.
Qué extraer de la ejecución:
- Los 100.000 hilos virtuales terminaron en aproximadamente un segundo de tiempo de reloj — cercano al único sleep de 200 ms más la sobrecarga de crear y planificar 100.000 de ellos, no 100.000 × 200 ms. Ese es el punto central de los hilos virtuales: la concurrencia (cuántas cosas están en vuelo) está desacoplada del paralelismo (cuántos núcleos están ejecutando trabajo de CPU). El número exacto varía según la máquina, pero se mantiene en el mismo rango de pocos segundos sin importar cuánto se eleve el número de tareas.
- La ejecución de 5.000 tareas con un pool de plataforma de 100 hilos trabajadores tardó aproximadamente
5000 / 100 * 200 = ~10 segundos— las tareas se pusieron en cola porque el pool solo podía ejecutar 100 a la vez. Para terminar en el mismo tiempo de reloj que la versión de hilos virtuales, el pool de plataforma necesitaría 100.000 hilos, lo que está cerca o más allá del límite del sistema operativo en la mayoría de los sistemas. Thread.currentThread().isVirtual()distinguió los dos tipos de hilos en tiempo de ejecución. Los nombres también difieren — los hilos virtuales suelen tener una representación genérica en lugar de un nombre definido por el usuario, a menos que lo establezcas a través del builder. Útil para el registro cuando mezclas los dos tipos.- La advertencia de anclaje es la advertencia más importante para los hilos virtuales en producción. Un bloque
synchronizedalrededor de cualquier llamada bloqueante (I/O de base de datos, I/O de archivo, red) anula la mayor parte del beneficio porque el portador no puede liberarse durante la espera. ReemplazarsynchronizedconReentrantLockmantiene el hilo virtual estacionable. - La forma
try (ExecutorService vexec = ...)hizo lo correcto al cerrar — ejecutóshutdown()y esperó a que cada tarea enviada terminara. Con 100.000 tareas en vuelo esa espera fue real (200 ms cada una, todas estacionadas juntas, terminando casi simultáneamente). Sin el try-with-resources, el executor habría permanecido activo manteniendo hilos no daemon y el programa se habría quedado colgado.
Fin de la parte 15
Este es el último capítulo de la parte de Multithreading y Concurrencia. Hemos pasado de "un hilo es algo a nivel del sistema operativo" a través de los bloqueos, atómicos y colecciones concurrentes que usas para hacer el estado compartido correcto, al framework de executors que oculta la gestión de hilos, luego CompletableFuture y ForkJoinPool para la composición, y finalmente los hilos virtuales para la carga de trabajo intensiva en I/O a la que se enfrentan realmente los servidores modernos.
El patrón en todo ello: elige la herramienta más pequeña que resuelva tu problema específico. ¿Un contador? AtomicInteger. ¿Una bandera? volatile. ¿Un productor/consumidor? BlockingQueue. ¿Muchas llamadas de I/O paralelas? Hilos virtuales. La palabra clave synchronized sigue siendo correcta cuando lo es; Lock es para cuando no lo es; los executors y futuros de alto nivel son para cuando has superado ambos. Desciende en la pila solo cuando la abstracción superior no está haciendo lo que necesitas.
La siguiente parte del libro es Anotaciones — qué hacen realmente los marcadores @ adjuntos a clases, métodos y campos, los integrados en java.lang, y las reglas para escribir los propios.