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.
También te puede interesar
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
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.
newFixedThreadPoolcon 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
synchronizedalrededor de una operación bloqueante: en algunas versiones eso ancla el hilo virtual a su hilo real y anula la ventaja. ConReentrantLockno 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.