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 um Callable e retorna Future<Integer> imediatamente. A tarefa roda em uma thread do pool.
  • total.get() bloqueia a thread atual até obter o resultado.
  • Do Java 19, ExecutorService implementa AutoCloseable. Ao sair do bloco try, close() chama shutdown() 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 main nã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 synchronized com operações bloqueantes “fixarão” a virtual thread na carrier thread. Nesse caso, os benefícios da virtual thread se perdem. Prefira ReentrantLock em 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:

  1. Se o número de threads for menor que corePoolSize, o pool cria uma nova thread.
  2. Caso contrário, o pool coloca a tarefa na fila.
  3. Se a fila estiver cheia e o número de threads for menor que maximumPoolSize, o pool cria uma thread adicional.
  4. 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 queueCapacity de ThreadPoolTaskExecutor é Integer.MAX_VALUE. Se você não definir este valor, maxPoolSize será 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.newFixedThreadPool com producer descontrolado. Uma fila ilimitada crescerá e causará OOM. Use ArrayBlockingQueue com 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 em Future. Se você não chamar get(), nunca verá o erro. Chame get() 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 ThreadLocal pesado 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 verificar Thread.currentThread().isInterrupted().

Nota: Não use ExecutorService para fluxos complexos com múltiplas etapas dependentes. Para isso, use CompletableFuture, 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 ForkJoinPool para tarefas CPU-bound.
  • Virtual threads com Semaphore para 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.

javaconcurrencyexecutorservicethread-poolspring-boot