W3docs

Hilos Virtuales de Java en Profundidad

Un análisis profundo de los hilos virtuales de Java: fijación, planificación y cómo migrar código desde hilos de plataforma.

Los hilos de plataforma — el único tipo que Java tenía hasta JDK 21 — se asignan uno a uno a los hilos del sistema operativo. Son costosos: cada uno reserva aproximadamente un megabyte de pila y el planificador del SO solo puede manejar unos pocos miles antes de que el cambio de contexto consuma tu CPU. Los hilos virtuales, entregados por el Proyecto Loom, rompen ese límite. Son hilos ligeros gestionados por la JVM, no por el SO, por lo que un solo programa puede ejecutar millones de ellos. Este capítulo va más allá de la introducción: cómo se planifican, qué es la fijación, cómo la concurrencia estructurada vincula sus tiempos de vida, y dónde ayudan (y dónde no).

Si eres nuevo en el tema, lee primero la introducción a los hilos virtuales; este capítulo asume que ya sabes cómo iniciar uno. También será útil tener conocimientos básicos de multihilo en Java y el framework de ejecutores.

Hilos de plataforma vs. hilos virtuales

Un hilo virtual sigue siendo un java.lang.Thread — la misma API, el mismo Runnable. La diferencia está en lo que lo respalda. Un hilo de plataforma es un hilo del SO durante toda su vida. Un hilo virtual se ejecuta sobre un pequeño grupo de hilos de plataforma llamados portadores: cuando se bloquea en E/S, la JVM lo desmonta de su portador, libera ese portador para otro hilo virtual, y vuelve a montarlo más tarde cuando la E/S se completa. Bloquear un hilo virtual es barato; bloquear un hilo de plataforma desperdicia un recurso escaso.

AspectoHilo de plataformaHilo virtual
Respaldado porUn hilo del SOUn hilo portador en grupo
Costo de memoria~1 MB de pila fijaUnos pocos cientos de bytes, crece según demanda
Cantidad prácticaMilesMillones
Mejor paraTrabajo intensivo en CPUTrabajo intensivo en E/S y alta concurrencia
Cuando se bloqueaDesperdicia el hilo del SOSe desmonta; el portador se reutiliza
Ciclo de vidaAgrupar y reutilizarCrear uno por tarea, desechable

El modelo mental cambia. Con hilos de plataforma ajustas cuidadosamente el tamaño de un grupo y reutilizas hilos. Con hilos virtuales creas uno por tarea y lo dejas morir — son lo suficientemente baratos como para ser desechables.

Creación de hilos virtuales

Hay tres puntos de entrada idiomáticos. Para una sola tarea usa el constructor Thread.ofVirtual() o el acceso directo Thread.startVirtualThread; para muchas tareas usa un ejecutor de un hilo virtual por tarea.

// One-off, started immediately.
Thread t = Thread.startVirtualThread(() ->
    System.out.println("hi from " + Thread.currentThread()));
t.join();

// Builder: configure before starting.
Thread named = Thread.ofVirtual().name("worker-", 0).unstarted(() -> doWork());
named.start();

// Many tasks: the executor creates a fresh virtual thread per submitted task.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000; i++) {
        executor.submit(() -> handleRequest());
    }
} // close() waits for every task to finish

Nunca agrupes hilos virtuales. Un grupo de hilos fijo tradicional limita la concurrencia a propósito; envolver hilos virtuales en Executors.newFixedThreadPool(...) elimina toda su ventaja. La herramienta correcta es newVirtualThreadPerTaskExecutor(), que no impone ningún límite de tamaño.

Planificación, portadores y fijación

Los hilos virtuales son planificados por un ForkJoinPool dedicado cuyo recuento de trabajadores por defecto es el número de núcleos de CPU. Esos trabajadores son los hilos portadores. Cuando un hilo virtual llega a una llamada bloqueante en el JDK — Thread.sleep, lecturas de socket, BlockingQueue.take — el tiempo de ejecución lo desmonta para que el portador pueda ejecutar otra cosa.

A veces un hilo virtual no puede desmontarse y permanece unido a su portador. Esto es la fijación, y anula el propósito: un hilo virtual bloqueado pero fijado mantiene a un portador como rehén. Dos situaciones la causan:

Causa de fijaciónPor qué ocurreSolución
Dentro de un bloque/método synchronizedEl monitor está vinculado al portadorReemplazar con ReentrantLock
Dentro de una llamada nativa (JNI)El tiempo de ejecución no puede capturar la pila nativaEvitar el bloqueo en código nativo
// Pins the carrier while sleeping — bad.
synchronized (lock) {
    Thread.sleep(1000); // the virtual thread cannot unmount here
}

