1. Lo esencial, en breve
Ya estás algo familiarizado con las clases del paquete java.util.concurrent, especialmente con ExecutorService. Es como un «gestor de tareas»: envías trabajo (por ejemplo, mediante submit()) y él decide cuándo y con qué hilo ejecutar la tarea. Normalmente, por debajo funciona un pool de hilos de tamaño fijo, que ahorra recursos y no crea un hilo nuevo por cada tarea.
¡Sin embargo, con los hilos virtuales todo cambia! Ahora puedes permitirte el lujo: un hilo por tarea, sin miedo a que la JVM «reviente» por empacho.
Nueva forma: Executors.newVirtualThreadPerTaskExecutor()
En Java 21 apareció una nueva forma de crear un ExecutorService que ejecuta cada tarea en su propio hilo virtual:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Diferencia importante:
- Los pools de hilos antiguos (Executors.newFixedThreadPool, Executors.newCachedThreadPool) limitaban la cantidad de tareas simultáneas por el alto coste de los hilos del SO.
- El nuevo Executor virtual está casi sin límites: cada tarea obtiene su propio hilo virtual ligero.
Ejemplo sencillo
Enviaremos 10 tareas a un Executor virtual:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10; i++) {
int taskId = i; // capturamos la variable para la lambda
executor.submit(() -> {
System.out.println("Task " + taskId + " is running in thread: " +
Thread.currentThread());
});
}
executor.shutdown();
}
}
¿Qué está pasando?
Cada tarea se ejecutará en su propio hilo virtual, y verás líneas como:
Task 1 is running in thread: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1
...
2. Paralelismo masivo: ¡miles de tareas no son un problema!
Para apreciar toda la potencia de los hilos virtuales, intentemos enviar al ExecutorService no 10, sino, digamos, 100_000 tareas. En los pools clásicos sería como intentar meter un elefante en una nevera: la JVM se quedaría sin memoria rápidamente o empezaría a ir lentísima. ¡Con hilos virtuales es distinto!
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorMassiveDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 100_000; i++) {
int taskId = i;
executor.submit(() -> {
// A modo de ejemplo — solo dormimos 1 ms
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// System.out.println("Task " + taskId + " done."); // No imprimimos, de lo contrario habrá demasiadas líneas
});
}
executor.shutdown();
}
}
Atención: imprimir 100_000 líneas en pantalla es mala idea: la consola «se ahogará» antes que los hilos virtuales. Mejor no escribir en la consola o mostrar solo las primeras tareas.
3. Cómo funciona newVirtualThreadPerTaskExecutor
Brevemente: este ExecutorService crea un hilo virtual nuevo para cada tarea que le envías. A diferencia de un pool fijo, aquí no hay cola de tareas ni límites estrictos al número de hilos simultáneos (salvo los límites de tu JVM y del hardware).
Arquitectónicamente:
- Los hilos virtuales se «mapean» sobre un pequeño pool de hilos del SO reales (carrier).
- La JVM decide por sí misma cuándo ejecutar, pausar y reanudar cada hilo virtual.
- Si un hilo se bloquea (por ejemplo, al leer un archivo o al esperar la red), la JVM puede «congelar» el hilo virtual y liberar el hilo carrier para otras tareas.
4. Ejemplo: obtención de resultados con Future
ExecutorService devuelve un objeto de tipo Future si la tarea retorna un resultado. Todo funciona igual que con los hilos normales:
import java.util.concurrent.*;
public class VirtualExecutorWithResult {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(500);
return "Hello from virtual thread!";
});
System.out.println("Result: " + future.get()); // Esperamos el resultado
executor.shutdown();
}
}
Todo como siempre: puedes enviar tareas que devuelven un valor, esperar el resultado con get(), y las excepciones se gestionan de forma estándar.
5. Cómo cerrar correctamente el Executor
Es muy importante no olvidar cerrar el ExecutorService, para que el programa no se quede colgado (aunque los hilos sean virtuales y no «reales»).
shutdown() y awaitTermination
executor.shutdown(); // Indicamos que no se aceptan más tareas
executor.awaitTermination(1, TimeUnit.MINUTES); // Esperamos a que terminen todas las tareas (máximo 1 minuto)
¿Por qué es importante?
Si no llamas a shutdown(), los hilos virtuales pueden seguir vivos y el programa no terminará incluso después de que finalice main(). Es un error típico de principiantes.
6. Detalles útiles
Comparación: Executor virtual vs pool de hilos clásico
| Pool clásico (newFixedThreadPool) | Executor virtual (newVirtualThreadPerTaskExecutor) | |
|---|---|---|
| Número de hilos | Limitado por el tamaño del pool | Un hilo virtual por tarea, casi sin límites |
| Tareas en cola | Sí, si todos los hilos están ocupados | Por regla general, no: la tarea obtiene un hilo inmediatamente |
| Coste del hilo | Alta (pila, recursos del SO) | Muy bajo (planificación a cargo de la JVM) |
| Escalabilidad | Limitada | Casi ilimitada |
| Para qué sirve | Tareas CPU-bound, paralelismo limitado | Tareas I/O-bound, paralelismo masivo |
Integración con servidores web
Los servidores web modernos (por ejemplo, Tomcat, Jetty, Undertow) ya empiezan a soportar hilos virtuales. Esto significa que se puede procesar cada petición HTTP en un hilo virtual separado, sin miedo a «ahogarse» ante una avalancha de usuarios.
Ventaja: no hace falta idear esquemas asíncronos complejos con callbacks y CompletableFuture; el código se simplifica — puedes escribir el habitual código bloqueante, pero la aplicación sigue escalando.
Pruebas masivas e imitación de carga
Los hilos virtuales son perfectos para pruebas en las que hay que «simular» miles de usuarios, peticiones u operaciones simultáneas. Por ejemplo, una prueba que envía 10_000 peticiones en paralelo a un servidor, cada una en su propio hilo virtual.
Procesamiento paralelo de archivos y conexiones de red
Si la aplicación trabaja con muchos archivos o conexiones de red, puedes procesar cada conexión en un hilo virtual separado sin preocuparte por la gestión manual de pools.
7. Errores típicos al trabajar con Executors virtuales
Error n.º 1: olvidaste llamar a shutdown(). Si no cierras el Executor, el programa no terminará: los hilos virtuales seguirán esperando nuevas tareas. Si es necesario, añade awaitTermination(...).
Error n.º 2: usar hilos virtuales para cómputo pesado. Los hilos virtuales no aceleran tareas que saturan completamente la CPU. Para CPU-bound es mejor usar un pool fijo (Executors.newFixedThreadPool) y ajustar cuidadosamente su tamaño.
Error n.º 3: ignorar las excepciones dentro de las tareas. Si una tarea lanza una excepción, no llegará al hilo principal: gestiona a través de Future (método get()) o con try/catch dentro de la lambda.
Error n.º 4: confundir el sintaxis/versión antigua y la nueva del JDK. Asegúrate de usar una versión adecuada del JDK (Java 21+) y de que el IDE está configurado para soportar hilos virtuales. El método concreto es Executors.newVirtualThreadPerTaskExecutor().
Error n.º 5: confiar en ThreadLocal para propagar contexto. Los hilos virtuales a menudo se crean y destruyen; ThreadLocal puede comportarse de forma distinta a la esperada. Para propagar contexto usa ScopedValue (Scoped Values; más detalles en la siguiente lección).
GO TO FULL VERSION