W3docs

Grupos de hilos en Java

Reutiliza hilos para ejecutar múltiples tareas de forma eficiente con grupos de hilos de Java y los parámetros de configuración de ThreadPoolExecutor.

Crear un hilo es costoso. Cada new Thread() asigna aproximadamente 1 MB de pila nativa, le pide al SO que programe un nuevo hilo del kernel y agrega carga al GC. Un programa que asigna un hilo por tarea funciona bien para diez tareas; colapsa con diez mil. La solución es un grupo de hilos — un pequeño conjunto de hilos de trabajo de larga duración que extraen tareas de una cola. El grupo gestiona los hilos; tú gestionas las tareas.

Este capítulo es el conceptual — qué es un grupo, los parámetros que lo configuran y los modos de fallo. El siguiente capítulo, Framework Executor, presenta los tipos Executor/ExecutorService que se usan para interactuar con un grupo. Ambos están entrelazados; este capítulo se centra en el qué y el por qué, el siguiente en el cómo.

¿Por qué usar un grupo de hilos?

Tres problemas que resuelve un grupo:

  1. Costo de creación de hilos. Asignar una pila nativa y pedirle al SO un nuevo hilo tarda del orden de milisegundos. Reutilizar hilos existentes tarda microsegundos. A escala, la diferencia es la diferencia entre un servidor que aguanta la carga y uno que no.
  2. Límites de recursos. Un hilo de plataforma en una JVM de 64 bits ocupa aproximadamente 1 MB de pila — 64 GB de RAM equivalen a ~64,000 hilos, y el SO tiene su propio overhead por hilo. La creación ilimitada de hilos implica un consumo ilimitado de RAM. Un grupo pone un límite en el recuento.
  3. Paralelismo predecible. Un grupo con N trabajadores te da exactamente N tareas en paralelo. Eso se adapta mucho mejor a "usa todos los 16 núcleos" que "crea un hilo por solicitud y espera lo mejor."

El costo del uso de grupos: hay que dimensionarlos. Demasiado pequeño → las tareas se acumulan en cola y la latencia aumenta. Demasiado grande → el cambio de contexto domina y el rendimiento cae. El capítulo de dimensionamiento (framework executor) cubre las reglas generales; este capítulo trata sobre lo que es el grupo.

La anatomía de un grupo de hilos

Un grupo de hilos consta esencialmente de tres cosas:

  1. Un conjunto acotado de hilos de trabajo. Los trabajadores ejecutan un bucle: toman una tarea de la cola, la ejecutan, toman la siguiente, y así sucesivamente. Viven durante la vida útil del grupo (o hasta que estén inactivos demasiado tiempo, dependiendo de la política).
  2. Una cola de tareas. Cuando envías trabajo y ningún trabajador está libre, la tarea va aquí. El tipo de cola — LinkedBlockingQueue, ArrayBlockingQueue, SynchronousQueue — afecta cómo crece el grupo bajo carga.
  3. Una API de envío. execute(Runnable), submit(Callable), invokeAll(...) — las formas de poner trabajo en el grupo.

En Java, todo eso está envuelto en java.util.concurrent.ThreadPoolExecutor, que es la clase subyacente de casi todos los grupos con los que te encontrarás.

Los siete parámetros de ThreadPoolExecutor

Construcción directa (que raramente se hace, pero los parámetros son los que pasa internamente cada fábrica):

new ThreadPoolExecutor(
  int corePoolSize,
  int maximumPoolSize,
  long keepAliveTime, TimeUnit unit,
  BlockingQueue<Runnable> workQueue,
  ThreadFactory threadFactory,
  RejectedExecutionHandler handler
);
ParámetroQué controla
corePoolSizeNúmero mínimo de trabajadores mantenidos activos incluso cuando están inactivos. Los hilos hasta este número no se eliminan.
maximumPoolSizeLímite superior del total de trabajadores. El grupo solo crece por encima de core cuando la cola está llena.
keepAliveTimeCuánto tiempo espera un trabajador inactivo por encima del tamaño central antes de terminar.
workQueueDonde viven las tareas pendientes. LinkedBlockingQueue (ilimitada) vs ArrayBlockingQueue (acotada) vs SynchronousQueue (sin buffer) determina completamente el comportamiento del grupo.
threadFactoryCómo se construyen los hilos de trabajo. Úsalo para establecer nombres, estado de daemon, prioridad, controladores de excepciones no capturadas.
handlerQué ocurre cuando tanto los trabajadores como la cola están saturados. Por defecto: AbortPolicy.

