En el desarrollo de aplicaciones Java de alto rendimiento, la gestión manual de hilos es una práctica desaconsejada. El uso de hilos sin control puede agotar los recursos del sistema y degradar la estabilidad de la aplicación. En su lugar, el estándar moderno exige el uso de hilos a través de pools gestionados. Aunque el JDK proporciona la factoría Executors, se recomienda encarecidamente utilizar directamente la clase ThreadPoolExecutor. Esto permite un control total sobre la configuración del pool, evitando riesgos de memoria derivados de colas infinitas o configuraciones por defecto poco seguras.
Anatomía del ThreadPoolExecutor
Para comprender cómo opera un pool de hilos, debemos analizar los parámetros fundamentales de su constructor:
public ThreadPoolExecutor(
int corePoolSize, // Hilos activos mínimos en el pool
int maximumPoolSize, // Límite máximo de hilos permitidos
long keepAliveTime, // Tiempo de espera para hilos excedentes antes de ser eliminados
TimeUnit unit, // Unidad de medida del tiempo de espera
BlockingQueue<Runnable> workQueue, // Cola donde residen las tareas pendientes
ThreadFactory threadFactory, // Fábrica para instanciar nuevos hilos
RejectedExecutionHandler handler // Estrategia cuando el pool y la cola están llenos
)
Cada parámetro juega un rol crítico en el flujo de trabajo: cuando llega una tarea, el pool intenta asignarla a un hilo del núcleo (corePoolSize). Si estos están ocupados, la tarea se encola en la workQueue. Si la cola se llena, se crean hilos adicionales hasta alcanzar el maximumPoolSize.
Selección de la Cola de Trabajo (BlockingQueue)
El comportamiento del pool varía drásticamente según la implementación de la cola utilizada:
- SynchronousQueue: No almacena tareas. Cada inserción debe esperar a que un hilo tome la tarea. Es ideal para procesamientos inmediatos que requieren escalabilidad rápida de hilos.
- ArrayBlockingQueue: Una cola con capacidad definida (acotada). Ayuda a prevenir el agotamiento de memoria, pero reqiuere un equilibrio entre el tamaño de la cola y el pool.
- LinkedBlockingQueue: Por defecto es ilimitada (aunque puede acotarse). Si no se limita, el pool nunca creará más hilos que los definidos en
corePoolSize, ya que la cola nunca se llena. - PriorityBlockingQueue: Permite procesar tareas basadas en su prioridad natural o mediante un comparador, ignorando el orden de llegada (FIFO).
Ejemplo de Ejecución con Cola de Prioridad
import java.util.concurrent.*;
public class SistemaPrioritario {
public static void main(String[] args) {
ThreadPoolExecutor ejecutor = new ThreadPoolExecutor(
1,
2,
60,
TimeUnit.SECONDS,
new PriorityBlockingQueue<>()
);
for (int i = 0; i < 5; i++) {
final int prioridad = i;
ejecutor.execute(new TareaConPrioridad(prioridad));
}
ejecutor.shutdown();
}
}
class TareaConPrioridad implements Runnable, Comparable<TareaConPrioridad> {
private final int nivel;
public TareaConPrioridad(int nivel) {
this.nivel = nivel;
}
@Override
public int compareTo(TareaConPrioridad otra) {
// Mayor valor indica mayor prioridad en este ejemplo
return Integer.compare(otra.nivel, this.nivel);
}
@Override
public void run() {
System.out.println("Procesando tarea con nivel: " + nivel + " en hilo: " + Thread.currentThread().getName());
}
}
Estrategias de Rechazo de Tareas
Cuando el pool alcanza su límite de hilos y la cola está saturada, entra en juego el RejectedExecutionHandler. Java ofrece cuatro políticas estándar:
- AbortPolicy (Por defecto): Lanza una excepción
RejectedExecutionException. - CallerRunsPolicy: La tarea es ejecutada por el hilo que intentó enviarla (el hilo "padre"), lo que reduce la velocidad de envío de nuevas tareas.
- DiscardPolicy: Ignora la tarea silenciosamente.
- DiscardOldestPolicy: Elimina la tarea más antigua de la cola e intenta reinsertar la nueva.
Personalización Avanzada: ThreadFactory y Hooks
Es una buena práctica nombrar los hilos para facilitar el depurado. Esto se logra implementando ThreadFactory:
ThreadFactory fabricaHilos = r -> {
Thread t = new Thread(r);
t.setName("Worker-API-" + t.hashCode());
return t;
};
Además, podemos extender ThreadPoolExecutor para interceptar el ciclo de vida de las tareas mediante los métodos beforeExecute, afterExecute y terminated:
public class MiPoolPersonalizado extends ThreadPoolExecutor {
public MiPoolPersonalizado(int core, int max, long time, TimeUnit unit, BlockingQueue<Runnable> queue) {
super(core, max, time, unit, queue);
}
@Override
protected void beforeExecute(Thread t, Runnable r) {
System.out.println("Hilo " + t.getName() + " iniciando ejecución.");
}
@Override
protected void afterExecute(Runnable r, Throwable t) {
System.out.println("Finalización de tarea detectada.");
}
}
Diferencias entre execute() y submit()
Aunque ambos envían tareas al pool, existen diferencias operativas cruciales:
- execute(): Se usa para tareas de tipo "disparar y olvidar" (Runnable). No devuelve resultados y las excepciones no capturadas pueden terminar el hilo.
- submit(): Devuelve un objeto
Future. Permite obtener resultados de tareasCallableo verificar si una tareaRunnableterminó correctamente mediantefuture.get(), el cual también captura excepciones lanzadas durante la ejecución.
Cálculo del Tamaño del Pool
No existe un número mágico, pero se puede estimar basándose en el tipo de carga:
- Tareas Intensivas en CPU: Se recomienda
N_CPUs + 1. Más hilos generarían demasiados cambios de contexto. - Tareas Intensivas en I/O (Red, BD, Disco): Se pueden usar más hilos ya que muchos estarán bloqueados esperando respuesta. Una fórmula común es
N_CPUs * (1 + tiempo_espera / tiempo_computo).
Para cerrar un pool de forma segura, use shutdown(), que permite terminar las tareas encoladas, o shutdownNow() si requiere una interrupción inmediata de los procesos activos.