W3docs

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/scheduleWithFixedDelay

Programas 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 como Executor es el contrato más general de "dame cualquier cosa que pueda ejecutar un Runnable".
  • ExecutorService — el motor principal. Casi todo el código de producción usa este tipo. Agrega submit (con un resultado Future), 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 del Callable).
  • 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 failed

Operaciones 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 rest

invokeAll(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 fixed

La 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ábricaConfiguración subyacenteCuándo usarla
newFixedThreadPool(n)core=max=n, LinkedBlockingQueue sin límiteParalelismo predecible; la cola sin límite es la trampa
newCachedThreadPoolcore=0, max=MAX_VALUE, SynchronousQueue, keep-alive 60sTareas cortas y en ráfagas; el número ilimitado de hilos es la trampa
newSingleThreadExecutorIgual que newFixedThreadPool(1), pero el grupo no es reconfigurableSerializar un único trabajador ordenado
newScheduledThreadPool(n)n hilos núcleo, cola programadaTareas periódicas
newWorkStealingPoolJava 8+: un ForkJoinPool con paralelismo = núcleosTrabajo ligado a CPU, subtareas recursivas
newVirtualThreadPerTaskExecutorJava 21+: un hilo virtual por tareaTrabajo 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 + awaitTermination

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

java— editable, runs on the server

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. El close() del grupo ejecuta shutdown() 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 secuencia shutdown + awaitTermination + shutdownNow.
  • La sección 3 ejecutó tres tareas de 50/80/20 ms en 4 trabajadores. invokeAll regresó solo después de que la más lenta terminó — aproximadamente 80 ms. Ese es el contrato de "esperar a todos". La sum sobre 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. invokeAny es 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ó scheduleAtFixedRate con un período de 60 ms. Cada tick se disparó en un hilo del grupo programado. El envoltorio try/catch dentro 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 submitCallable<V>, Future<V>, cancelación y los patrones estándar para obtener un valor de una tarea asíncrona.

Práctica

Práctica
Programas una tarea con `scheduleAtFixedRate` y lanza una `RuntimeException` en la tercera ejecución. ¿Qué sucede con las ejecuciones siguientes?
Programas una tarea con `scheduleAtFixedRate` y lanza una `RuntimeException` en la tercera ejecución. ¿Qué sucede con las ejecuciones siguientes?
Was this page helpful?