Tareas en paralelo con hilos virtuales en Java

Los hilos virtuales llegaron en Java 21 y cambian la regla de oro de la concurrencia en Java: ya no hay que evitar bloquear. Son tan baratos que lanzar diez mil no es un problema.

El problema de siempre

Nueve tareas que tardan 100 ms cada una —una consulta, una petición de red, una lectura de disco—, una detrás de otra:

salidasecuencial: 903 ms

Un hilo por tarea

try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Callable<Integer>> tareas = new ArrayList<>();
    for (int i = 1; i <= 9; i++) { int n = i; tareas.add(() -> tarea(n)); }
    var resultados = ex.invokeAll(tareas);
}
salidaen paralelo: 109 ms   resultados [1, 4, 9, 16, 25, 36, 49, 64, 81]

De 903 a 109 ms. El try con recursos no es decorativo: al cerrarse, el ejecutor espera a que terminen todas. Es la forma más corta de no dejar trabajo colgando.

Y ahora diez mil

try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++)
        ex.submit(() -> { Thread.sleep(Duration.ofMillis(100)); return null; });
}
salida10 000 tareas de 100 ms: 133 ms
Ahí está todo el argumento. Diez mil tareas que duermen 100 ms cada una, resueltas en 133. Con hilos del sistema eso serían diez mil hilos, cada uno con su pila de un megabyte reservada: varios gigabytes de memoria y un reparto de CPU imposible. Antes de los hilos virtuales, la respuesta a esto era programación asíncrona con CompletableFuture y código del revés. Ahora es un bucle.

Qué son por dentro

salidavirtual   : VirtualThread[#10068]/new   isVirtual=true
  plataforma: Thread[#10069,Thread-0,5,main]   isVirtual=false

Un hilo virtual es un Thread normal desde el punto de vista del código, pero no le corresponde un hilo del sistema operativo. La máquina virtual lo monta sobre un grupo pequeño de hilos reales y, cuando se bloquea, lo desmonta y deja el hilo real libre para otro. Por eso bloquear deja de ser caro, que es exactamente lo contrario de lo que había que hacer antes.

Si una falla

var f = ex.submit(() -> { throw new IllegalStateException("algo se rompió"); });
try { f.get(); }
catch (ExecutionException e) { IO.println(e.getCause()); }
salidajava.lang.IllegalStateException: algo se rompió

La excepción no se pierde, pero tampoco salta donde la lanzaste: queda guardada en el Future y sale al pedir el resultado, envuelta en una ExecutionException. Lo que te interesa casi siempre es getCause(). Y si nunca llamas a get(), el error desaparece en silencio.

Cuándo no sirven

  • Si el trabajo es cálculo puro, no hay nada que ganar: el límite son los núcleos, y ahí lo que toca es un grupo de tamaño fijo o parallelStream.
  • No los metas en un grupo. newFixedThreadPool con hilos virtuales no tiene sentido: los grupos existen para reutilizar algo caro de crear, y estos son baratos. Uno por tarea y ya.
  • Cuidado con synchronized alrededor de una operación bloqueante: en algunas versiones eso ancla el hilo virtual a su hilo real y anula la ventaja. Con ReentrantLock no pasa.
Para… Se usa
Muchas tareas que esperan a red o disco Executors.newVirtualThreadPerTaskExecutor()
Esperar a todas cerrar el ejecutor con try con recursos
Recoger resultados invokeAll y luego get() de cada uno
Lanzar uno suelto Thread.ofVirtual().start(...)
Cálculo puro un grupo fijo, no hilos virtuales

Todo el código se compiló y se ejecutó con Java 25 LTS en un contenedor limpio antes de publicar esta página; las salidas están copiadas de esa ejecución.