ExecutorService y thread pool: elige el tipo correcto
1. Descripción general
Crear new Thread() para cada tarea es fácil de escribir, pero difícil de controlar cuando la carga aumenta. ExecutorService resuelve esto reutilizando threads y encolando tareas. La clase Executors proporciona muchos tipos de pool diferentes, y elegir el tipo incorrecto puede causar OutOfMemoryError o congelar la aplicación.
Este artículo recorre cada tipo de pool, cómo ThreadPoolExecutor procesa tareas internamente, cómo elegir el tamaño del pool y cómo configurarlo en Spring Boot 3.x. Después de leerlo, sabrás qué pool usar para tareas CPU-bound, I/O-bound o tareas programadas.
2. ExecutorService básico
El siguiente ejemplo usa un pool simple para ejecutar una tarea que devuelve un resultado:
import java.util.concurrent.*;
public class OrderReportApp {
public static void main(String[] args) throws Exception {
try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
Future<Integer> total = executor.submit(() -> countOrders("2026-10"));
System.out.println("Total de órdenes: " + total.get());
}
}
static int countOrders(String month) {
return 128; // simular consulta a base de datos
}
}
Total de órdenes: 128
El código funciona de la siguiente manera:
submit()recibe unCallabley devuelveFuture<Integer>inmediatamente. La tarea se ejecuta en un thread del pool.total.get()bloquea el thread actual hasta que hay resultado.- A partir de Java 19,
ExecutorServiceimplementaAutoCloseable. Al salir del bloquetry,close()llama ashutdown()y espera a que terminen todas las tareas.
Nota: Si no cierras el executor, los threads que no son daemon mantendrán el JVM vivo. El programa
mainno saldrá.
3. Tipos de pool en Executors
3.1. newFixedThreadPool(n)
Este pool tiene exactamente n threads. Las tareas excedentes se ponen en una LinkedBlockingQueue sin límite.
ExecutorService invoiceExecutor = Executors.newFixedThreadPool(8);
Este tipo es apropiado cuando deseas limitar el número de tareas ejecutándose simultáneamente, por ejemplo tareas CPU-bound. El riesgo está en la cola. Si el productor emite tareas más rápido que la velocidad de procesamiento, la cola se hinchará hasta que el heap se agote.
3.2. newCachedThreadPool()
Este pool no tiene threads fijos. Usa SynchronousQueue, es decir, no hay una cola real. Cada nueva tarea se asigna a un thread libre si hay, de lo contrario el pool crea un nuevo thread. Un thread inactivo durante más de 60 segundos será eliminado.
ExecutorService notificationExecutor = Executors.newCachedThreadPool();
Este pool es apropiado para muchas tareas cortas y carga baja e irregular. Cuando la carga aumenta repentinamente, puede crear miles de platform threads. Cada thread ocupa alrededor de 1 MB de stack por defecto, por lo que la aplicación puede fácilmente encontrar OutOfMemoryError: unable to create native thread.
3.3. newSingleThreadExecutor()
Este pool tiene solo un thread, y las tareas se ejecutan secuencialmente en el orden exacto en que se envían.
ExecutorService auditLogExecutor = Executors.newSingleThreadExecutor();
auditLogExecutor.submit(() -> System.out.println("Order #1 created"));
auditLogExecutor.submit(() -> System.out.println("Order #1 paid"));
Order #1 created
Order #1 paid
Las dos líneas de registro siempre se imprimen en el orden exacto anterior. Este tipo es útil cuando necesitas registrar, escribir archivos o actualizar estado en orden sin usar locks. Si un thread muere por una excepción, el pool creará un nuevo thread como reemplazo. Su cola también es ilimitada.
3.4. newScheduledThreadPool(n)
Este tipo se usa para tareas programadas o repetidas:
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(
() -> System.out.println("Sincronizar inventario a las " + java.time.LocalTime.now()),
0, 5, TimeUnit.SECONDS);
scheduleAtFixedRate ejecuta la tarea cada 5 segundos, contando desde el inicio de la ejecución anterior. Por el contrario, scheduleWithFixedDelay cuenta 5 segundos desde que la ejecución anterior termina. Deberías elegir scheduleWithFixedDelay cuando el tiempo de ejecución de la tarea es impredecible.
Nota: Si la tarea programada lanza una excepción, todas las ejecuciones posteriores serán canceladas silenciosamente, sin log. Siempre envuelve el cuerpo de la tarea en
try/catch.
3.5. newWorkStealingPool()
Este método devuelve un ForkJoinPool con un número de threads igual al número de CPUs. Cada thread tiene su propia cola. Un thread inactivo “robará” trabajo de la cola de otro thread.
ExecutorService pricingExecutor = Executors.newWorkStealingPool();
Este tipo es apropiado para tareas CPU-bound que se pueden dividir recursivamente, por ejemplo cálculos paralelos sobre grandes conjuntos de datos. Los threads del pool son daemon, y el pool no garantiza orden de ejecución.
3.6. newVirtualThreadPerTaskExecutor() (Java 21)
Con este tipo, cada tarea se ejecuta en un nuevo virtual thread. Los virtual threads son muy económicos, por lo que puedes crear cientos de miles.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int customerId = i;
executor.submit(() -> {
Thread.sleep(100); // simular llamada HTTP a CustomerService
return customerId;
});
}
}
El código anterior ejecuta 10.000 tareas bloqueadas durante 100 ms y se completa en unos pocos cientos de ms. Si usas un fixed pool de 10 threads, la misma cantidad de trabajo tomaría alrededor de 100 segundos.
Esta es una buena opción predeterminada para tareas I/O-bound como llamadas HTTP, consultas de base de datos o lectura de archivos. Debes recordar tres cosas:
- No hagas pool de virtual threads. Nacen para usarse una vez y luego descartarse.
- Para limitar la carga en recursos externos, usa
Semaphore. Por ejemplo, cuando la base de datos solo admite 20 conexiones, no confíes en el tamaño del pool para limitarla. - En Java 21, un bloque
synchronizedcon operaciones de bloqueo “pinará” el virtual thread al carrier thread. En ese caso, los beneficios del virtual thread se pierden. PrefiereReentrantLocken esos segmentos.
Ejemplo de limitar el número de consultas simultáneas usando Semaphore:
Semaphore dbPermits = new Semaphore(20);
executor.submit(() -> {
dbPermits.acquire();
try {
return orderRepository.findByCustomerId(customerId);
} finally {
dbPermits.release();
}
});
4. Configurar manualmente ThreadPoolExecutor
Los factory methods en Executors a menudo ocultan un parámetro peligroso: cola ilimitada o número ilimitado de threads. Para sistemas de producción, deberías inicializar manualmente ThreadPoolExecutor para controlar todo:
ThreadFactory factory = Thread.ofPlatform().name("payment-", 0).factory();
ThreadPoolExecutor paymentExecutor = new ThreadPoolExecutor(
4, // corePoolSize
8, // maximumPoolSize
60, TimeUnit.SECONDS, // keepAlive para threads más allá del core
new ArrayBlockingQueue<>(100), // cola limitada
factory, // nombre: payment-0, payment-1...
new ThreadPoolExecutor.CallerRunsPolicy());
Cuando llega una nueva tarea, ThreadPoolExecutor la procesa en exacto este orden:
- Si el número de threads es menor que
corePoolSize, el pool crea un nuevo thread. - Si no, el pool pone la tarea en la cola.
- Si la cola está llena y el número de threads es menor que
maximumPoolSize, el pool crea más threads. - Si alcanza ambos límites, el pool llama a
RejectedExecutionHandler.
Como resultado, maximumPoolSize solo tiene efecto cuando la cola está llena. Si usas una cola sin límite, el pool nunca excederá corePoolSize. Esta es una fuente de confusión muy común.
Java proporciona cuatro rejection policies listos para usar:
| Policy | Comportamiento |
|---|---|
AbortPolicy (por defecto) |
Lanza RejectedExecutionException |
CallerRunsPolicy |
El thread que llama a execute() ejecuta la tarea, lo que ayuda a desacelerar el productor (back-pressure) |
DiscardPolicy |
Descarta la tarea silenciosamente |
DiscardOldestPolicy |
Descarta la tarea más antigua de la cola e intenta de nuevo |
Nombrar threads a través de ThreadFactory hace que los thread dumps y los logs sean mucho más legibles. Comparar payment-3 con pool-7-thread-3 muestra inmediatamente la diferencia.
5. Elige el tipo correcto
5.1. Tamaño del pool
Para tareas CPU-bound, el número de threads debe ser aproximadamente igual al número de cores:
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuExecutor = Executors.newFixedThreadPool(cores);
Más threads que cores solo aumenta el costo del cambio de contexto.
Para tareas I/O-bound ejecutadas en platform threads, puedes usar la siguiente fórmula aproximada:
número de threads ≈ número de cores × (1 + tiempo de espera / tiempo de cálculo)
Ejemplo: una máquina tiene 8 cores, cada solicitud espera 90 ms de I/O y 10 ms de cálculo. El resultado es 8 × (1 + 9) = 80 threads. Este es solo el punto de partida. Necesitas medir en la práctica con pruebas de carga.
5.2. Tabla de decisión
| Caso de uso | Opción |
|---|---|
| Computación CPU intensiva | newFixedThreadPool(cores) o ForkJoinPool |
| Llamadas HTTP, consultas BD en grandes cantidades | newVirtualThreadPerTaskExecutor() + Semaphore |
| Necesita orden secuencial | newSingleThreadExecutor() |
| Tareas programadas | newScheduledThreadPool(n) |
| Producción, necesita back-pressure | ThreadPoolExecutor con cola limitada |
| Picos de carga ilimitados | Evita newCachedThreadPool() |
6. Thread pool en Spring Boot 3.x
Primero, necesitas la dependencia Maven:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Luego, declara un executor dedicado para enviar correos:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "emailExecutor")
public ThreadPoolTaskExecutor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("email-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
return executor;
}
}
@Service
public class EmailService {
@Async("emailExecutor")
public void sendOrderConfirmation(String customerEmail) {
System.out.println(Thread.currentThread().getName() + " envía correo a " + customerEmail);
}
}
email-1 envía correo a an.nguyen@example.com
Algunos puntos a tener en cuenta:
- El
queueCapacitypredeterminado deThreadPoolTaskExecutoresInteger.MAX_VALUE. Si no estableces este valor,maxPoolSizeserá inútil, como se explicó en la sección 4. setWaitForTasksToCompleteOnShutdown(true)asegura que los correos que se están enviando no se interrumpan cuando la aplicación se apaga.- Si deseas usar virtual threads para
@Async, Tomcat y@Scheduled, solo necesitas agregar una línea de configuración:
spring.threads.virtual.enabled=true
7. Prueba del comportamiento del pool
La siguiente prueba confirma el orden correcto de procesamiento descrito en la sección 4: threads core primero, luego cola, luego threads adicionales, finalmente rechazo.
import org.junit.jupiter.api.Test;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
class PaymentExecutorTest {
@Test
void shouldRejectWhenThreadsAndQueueAreFull() throws Exception {
CountDownLatch release = new CountDownLatch(1);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 2, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1),
new ThreadPoolExecutor.AbortPolicy());
Runnable blockingPayment = () -> {
try {
release.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
try {
executor.execute(blockingPayment); // 1: crear thread core
executor.execute(blockingPayment); // 2: entrar en cola
executor.execute(blockingPayment); // 3: cola llena -> crear segundo thread
assertThat(executor.getPoolSize()).isEqualTo(2);
assertThat(executor.getQueue()).hasSize(1);
assertThatThrownBy(() -> executor.execute(blockingPayment))
.isInstanceOf(RejectedExecutionException.class);
} finally {
release.countDown();
executor.shutdown();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
}
CountDownLatch mantiene todas las tareas en estado bloqueado, por lo que el estado del pool permanece estable cuando haces la verificación. La cuarta tarea no tiene lugar, por lo que AbortPolicy lanza una excepción. El bloque finally libera el latch y cierra el pool, por lo que la prueba no deja threads en la basura.
8. Errores comunes
- Usar
Executors.newFixedThreadPoolcon productores no controlados. La cola sin límite se hinchará y causará OOM. UsaArrayBlockingQueuecon límite. - Crear un nuevo pool en cada solicitud. El pool debe ser compartido, por ejemplo, decláralo como bean o campo
static final. Crear y olvidar cerrarlo causará una fuga de threads. - Tragar excepciones. Con
submit(), la excepción se mantiene enFuture. Si no llamas aget(), nunca verás el error. Llama aget()o captura el error dentro de la tarea. - Deadlock porque la tarea padre espera a la tarea hija en el mismo pool pequeño. Ejemplo: pool de 2 threads, ambas tareas padre llaman a
get()en la tarea hija que está en la cola. No hay ningún thread disponible para ejecutar la tarea hija, por lo que todo el sistema se bloquea. - Poner
ThreadLocalpesado en virtual threads. Puedes tener millones de virtual threads, cada uno manteniendo una copia, por lo que la memoria aumenta muy rápidamente. - Llamar a
shutdownNow()cuando la tarea no maneja interrupt. La tarea seguirá ejecutándose. Las tareas largas deben verificarThread.currentThread().isInterrupted().
Nota: No deberías usar
ExecutorServicepara flujos de procesamiento complejos con múltiples pasos interdependientes. Para ese caso, usaCompletableFuture, que aún puede ejecutarse en el executor que configures.
9. Conclusión
Cada tipo de thread pool resuelve un problema diferente:
- Fixed pool o
ForkJoinPoolpara tareas CPU-bound. - Virtual threads con
Semaphorepara tareas I/O-bound en Java 21. - Single thread executor cuando necesites orden.
- Scheduled pool para tareas programadas.
En producción, configura manualmente ThreadPoolExecutor o ThreadPoolTaskExecutor con una cola limitada, una rejection policy clara y nombres de thread fáciles de identificar. Finalmente, deberías verificar el tamaño del pool con pruebas de carga en lugar de solo confiar en la fórmula.