La interacción no obvia: el grupo prefiere llenar la cola antes de crear nuevos hilos por encima de core. Así que una cola ilimitada significa que el grupo nunca crece más allá de core — simplemente encola indefinidamente. Una cola acotada (o SynchronousQueue) es lo que hace que el parámetro max sea significativo.

Las cuatro políticas de rechazo

Cuando submit no puede aceptar una tarea (cola llena, todos los trabajadores al máximo ocupados), el RejectedExecutionHandler decide qué sucede:

PolíticaComportamiento
AbortPolicy (por defecto)Lanza RejectedExecutionException. El llamador sabe que la tarea fue descartada.
CallerRunsPolicyEl hilo llamador ejecuta la tarea él mismo. Ralentiza al llamador, proporcionando contrapresión.
DiscardPolicyDescarta la tarea silenciosamente. Úsala solo para trabajo de telemetría de "mejor esfuerzo".
DiscardOldestPolicyDescarta la tarea en cola más antigua y envía la nueva. Útil cuando "solo importa lo más reciente".

Lanzar una excepción por defecto suele ser la opción segura. CallerRunsPolicy es un mecanismo de contrapresión inteligente — cuando el grupo está abrumado, el emisor se ralentiza para adaptarse, lo que naturalmente limita la tasa de la fuente.

Los métodos de fábrica de Executors — y por qué deberías evitarlos en su mayoría

java.util.concurrent.Executors incluye fábricas de conveniencia:

Executors.newFixedThreadPool(n);             // core = max = n, unbounded LinkedBlockingQueue
Executors.newCachedThreadPool();             // core = 0, max = Integer.MAX_VALUE, SynchronousQueue, 60s keep-alive
Executors.newSingleThreadExecutor();         // fixed pool with one thread
Executors.newScheduledThreadPool(n);         // for delay/repeat scheduling
Executors.newVirtualThreadPerTaskExecutor(); // Java 21+: one virtual thread per task

Dos de estas tienen trampas bien conocidas:

  • newFixedThreadPool usa una LinkedBlockingQueue ilimitada. Bajo una sobrecarga sostenida, la cola crece sin límite — eventualmente OOM. El tamaño del grupo es fijo; el trabajo acumulado detrás de él no lo es.
  • newCachedThreadPool tiene maximum = Integer.MAX_VALUE. Bajo una ráfaga sostenida de trabajo, crea hilos sin límite — eventualmente agota el límite de hilos por proceso del SO y colapsa la JVM.

Estas están bien para trabajos pequeños, demos y scripts puntuales. Para código en producción, construye un ThreadPoolExecutor directamente con una cola acotada, un max razonable y una política de rechazo explícita.

La excepción: newVirtualThreadPerTaskExecutor (Java 21+) entrega hilos virtuales, que son suficientemente baratos como para que "uno por tarea" realmente funcione. Lo tratamos en el capítulo de hilos virtuales.

Ciclo de vida: shutdown vs shutdownNow

Un grupo sigue ejecutándose hasta que le dices que se detenga. Los dos modos de parada:

pool.shutdown();                              // stop accepting new work; let queued tasks finish
pool.shutdownNow();                           // stop accepting; interrupt running threads; return queued tasks

boolean terminated = pool.awaitTermination(10, TimeUnit.SECONDS);

shutdown es la versión educada: no se aceptan nuevas solicitudes, el trabajo existente termina y luego el grupo sale. shutdownNow es la versión brusca: interrumpe los trabajadores, devuelve la cola pendiente. Usa shutdown para una salida limpia; usa shutdownNow después de un shutdown + plazo de awaitTermination si el trabajo no terminó.

El patrón combinado de cierre de la documentación del JDK:

pool.shutdown();
try {
  if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
    pool.shutdownNow();
    pool.awaitTermination(5, TimeUnit.SECONDS);
  }
} catch (InterruptedException e) {
  pool.shutdownNow();
  Thread.currentThread().interrupt();
}

Casi siempre querrás exactamente esta estructura en cualquier código que gestione un grupo. Sin shutdown, la JVM mantiene vivos a los trabajadores (no daemon por defecto) y no termina.

