Cuando trabajamos con concurrencia en Java, una herramienta fundamental para gestionar múltiples tareas es el uso de pools de hilos, y para ello la clase Executors nos ofrece varias formas de crear y manejar estos pools. Vamos a explorar algunas de las opciones más comunes y entender cómo funcionan bajo el capó.
Primero, recordemos que Executors tiene muchos métodos, pero nos centraremos en aquellos que comienzan con new, ya que son los que nos permiten crear diferentes tipos de ExecutorService. Por ejemplo, el SingleThreadExecutor es un ejecutor que maneja las tareas de forma secuencial, utilizando un solo hilo. Internamente, este ejecutor es un caso especial de un pool de hilos fijo con tamaño uno, lo que significa que solo puede ejecutar una tarea a la vez y las demás quedan en cola hasta que el hilo esté disponible.
Un concepto importante que aparece en la creación de estos ejecutores es el de ThreadFactory. Esta interfaz nos permite personalizar la forma en que se crean los hilos. Por defecto, un ThreadFactory simplemente devuelve un nuevo hilo para ejecutar la tarea, pero podemos implementar uno propio para establecer prioridades, nombres específicos o cualquier otra configuración que necesitemos para los hilos que se creen.
Avanzando, uno de los métodos más utilizados es newFixedThreadPool, que crea un pool con un número fijo de hilos. Por ejemplo, si creamos un pool con 8 hilos, podremos ejecutar hasta 8 tareas simultáneamente. Si intentamos ejecutar más tareas, estas se encolarán y esperarán a que algún hilo termine su trabajo para poder ser ejecutadas. Esto es especialmente útil para limitar el uso de recursos y evitar que el sistema se sobrecargue con demasiados hilos activos.
Para ilustrar esto, imaginemos que creamos un FixedThreadPool de 8 hilos y lanzamos 8 tareas que simulan trabajo durante 2 segundos cada una. Al ejecutar el programa, veremos que las 8 tareas se ejecutan concurrentemente y terminan aproximadamente al mismo tiempo. Si en lugar de 8 hilos tuviéramos solo 1, las tareas se ejecutarían una tras otra, respetando el orden en que fueron encoladas.
También podemos ajustar el tamaño del pool según el número de procesadores disponibles en la máquina, usando Runtime.getRuntime().availableProcessors(). Esto nos ayuda a optimizar el rendimiento, adaptando el número de hilos al hardware.
Por otro lado, existe el cachedThreadPool, que es un pool de tamaño dinámico e ilimitado. Este pool crea nuevos hilos según sea necesario para ejecutar las tareas que llegan, y reutiliza los hilos que ya están disponibles. Si un hilo permanece inactivo durante un minuto, se elimina para liberar recursos. Esto significa que el pool puede crecer rápidamente si llegan muchas tareas, pero también puede reducir su tamaño cuando la carga disminuye. Internamente, este pool tiene un tamaño mínimo de cero y un máximo muy alto (Integer.MAX_VALUE), y utiliza una cola especial llamada SynchronousQueue para gestionar las tareas.
Finalmente, hay otros tipos de pools como el ScheduledThreadPool para tareas programadas, o el virtualThreadPerTask que aprovecha los hilos virtuales introducidos en Java 21, pero estos merecen una explicación aparte.
En resumen, al crear pools de hilos en Java con Executors, podemos elegir entre un número fijo de hilos para controlar la concurrencia, o un pool dinámico que se adapta a la carga de trabajo. Además, la posibilidad de personalizar la creación de hilos mediante ThreadFactory nos da un control adicional sobre el comportamiento de los hilos en nuestro sistema concurrente.
Un ejemplo básico para crear un pool fijo de 8 hilos y ejecutar tareas podría ser:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class EjemploFixedThreadPool {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(8);
for (int i = 0; i < 8; i++) {
executor.submit(() -> {
try {
System.out.println("Ejecutando tarea en hilo: " + Thread.currentThread().getName());
Thread.sleep(2000); // Simula trabajo
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
}
}
Y para un pool dinámico con cachedThreadPool:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class EjemploCachedThreadPool {
public static void main(String[] args) {
ExecutorService executor = Executors.newCachedThreadPool();
for (int i = 0; i < 20; i++) {
executor.submit(() -> {
System.out.println("Ejecutando tarea en hilo: " + Thread.currentThread().getName());
});
}
executor.shutdown();
}
}
Así, podemos adaptar la gestión de hilos a las necesidades específicas de nuestras aplicaciones concurrentes en Java.