// Does not pin — good.
lock.lock();
try {
    Thread.sleep(1000); // the virtual thread unmounts freely
} finally {
    lock.unlock();
}

Puedes diagnosticar la fijación ejecutando con -Djdk.tracePinnedThreads=full, que imprime un stack trace cada vez que un hilo virtual fija su portador.

Concurrencia estructurada

Crear hilos de forma ad hoc genera fugas: si una subtarea falla, sus hermanas siguen ejecutándose y tienes que recordar cancelarlas. La concurrencia estructurada (StructuredTaskScope, una API de vista previa) hace que un grupo de subtareas se comporte como una sola unidad de trabajo — se bifurcan juntas, se unen juntas y se cancelan juntas. Cuando el ámbito padre termina, se garantiza que todos los hijos han terminado.

import java.util.concurrent.StructuredTaskScope;

Response handle() throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        var user  = scope.fork(() -> fetchUser());     // subtask 1
        var order = scope.fork(() -> fetchOrder());    // subtask 2

        scope.join();            // wait for both
        scope.throwIfFailed();   // propagate the first failure, cancel the rest

        return new Response(user.get(), order.get());
    } // both subtasks are guaranteed finished or cancelled here
}

ShutdownOnFailure cancela las subtareas restantes en el momento en que una lanza una excepción; ShutdownOnSuccess retorna tan pronto como la primera subtarea tiene éxito (útil para competir llamadas redundantes). De cualquier manera no hay hilos huérfanos. Para la API completa y más patrones, consulta el capítulo de concurrencia estructurada.

Un ejemplo práctico: diez mil tareas concurrentes

El programa a continuación envía 10,000 tareas intensivas en E/S — cada una simplemente duerme 50 ms para simular una llamada de red — a un ejecutor de un hilo virtual por tarea. Cuenta cuántos hilos portadores distintos ejecutaron realmente el trabajo y compara el tiempo de reloj de pared con ejecutar las mismas tareas una tras otra.

java— editable, runs on the server

Lo que se puede extraer de la ejecución:

  • 10,000 tareas se completan, pero toda la ejecución termina en bien menos de un segundo — muy lejos de los ~500,000 ms que los mismos sleep tardarían ejecutándose secuencialmente, porque toda la espera se solapa.
  • El recuento de hilos portadores es igual al número de núcleos de CPU (Carrier threads coincide con Available cores): miles de hilos virtuales se multiplexan sobre ese puñado diminuto de hilos de plataforma.
  • Thread.sleep desmonta el hilo virtual de su portador, que es exactamente por qué tan pocos portadores pueden servir a tantas tareas a la vez — el portador nunca está sentado ocioso esperando.
  • Cerrar el newVirtualThreadPerTaskExecutor() en un bloque try-with-resources bloquea hasta que cada tarea enviada termina, por lo que el recuento completado siempre llega a 10,000 antes de que se imprima el tiempo.
  • isVirtual() devuelve true e isDaemon() devuelve true — los hilos virtuales son siempre hilos daemon, por lo que nunca mantienen la JVM activa por sí solos.

Cuándo usar hilos virtuales (y cuándo no)

Los hilos virtuales son una ventaja cuando tus tareas pasan la mayor parte del tiempo esperando — en la red, una base de datos, un archivo o un servicio externo. Esa es la forma común del trabajo del lado del servidor, por lo que el consejo típico es simple: usa un hilo virtual por solicitud y escribe código bloqueante simple.

No son una aceleración para el trabajo intensivo en CPU. Una tarea que procesa números nunca se bloquea, por lo que nunca se desmonta; ejecutar un millón de ellas solo añade sobrecarga de planificación. Para cómputo puro, ajusta un grupo al recuento de tus núcleos. Dos cosas más a tener en cuenta:

  • Audita las rutas críticas en busca de bloques synchronized que envuelvan llamadas bloqueantes y mígralos a ReentrantLock para evitar la fijación.
  • No almacenes en caché ni agrupes hilos virtuales, y no dependas del estado de hilo local para limitar la concurrencia — usa un semáforo u otro limitador explícito si necesitas limitar el rendimiento.

Práctica

Práctica
Envuelves hilos virtuales en Executors.newFixedThreadPool(200) para ejecutar 10,000 tareas intensivas en E/S. ¿Por qué es esto un error?
Envuelves hilos virtuales en Executors.newFixedThreadPool(200) para ejecutar 10,000 tareas intensivas en E/S. ¿Por qué es esto un error?
Was this page helpful?