java-executorservice-thread-pools

Executors en Java: ExecutorService y thread pools

  • 6 min

Un Executor es un componente que recibe tareas y decide cómo ejecutarlas. En lugar de crear y controlar cada hilo a mano, entregamos el trabajo a un gestor especializado.

Esta separación es importante. La tarea define qué hay que hacer, mientras que el executor decide cuándo y en qué hilo hacerlo.

Hasta ahora hemos creado hilos con new Thread(...). Funciona para aprender, pero gestionar decenas o miles de tareas así resulta caro y bastante fácil de hacer mal.

El problema de crear hilos manualmente

Los hilos de plataforma consumen recursos. Cada uno necesita memoria para su pila y el sistema operativo tiene que planificar cuándo se ejecuta.

Si una aplicación crea un hilo nuevo por cada trabajo, puede terminar con miles de hilos compitiendo por la CPU. El coste de coordinarlos puede ser mayor que el trabajo que realizan.

Un thread pool mantiene un conjunto de hilos que se reutilizan para ejecutar muchas tareas. Cuando llega un trabajo, queda en una cola hasta que uno de los hilos está disponible.

Imagina un taller con tres mecánicos. No contratamos y despedimos a una persona por cada coche que entra: los coches esperan y los mismos mecánicos van atendiendo los trabajos.

Crear un ExecutorService

La clase Executors ofrece varios métodos para crear executors ya configurados. El más sencillo para empezar es newFixedThreadPool().

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String[] args) {
        try (ExecutorService executor = Executors.newFixedThreadPool(3)) {
            for (int i = 1; i <= 6; i++) {
                int idTarea = i;

                executor.submit(() -> {
                    String hilo = Thread.currentThread().getName();
                    System.out.printf("Tarea %d ejecutada por %s%n", idTarea, hilo);
                });
            }
        }
    }
}
Copied!

Hemos enviado seis tareas a un pool de tres hilos. Esto significa que nunca habrá más de tres tareas ejecutándose a la vez; las demás esperarán su turno.

El orden de la salida puede cambiar en cada ejecución. Un executor coordina las tareas, pero no garantiza el orden salvo que usemos uno diseñado específicamente para ejecutarlas en serie.

En Java moderno, ExecutorService implementa AutoCloseable. Por eso podemos usar try-with-resources: al salir del bloque deja de aceptar tareas nuevas y espera a que terminen las ya enviadas.

execute() frente a submit()

Un executor ofrece dos formas habituales de enviar una tarea Runnable:

  • execute(tarea) la envía y no devuelve ningún objeto con el que seguir su estado.
  • submit(tarea) devuelve un Future, que permite esperar, cancelar o comprobar si la tarea ha terminado.
executor.execute(() -> System.out.println("No necesito resultado"));

Future<?> futuro = executor.submit(
    () -> System.out.println("Puedo comprobar cuándo termino")
);
Copied!

Para trabajos sencillos donde no necesitamos observar el resultado, execute() es suficiente. En cuanto necesitemos recuperar un valor o gestionar un error, usaremos submit().

Tareas que devuelven valores: Callable

Runnable no devuelve nada y su método run() no puede declarar excepciones comprobadas. Cuando una tarea tiene que devolver un resultado usamos Callable<T>.

Al enviar un Callable, submit() devuelve un Future<T>. Ese objeto representa un resultado que quizá todavía no existe.

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Main {
    public static void main(String[] args) {
        try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
            Future<Integer> futuro = executor.submit(() -> {
                Thread.sleep(500);
                return 21 * 2;
            });

            System.out.println("La tarea sigue ejecutándose...");

            try {
                int resultado = futuro.get();
                System.out.println("Resultado: " + resultado);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } catch (ExecutionException e) {
                System.err.println("La tarea falló: " + e.getCause());
            }
        }
    }
}
Copied!

La llamada futuro.get() bloquea el hilo actual hasta que el resultado está disponible. Si la tarea falla, get() lanza una ExecutionException que envuelve la excepción original.

No llenes el programa de llamadas inmediatas a get(). Si envías una tarea y esperas su resultado justo después, puedes convertir un flujo concurrente en uno secuencial sin darte cuenta.

Tipos habituales de executor

La clase Executors incluye varias configuraciones preparadas. Cada una resuelve un problema distinto.

Pool de tamaño fijo

Executors.newFixedThreadPool(n) mantiene como máximo n hilos activos y deja las tareas pendientes en una cola.

int nucleos = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(nucleos);
Copied!

Es una opción razonable para trabajos intensivos de CPU, porque limita cuántas tareas compiten por los núcleos disponibles.

Un solo hilo

Executors.newSingleThreadExecutor() ejecuta las tareas una detrás de otra y en el orden de la cola.

ExecutorService executor = Executors.newSingleThreadExecutor();
Copied!

Es útil cuando queremos sacar una operación del hilo principal, pero necesitamos mantener el orden y evitar que dos tareas se solapen.

Pool elástico

Executors.newCachedThreadPool() reutiliza los hilos disponibles y crea otros cuando hacen falta. Puede crecer mucho, así que no conviene usarlo sin entender los límites de la carga.

Tareas programadas

Executors.newScheduledThreadPool(n) devuelve un ScheduledExecutorService, capaz de ejecutar una tarea después de un retraso o de forma periódica.

var scheduler = Executors.newScheduledThreadPool(1);

scheduler.schedule(
    () -> System.out.println("Han pasado dos segundos"),
    2,
    TimeUnit.SECONDS
);
Copied!

Cerrar el executor correctamente

Un pool mantiene hilos vivos. Si no lo cerramos, la aplicación puede seguir ejecutándose aunque el método main haya terminado.

Con Java moderno, la opción más limpia es usar try-with-resources. Si tenemos que controlar el cierre manualmente, disponemos de estos métodos:

  • shutdown() deja de aceptar tareas nuevas, pero permite terminar las pendientes.
  • shutdownNow() intenta interrumpir las tareas activas y devuelve las que no llegaron a empezar.
  • awaitTermination(...) espera durante un tiempo a que el executor termine.

shutdownNow() no puede matar un hilo por la fuerza. Solo solicita su interrupción. La tarea tiene que responder correctamente a esa interrupción para poder detenerse.

Hilos virtuales

Desde Java 21 también podemos crear un executor que inicia un hilo virtual por cada tarea.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> futuro = executor.submit(() -> descargarDatos());
    System.out.println(futuro.get());
}
Copied!

Los hilos virtuales son muy ligeros y encajan especialmente bien en aplicaciones con muchas tareas que pasan tiempo esperando E/S, como peticiones HTTP, consultas a bases de datos o acceso a ficheros.

No forman un pool y tampoco hacen que una operación matemática sea más rápida. Su ventaja es que permiten tener una gran cantidad de tareas bloqueantes concurrentes sin crear la misma cantidad de costosos hilos del sistema operativo.

  • Para unas pocas tareas intensivas de CPU, usa un pool fijo con un tamaño cercano al número de núcleos.
  • Para mantener un orden estrictamente secuencial, usa un executor de un solo hilo.
  • Para muchas tareas bloqueantes independientes, valora hilos virtuales.