CodeGym /Cursos /JAVA 25 SELF /Uso de Executor con hilos virtuales

Uso de Executor con hilos virtuales

JAVA 25 SELF
Nivel 57 , Lección 3
Disponible

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).

Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION