ExecutorService e thread pool: escolha o tipo certo para cada problema
1. Visão geral
Criar new Thread() para cada tarefa é fácil de escrever, mas difícil de controlar quando a carga aumenta. ExecutorService resolve isso ao reutilizar threads e enfileirar tarefas. A classe Executors fornece vários tipos de pool, e escolher o tipo errado pode causar OutOfMemoryError ou travar a aplicação.
Este artigo passa por cada tipo de pool, como ThreadPoolExecutor processa tarefas internamente, como escolher o tamanho do pool e como configurar no Spring Boot 3.x. Depois de ler, você saberá qual pool usar para tarefas CPU-bound, I/O-bound ou tarefas agendadas.
2. ExecutorService básico
Exemplo a seguir usa um pool simples para executar uma tarefa com 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 pedidos: " + total.get());
}
}
static int countOrders(String month) {
return 128; // simula consulta ao banco de dados
}
}
Total de pedidos: 128
O código funciona assim:
submit()recebe umCallablee retornaFuture<Integer>imediatamente. A tarefa roda em uma thread do pool.total.get()bloqueia a thread atual até obter o resultado.- Do Java 19,
ExecutorServiceimplementaAutoCloseable. Ao sair do blocotry,close()chamashutdown()e aguarda todas as tarefas terminarem.
Nota: Se você não fechar o executor, threads não-daemon manterão a JVM viva. O programa
mainnão sairá.
3. Tipos de pool no Executors
3.1. newFixedThreadPool(n)
Este pool tem exatamente n threads. Tarefas excedentes são colocadas em uma LinkedBlockingQueue ilimitada.
ExecutorService invoiceExecutor = Executors.newFixedThreadPool(8);
Este tipo é adequado quando você quer limitar o número de tarefas executadas simultaneamente, por exemplo, tarefas CPU-bound. O risco está na fila. Se o produtor enviar tarefas mais rápido do que a taxa de processamento, a fila cresce até a heap se esgotar.
3.2. newCachedThreadPool()
Este pool não tem threads fixas. Usa SynchronousQueue, ou seja, não há uma fila real. Cada nova tarefa é atribuída a uma thread ociosa se houver uma; caso contrário, o pool cria uma nova thread. Threads ociosas por mais de 60 segundos são destruídas.
ExecutorService notificationExecutor = Executors.newCachedThreadPool();
Este pool é adequado para muitas tarefas curtas e carga baixa e irregular. Quando a carga salta, pode criar milhares de platform threads. Cada thread consome cerca de 1 MB de stack por padrão, portanto a aplicação pode facilmente encontrar OutOfMemoryError: unable to create native thread.
3.3. newSingleThreadExecutor()
Este pool tem apenas uma thread e as tarefas são executadas sequencialmente na ordem exata em que foram enviadas.
ExecutorService auditLogExecutor = Executors.newSingleThreadExecutor();
auditLogExecutor.submit(() -> System.out.println("Pedido #1 criado"));
auditLogExecutor.submit(() -> System.out.println("Pedido #1 pago"));
Pedido #1 criado
Pedido #1 pago
As duas linhas de log sempre imprimem nesta ordem exata. Este tipo é útil quando você precisa registrar, gravar arquivo ou atualizar estado em ordem sem usar locks. Se a thread morrer por exceção, o pool criará uma nova thread para substituí-la. Sua fila também é ilimitada.
3.4. newScheduledThreadPool(n)
Loại này dùng cho tác vụ hẹn giờ hoặc lặp lại:
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(
() -> System.out.println("Sincroniza estoque às " + java.time.LocalTime.now()),
0, 5, TimeUnit.SECONDS);
scheduleAtFixedRate executa a tarefa a cada 5 segundos, contando a partir do início da execução anterior. Por outro lado, scheduleWithFixedDelay conta 5 segundos a partir de quando a execução anterior termina. Você deve escolher scheduleWithFixedDelay quando o tempo de execução da tarefa é instável.
Nota: Se a tarefa agendada lançar uma exceção, todas as execuções subsequentes serão canceladas silenciosamente, sem nenhum log. Sempre envolva o corpo da tarefa em
try/catch.
3.5. newWorkStealingPool()
Este método retorna um ForkJoinPool com número de threads igual ao número de CPUs. Cada thread tem sua própria fila. Uma thread ociosa irá “roubar” trabalho da fila de outra thread.
ExecutorService pricingExecutor = Executors.newWorkStealingPool();
Este tipo é adequado para tarefas CPU-bound que podem ser divididas recursivamente, como computação paralela em grandes conjuntos de dados. As threads do pool são daemon e o pool não garante ordem de execução.
3.6. newVirtualThreadPerTaskExecutor() (Java 21)
Com este tipo, cada tarefa é executada em uma nova virtual thread. Virtual threads são muito baratas, então você pode criar centenas de milhares delas.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int customerId = i;
executor.submit(() -> {
Thread.sleep(100); // simula chamada HTTP para CustomerService
return customerId;
});
}
}
O código acima executa 10.000 tarefas bloqueantes de 100 ms e completa em poucas centenas de ms. Se usasse um pool fixo de 10 threads, o mesmo volume de trabalho levaria cerca de 100 segundos.
Esta é uma boa escolha padrão para tarefas I/O-bound como chamadas HTTP, consultas de banco de dados ou leitura de arquivo. Você precisa lembrar de três coisas:
- Não agregue virtual threads em pool. Elas nascem para serem usadas uma vez e descartadas.
- Para limitar carga em recursos externos, use
Semaphore. Por exemplo, quando o banco de dados suporta apenas 20 conexões, não dependa do tamanho do pool para limitar. - No Java 21, blocos
synchronizedcom operações bloqueantes “fixarão” a virtual thread na carrier thread. Nesse caso, os benefícios da virtual thread se perdem. PrefiraReentrantLockem tais seções.
Exemplo limitando número de consultas simultâneas com Semaphore:
Semaphore dbPermits = new Semaphore(20);
executor.submit(() -> {
dbPermits.acquire();
try {
return orderRepository.findByCustomerId(customerId);
} finally {
dbPermits.release();
}
});
4. Configurando ThreadPoolExecutor manualmente
Os factory methods em Executors geralmente escondem um parâmetro perigoso: filas ilimitadas ou número ilimitado de threads. Para sistemas em produção, você deve instanciar ThreadPoolExecutor manualmente para controlar tudo:
ThreadFactory factory = Thread.ofPlatform().name("payment-", 0).factory();
ThreadPoolExecutor paymentExecutor = new ThreadPoolExecutor(
4, // corePoolSize
8, // maximumPoolSize
60, TimeUnit.SECONDS, // keepAlive para threads além do core
new ArrayBlockingQueue<>(100), // fila com limite
factory, // nomes: payment-0, payment-1...
new ThreadPoolExecutor.CallerRunsPolicy());
Quando uma nova tarefa chega, ThreadPoolExecutor processa na seguinte ordem exata:
- Se o número de threads for menor que
corePoolSize, o pool cria uma nova thread. - Caso contrário, o pool coloca a tarefa na fila.
- Se a fila estiver cheia e o número de threads for menor que
maximumPoolSize, o pool cria uma thread adicional. - Se ambos os limites forem atingidos, o pool chama
RejectedExecutionHandler.
Como resultado, maximumPoolSize só tem efeito quando a fila está cheia. Se você usar uma fila ilimitada, o pool nunca excederá corePoolSize. Esta é uma fonte comum de confusão.
Java fornece quatro rejection policies prontas:
| Policy | Comportamento |
|---|---|
AbortPolicy (padrão) |
Lança RejectedExecutionException |
CallerRunsPolicy |
A thread que chamou execute() executa a tarefa, ajudando a desacelerar o producer (back-pressure) |
DiscardPolicy |
Descarta a tarefa, sem aviso |
DiscardOldestPolicy |
Descarta a tarefa mais antiga na fila e tenta novamente |
Nomear threads via ThreadFactory torna thread dumps e logs muito mais legíveis. Comparar payment-3 com pool-7-thread-3 deixa clara a diferença.
5. Escolhendo o tipo correto
5.1. Tamanho do pool
Para tarefas CPU-bound, o número de threads deve ser aproximadamente igual ao número de cores:
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuExecutor = Executors.newFixedThreadPool(cores);
Mais threads do que cores apenas aumenta o custo de troca de contexto.
Para tarefas I/O-bound em platform threads, você pode usar uma fórmula aproximada:
número de threads ≈ número de cores × (1 + tempo de espera / tempo de computação)
Exemplo: máquina com 8 cores, cada request aguarda I/O 90 ms e computa 10 ms. Resultado: 8 × (1 + 9) = 80 threads. Este é apenas um ponto de partida. Você deve validar com load testing real.
5.2. Tabela de decisão
| Problema | Escolha |
|---|---|
| Computação CPU intensiva | newFixedThreadPool(cores) ou ForkJoinPool |
| Chamar APIs, consultar BD em grande volume | newVirtualThreadPerTaskExecutor() + Semaphore |
| Precisa de ordem sequencial | newSingleThreadExecutor() |
| Tarefas agendadas | newScheduledThreadPool(n) |
| Produção, precisa back-pressure | ThreadPoolExecutor com fila limitada |
| Carga em picos, sem limite | Evite newCachedThreadPool() |
6. Thread pool no Spring Boot 3.x
Primeiro, você precisa da dependência 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>
Em seguida, declare um executor separado para envio de email:
@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() + " envia email para " + customerEmail);
}
}
email-1 envia email para an.nguyen@example.com
Alguns pontos a observar:
- O padrão
queueCapacitydeThreadPoolTaskExecutoréInteger.MAX_VALUE. Se você não definir este valor,maxPoolSizeserá inútil, como explicado na seção 4. setWaitForTasksToCompleteOnShutdown(true)garante que emails sendo enviados não sejam interrompidos quando a aplicação desliga.- Se quiser usar virtual threads para
@Async, Tomcat e@Scheduled, basta adicionar uma linha de configuração:
spring.threads.virtual.enabled=true
7. Testando o comportamento do pool
O teste abaixo confirma a ordem correta de processamento descrita na seção 4: threads core primeiro, depois fila, depois threads adicionais, finalmente rejeição.
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: cria thread core
executor.execute(blockingPayment); // 2: vai para fila
executor.execute(blockingPayment); // 3: fila cheia -> cria segunda 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 mantém todas as tarefas bloqueadas, permitindo que o estado do pool fique estável durante a verificação. A quarta tarefa não tem espaço, então AbortPolicy lança uma exceção. O bloco finally libera o latch e fecha o pool, garantindo que o teste não deixe threads órfãs.
8. Erros comuns
- Usar
Executors.newFixedThreadPoolcom producer descontrolado. Uma fila ilimitada crescerá e causará OOM. UseArrayBlockingQueuecom limite. - Criar um novo pool a cada request. O pool deve ser compartilhado, como um bean declarado ou campo
static final. Criar e esquecer de fechar causa vazamento de threads. - Engolir exceções. Com
submit(), exceções são mantidas emFuture. Se você não chamarget(), nunca verá o erro. Chameget()ou capture erros no corpo da tarefa. - Deadlock por tarefas pai aguardando tarefas filhas no mesmo pool pequeno. Exemplo: pool de 2 threads, ambas as tarefas pai chamam
get()em tarefas filhas que estão na fila. Nenhuma thread disponível para executar as tarefas filhas, então o sistema congela. - Colocar
ThreadLocalpesado em virtual threads. Você pode ter milhões de virtual threads, cada uma mantendo uma cópia, causando crescimento rápido de memória. - Chamar
shutdownNow()quando tarefas não lidam com interrupção. Nesse caso, as tarefas continuam executando. Tarefas longas devem verificarThread.currentThread().isInterrupted().
Nota: Não use
ExecutorServicepara fluxos complexos com múltiplas etapas dependentes. Para isso, useCompletableFuture, que ainda pode ser executado em um executor que você configura.
9. Conclusão
Cada tipo de thread pool resolve um problema específico:
- Pool fixo ou
ForkJoinPoolpara tarefas CPU-bound. - Virtual threads com
Semaphorepara tarefas I/O-bound no Java 21. - Executor de thread única quando ordem é importante.
- Pool agendado para tarefas periódicas.
Em produção, configure manualmente ThreadPoolExecutor ou ThreadPoolTaskExecutor com fila limitada, rejection policy clara e nomes de thread legíveis. Por fim, valide o tamanho do pool com load testing em vez de depender apenas de fórmulas.