Nombrar trabajadores con ThreadFactory

El Executors.defaultThreadFactory() predeterminado nombra los hilos pool-1-thread-1, pool-1-thread-2, etc. Eso es un pequeño paso por encima de Thread-7, pero sigue sin ser ideal. El código en producción usa una fábrica con nombre:

ThreadFactory factory = r -> {
  Thread t = new Thread(r, "image-worker-" + COUNTER.incrementAndGet());
  t.setDaemon(false);
  t.setUncaughtExceptionHandler((thr, ex) -> log.error("uncaught in " + thr.getName(), ex));
  return t;
};
ExecutorService pool = new ThreadPoolExecutor(
    4, 4, 0, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(1000),
    factory,
    new ThreadPoolExecutor.CallerRunsPolicy());

La fábrica es tu oportunidad de establecer cada propiedad por hilo: nombre, flag daemon, prioridad, controlador de excepciones no capturadas, grupo de hilos. En un volcado de heap con 200 hilos, un hilo llamado image-worker-7 es un hilo que puedes encontrar.

Ejemplo práctico: construir un grupo acotado con contrapresión

El programa a continuación construye un ThreadPoolExecutor con 4 trabajadores, una cola acotada de 8 y el controlador de rechazo CallerRunsPolicy — de modo que el emisor se ralentiza cuando el grupo está abrumado en lugar de lanzar una excepción.

java— editable, runs on the server

Qué extraer de la ejecución:

  • El grupo tenía un límite estricto de 4 hilos de trabajo. Con 40 tareas de 50 ms cada una, el tiempo serial idealizado por el grupo es 40 * 50 / 4 = 500 ms. El tiempo de reloj real fue cercano a eso — menos el costo de que CallerRunsPolicy ralentizara al emisor cada vez que la cola se llenaba.
  • Algunas tareas reportaron un nombre de hilo main. Eso es CallerRunsPolicy en acción: cuando la cola estaba llena y todos los trabajadores estaban ocupados, pool.execute ejecutó la tarea en el hilo llamador en lugar de encolarla o lanzar una excepción. El emisor se volvió más lento; el sistema permaneció acotado. Eso es la contrapresión bien implementada.
  • pool.getLargestPoolSize() fue 4 — el máximo se mantuvo igual que el núcleo. El grupo no creció más allá de core incluso bajo carga sostenida porque la cola acotada tenía espacio para las ráfagas breves. Con una cola ilimitada (el valor por defecto de Executors.newFixedThreadPool), la cola habría aceptado cada tarea y largestPoolSize se habría quedado en 4 — pero la memoria habría aumentado mientras se acumulaban las tareas.
  • La secuencia de cierre es el patrón de producción. shutdown() le dijo al grupo que dejara de aceptar nuevas solicitudes; awaitTermination(5, SECONDS) esperó hasta 5 segundos para el trabajo en vuelo; si el trabajo no hubiera terminado, shutdownNow() habría interrumpido a los trabajadores restantes. Sin estas llamadas, la JVM no termina — los trabajadores no daemon la mantienen viva.
  • La fábrica de hilos dio a cada trabajador un nombre significativo (worker-1 ... worker-4) y un controlador de excepciones no capturadas. En un volcado de hilos o en un perfilador de producción, esos nombres son la diferencia entre "sé de qué subsistema se trata" y "no tengo idea." Establécelos en cada grupo que crees.

¿Qué sigue?

El siguiente capítulo, Framework Executor de Java, presenta la jerarquía de tipos que se usa para interactuar con grupos de hilos — Executor, ExecutorService, ScheduledExecutorService — y cómo dimensionar un grupo para cargas de trabajo vinculadas a CPU y a I/O.

Práctica

Práctica
Llamas a `Executors.newFixedThreadPool(8)` y envías tareas más rápido de lo que el grupo puede procesarlas. El grupo tiene 8 hilos. ¿Cuál es el modo de fallo patológico bajo sobrecarga sostenida?
Llamas a `Executors.newFixedThreadPool(8)` y envías tareas más rápido de lo que el grupo puede procesarlas. El grupo tiene 8 hilos. ¿Cuál es el modo de fallo patológico bajo sobrecarga sostenida?
Was this page helpful?