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:
- 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.
- Límites de recursos. Un hilo de plataforma en una JVM de 64 bits ocupa aproximadamente 1 MB de pila —
64 GBde RAM equivalen a~64,000hilos, 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. - Paralelismo predecible. Un grupo con
Ntrabajadores te da exactamenteNtareas 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:
- 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).
- 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. - 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ámetro | Qué controla |
|---|---|
corePoolSize | Número mínimo de trabajadores mantenidos activos incluso cuando están inactivos. Los hilos hasta este número no se eliminan. |
maximumPoolSize | Límite superior del total de trabajadores. El grupo solo crece por encima de core cuando la cola está llena. |
keepAliveTime | Cuánto tiempo espera un trabajador inactivo por encima del tamaño central antes de terminar. |
workQueue | Donde viven las tareas pendientes. LinkedBlockingQueue (ilimitada) vs ArrayBlockingQueue (acotada) vs SynchronousQueue (sin buffer) determina completamente el comportamiento del grupo. |
threadFactory | Cómo se construyen los hilos de trabajo. Úsalo para establecer nombres, estado de daemon, prioridad, controladores de excepciones no capturadas. |
handler | Qué 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ítica | Comportamiento |
|---|---|
AbortPolicy (por defecto) | Lanza RejectedExecutionException. El llamador sabe que la tarea fue descartada. |
CallerRunsPolicy | El hilo llamador ejecuta la tarea él mismo. Ralentiza al llamador, proporcionando contrapresión. |
DiscardPolicy | Descarta la tarea silenciosamente. Úsala solo para trabajo de telemetría de "mejor esfuerzo". |
DiscardOldestPolicy | Descarta 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 taskDos de estas tienen trampas bien conocidas:
newFixedThreadPoolusa unaLinkedBlockingQueueilimitada. 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.newCachedThreadPooltienemaximum = 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.
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 queCallerRunsPolicyralentizara al emisor cada vez que la cola se llenaba. - Algunas tareas reportaron un nombre de hilo
main. Eso esCallerRunsPolicyen acción: cuando la cola estaba llena y todos los trabajadores estaban ocupados,pool.executeejecutó 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á decoreincluso bajo carga sostenida porque la cola acotada tenía espacio para las ráfagas breves. Con una cola ilimitada (el valor por defecto deExecutors.newFixedThreadPool), la cola habría aceptado cada tarea ylargestPoolSizese 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.