Java Executor Framework
Envía tareas a grupos de hilos con Executor y ExecutorService: jerarquía de tipos, fábricas y reglas de dimensionamiento.
El capítulo anterior describió qué es un grupo de hilos. Este capítulo trata sobre la jerarquía de tipos que se usa para comunicarse con uno: las interfaces Executor, ExecutorService y ScheduledExecutorService. En conjunto se denominan el executor framework, introducido en Java 5 para desacoplar "el trabajo" de "los hilos que lo ejecutan". Tú escribes Callable<Result> y Runnable; haces el envío; el framework se encarga de la asignación de hilos, la cola y la entrega de resultados.
La jerarquía de tres niveles
Executor // execute(Runnable)
|
ExecutorService // + submit/invokeAll/invokeAny/shutdown/awaitTermination
|
ScheduledExecutorService // + schedule/scheduleAtFixedRate/scheduleWithFixedDelayProgramas con la interfaz más general que tenga lo que necesitas:
Executor— la base con un solo método. Úsala cuando solo necesitas disparar y olvidar. Un parámetro de método tipado comoExecutores el contrato más general de "dame cualquier cosa que pueda ejecutar unRunnable".ExecutorService— el motor principal. Casi todo el código de producción usa este tipo. Agregasubmit(con un resultadoFuture), operaciones en bloque y ciclo de vida.ScheduledExecutorService— cuando necesitas ejecución diferida o repetitiva.
Executor.execute — disparar y olvidar
public interface Executor {
void execute(Runnable command);
}Esa es toda la interfaz. execute recibe un Runnable, lo ejecuta en algún momento futuro y no devuelve nada. Si el trabajo lanza una excepción, no te enteras — la excepción va al manejador de excepciones no capturadas del hilo trabajador.
execute es la llamada correcta cuando:
- El trabajo no tiene valor de retorno.
- No necesitas esperar por él ni obtener su resultado.
- No necesitas cancelarlo.
Para cualquier cosa más elaborada, usa submit.
ExecutorService.submit, la versión completa
public interface ExecutorService extends Executor {
<T> Future<T> submit(Callable<T> task);
Future<?> submit(Runnable task);
<T> Future<T> submit(Runnable task, T result);
// ... lifecycle, bulk ops
}submit devuelve un Future, que te permite:
- Esperar la finalización (
get()bloquea). - Leer el resultado (
get()devuelve el valor delCallable). - Cancelar la tarea (
cancel(boolean mayInterrupt)). - Capturar la excepción de la tarea (
get()la relanza).
Cubrimos Future y Callable en detalle en el próximo capítulo; por ahora, el contraste con execute es lo importante. execute es unidireccional; submit abre un canal de retorno.
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Integer> result = pool.submit(() -> {
// Callable<Integer>; can throw, returns a value
return expensive();
});
Integer value = result.get(); // waits, throws ExecutionException if task failedOperaciones en bloque: invokeAll e invokeAny
Cuando tienes una colección de tareas:
List<Callable<Integer>> tasks = makeTasks();
List<Future<Integer>> futures = pool.invokeAll(tasks); // run all, wait for all
Integer first = pool.invokeAny(tasks); // run all, return first success, cancel the restinvokeAll(tasks, timeout, unit) las ejecuta pero se rinde después de un plazo; las tareas que no terminaron se devuelven como Futures cuyo isDone() es true pero fueron canceladas.
invokeAny es la herramienta correcta para consultas redundantes — llama a tres servidores DNS, toma el primero que responda, cancela los demás.
ScheduledExecutorService — demoras y repeticiones
Cuando necesitas una demora o un calendario periódico:
ScheduledExecutorService sched = Executors.newScheduledThreadPool(2);
sched.schedule(() -> log("once, after 5 seconds"), 5, TimeUnit.SECONDS);
sched.scheduleAtFixedRate(this::flush, 0, 1, TimeUnit.SECONDS);
// runs at t=0, t=1, t=2, ... — even if a run takes longer, the next one queues
sched.scheduleWithFixedDelay(this::poll, 0, 1, TimeUnit.SECONDS);
// runs at t=0, then 1 second AFTER the previous finished — back-to-back delay is what's fixedLa diferencia entre atFixedRate y withFixedDelay está en si el período es entre inicios o entre el fin y el siguiente inicio. Para "quiero hacer flush cada segundo según el reloj", usa atFixedRate; para "quiero un intervalo de 1 segundo entre ejecuciones sin importar cuánto tarden", usa withFixedDelay.
Si una tarea programada lanza una excepción, las ejecuciones futuras se cancelan silenciosamente. El planificador no registra nada. Siempre envuelve las tareas programadas en un try/catch de nivel superior para que sigan ejecutándose:
sched.scheduleAtFixedRate(() -> {
try { flush(); }
catch (Throwable t) { log.error("flush failed", t); }
}, 0, 1, TimeUnit.SECONDS);Olvidar esto es el error de planificador más común en Java en producción.
Dimensionamiento del grupo
El tamaño correcto del grupo depende de lo que hacen las tareas.
Para trabajo ligado a la CPU, la regla general es N + 1 hilos en una máquina con N núcleos. Cada hilo mantiene un núcleo ocupado; el +1 cubre el raro momento en que un hilo se detiene por acceso a memoria.
Para trabajo ligado a I/O, el número correcto es mucho mayor. La fórmula aproximada:
threads = cores * (1 + (wait_time / compute_time))Si tus tareas están el 90% del tiempo esperando la base de datos, el multiplicador es 10x — 80 hilos en 8 núcleos. El número exacto depende del patrón de I/O específico; perfila y ajusta.
En la práctica, ejecuta dos grupos: uno pequeño para trabajo de CPU y uno grande para I/O. No los mezcles — una llamada lenta a la base de datos dentro de un hilo del grupo de CPU bloquea un núcleo que debería estar computando.
Los hilos virtuales de Java 21 cambian fundamentalmente este cálculo: bloquear en I/O ya no desperdicia un hilo de plataforma, por lo que puedes usar un executor de un hilo virtual por tarea y dejar de dimensionar completamente. Lo cubrimos al final de la parte.
Fábricas de Executors — referencia rápida
Los métodos de fábrica devuelven todos ExecutorService (o una subinterfaz). Cada uno es un ThreadPoolExecutor con valores específicos de configuración:
| Fábrica | Configuración subyacente | Cuándo usarla |
|---|---|---|
newFixedThreadPool(n) | core=max=n, LinkedBlockingQueue sin límite | Paralelismo predecible; la cola sin límite es la trampa |
newCachedThreadPool | core=0, max=MAX_VALUE, SynchronousQueue, keep-alive 60s | Tareas cortas y en ráfagas; el número ilimitado de hilos es la trampa |
newSingleThreadExecutor | Igual que newFixedThreadPool(1), pero el grupo no es reconfigurable | Serializar un único trabajador ordenado |
newScheduledThreadPool(n) | n hilos núcleo, cola programada | Tareas periódicas |
newWorkStealingPool | Java 8+: un ForkJoinPool con paralelismo = núcleos | Trabajo ligado a CPU, subtareas recursivas |
newVirtualThreadPerTaskExecutor | Java 21+: un hilo virtual por tarea | Trabajo ligado a I/O, servidores web |
Evita newFixedThreadPool y newCachedThreadPool en rutas de sobrecarga de producción — ambos tienen ejes de crecimiento ilimitado. Usa directamente new ThreadPoolExecutor(...) con una cola acotada.
La secuencia de cierre estándar
Un grupo que nunca se cierra mantiene vivos sus hilos trabajadores no daemon, impidiendo la salida de la JVM. Cada grupo que crees necesita el mismo patrón de limpieza:
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
// ... submit work, gather results ...
} finally {
pool.shutdown();
try {
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
pool.shutdownNow();
pool.awaitTermination(5, TimeUnit.SECONDS);
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
}O, desde Java 19, lo mismo mediante try-with-resources:
try (var pool = Executors.newFixedThreadPool(4)) {
pool.submit(...);
pool.submit(...);
} // close() runs shutdown + awaitTerminationEl ExecutorService.close() de Java 19 realiza el cierre ordenado y luego espera indefinidamente; combínalo con un vigilante si no puedes permitirte una espera infinita.
Un ejemplo completo: el framework de principio a fin
El programa de abajo usa cada una de las tres interfaces — Executor para disparar y olvidar, ExecutorService para resultados y ScheduledExecutorService para ejecuciones periódicas — todo en uno.
Lo que hay que extraer de la ejecución:
- La sección 2 usó
try (ExecutorService pool = ...)— el patrón de cierre al salir del ámbito de Java 19. Elclose()del grupo ejecutashutdown()y luego espera. Esa es la forma de cierre más limpia; para código más antiguo o plazos más estrictos, vuelve a la secuenciashutdown+awaitTermination+shutdownNow. - La sección 3 ejecutó tres tareas de 50/80/20 ms en 4 trabajadores.
invokeAllregresó solo después de que la más lenta terminó — aproximadamente 80 ms. Ese es el contrato de "esperar a todos". Lasumsobre los futuros fue la suma de los valores que devolvieron, en orden de envío. - La sección 4 ejecutó la misma estructura con
invokeAny. La tarea más rápida (50 ms) regresó primero; las demás fueron canceladas.invokeAnyes exactamente la forma correcta para patrones de "primera respuesta exitosa" — búsquedas DNS contra múltiples servidores, descargas desde espejos, carreras de latencia. - La sección 5 usó
scheduleAtFixedRatecon un período de 60 ms. Cada tick se disparó en un hilo del grupo programado. El envoltoriotry/catchdentro del cuerpo es la forma de producción — si una tarea programada lanza una excepción, el planificador cancela silenciosamente las ejecuciones futuras. Envolver cada cuerpo en un catch de nivel superior evita que eso suceda. - La tarea programada fue explícitamente cancelada con
cancel(false)antes de que el programa terminara. Cancelar y cerrar el planificador es lo que permite que la JVM termine; sin ello, el planificador mantiene hilos no daemon y el programa queda colgado. Lo mismo aplica a todo executor que crees.
Qué sigue
El próximo capítulo, Java Callable and Future, profundiza en el lado del manejo de resultados de submit — Callable<V>, Future<V>, cancelación y los patrones estándar para obtener un valor de una tarea asíncrona.