W3docs

Pools de Threads em Java

Reutilize threads para executar muitas tarefas com eficiência usando pools de threads Java e os parâmetros de configuração do ThreadPoolExecutor.

Criar uma thread é custoso. Cada new Thread() aloca cerca de 1 MB de pilha nativa, solicita ao SO que agende uma nova thread do kernel e adiciona carga ao GC. Um programa que cria uma thread por tarefa funciona bem para dez tarefas; colapsa em dez mil. A solução é um pool de threads — um pequeno conjunto de threads trabalhadoras de longa duração que retiram tarefas de uma fila. O pool é dono das threads; você é dono das tarefas.

Este capítulo é o conceitual — o que é um pool, os parâmetros que o configuram e os modos de falha. O próximo capítulo, Framework Executor, apresenta os tipos Executor/ExecutorService que você usa para se comunicar com um pool. Os dois estão interligados; este capítulo foca no o quê e no porquê, o próximo no como.

Por que usar pool?

Três problemas que um pool resolve:

  1. Custo de criação de threads. Alocar uma pilha nativa e solicitar ao SO uma nova thread leva algo em torno de milissegundos. Reutilizar threads existentes leva microssegundos. Em escala, a diferença é o que separa um servidor que aguenta a carga de um que não aguenta.
  2. Limites de recursos. Uma thread de plataforma em uma JVM 64 bits consome cerca de 1 MB de pilha — 64 GB de RAM equivalem a ~64.000 threads, e o SO tem sua própria sobrecarga por thread. A criação ilimitada de threads implica consumo ilimitado de RAM. Um pool impõe um limite nessa contagem.
  3. Paralelismo previsível. Um pool com N workers fornece exatamente N tarefas paralelas. Isso se adapta muito melhor ao objetivo de "usar todos os 16 núcleos" do que "criar uma thread por requisição e torcer."

O custo do pooling: você precisa dimensioná-lo. Muito pequeno → tarefas se acumulam na fila e a latência aumenta. Muito grande → a troca de contexto domina e a taxa de transferência cai. O capítulo sobre dimensionamento (framework executor) cobre as regras práticas; este capítulo trata do que o pool é.

A anatomia de um pool

Um pool de threads é essencialmente três coisas:

  1. Um conjunto limitado de threads trabalhadoras. As workers executam um laço: retira uma tarefa da fila, executa, retira a próxima, repete. Elas vivem pelo tempo de vida do pool (ou até ficar ociosas por tempo demais, dependendo da política).
  2. Uma fila de tarefas. Quando você submete trabalho e nenhuma worker está livre, a tarefa vai para cá. O tipo de fila — LinkedBlockingQueue, ArrayBlockingQueue, SynchronousQueue — afeta como o pool cresce sob carga.
  3. Uma API de submissão. execute(Runnable), submit(Callable), invokeAll(...) — as formas de colocar trabalho no pool.

Em Java, tudo isso está encapsulado em java.util.concurrent.ThreadPoolExecutor, que é a classe subjacente de praticamente todo pool que você vai encontrar.

Os sete parâmetros do ThreadPoolExecutor

Construção direta (que raramente se faz, mas os parâmetros são o que toda factory passa internamente):

new ThreadPoolExecutor(
  int corePoolSize,
  int maximumPoolSize,
  long keepAliveTime, TimeUnit unit,
  BlockingQueue<Runnable> workQueue,
  ThreadFactory threadFactory,
  RejectedExecutionHandler handler
);
ParâmetroO que controla
corePoolSizeNúmero mínimo de workers mantidos vivos mesmo quando ociosos. Threads até esse número não são encerradas.
maximumPoolSizeLimite máximo total de workers. O pool só cresce além do core quando a fila está cheia.
keepAliveTimePor quanto tempo uma worker ociosa acima do tamanho core espera antes de ser encerrada.
workQueueOnde as tarefas pendentes ficam. LinkedBlockingQueue (ilimitada) vs ArrayBlockingQueue (limitada) vs SynchronousQueue (sem buffer) determina completamente o comportamento do pool.
threadFactoryComo as threads trabalhadoras são construídas. Use isso para definir nomes, status de daemon, prioridade, handlers de exceções não capturadas.
handlerO que acontece quando tanto os workers quanto a fila estão saturados. Padrão: AbortPolicy.

A interação não óbvia: o pool prefere encher a fila antes de criar novas threads acima do core. Portanto, uma fila ilimitada significa que o pool nunca cresce além do core — ele apenas enfileira indefinidamente. Uma fila limitada (ou SynchronousQueue) é o que torna o parâmetro max significativo.

As quatro políticas de rejeição

Quando submit não consegue aceitar uma tarefa (fila cheia, todos os workers máximos ocupados), o RejectedExecutionHandler decide o que acontece:

PolíticaComportamento
AbortPolicy (padrão)Lança RejectedExecutionException. O chamador sabe que a tarefa foi descartada.
CallerRunsPolicyA thread chamadora executa a tarefa ela mesma. Desacelera o chamador, fornecendo contrapressão.
DiscardPolicyDescarta silenciosamente a tarefa. Use apenas para trabalho de telemetria do tipo "melhor esforço".
DiscardOldestPolicyDescarta a tarefa mais antiga na fila e submete a nova. Útil para "somente o mais recente importa."

Lançar exceção por padrão geralmente é a escolha segura. CallerRunsPolicy é um mecanismo inteligente de contrapressão — quando o pool está sobrecarregado, o submissor é desacelerado para acompanhar, o que naturalmente limita a taxa da fonte.

Os métodos factory do Executors — e por que você deveria evitá-los na maioria dos casos

java.util.concurrent.Executors fornece factories de conveniência:

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 task

Dois deles têm armadilhas conhecidas:

  • newFixedThreadPool usa uma LinkedBlockingQueue ilimitada. Sob sobrecarga sustentada, a fila cresce sem limite — eventualmente OOM. O tamanho do pool é fixo; o trabalho acumulado atrás dele não é.
  • newCachedThreadPool tem maximum = Integer.MAX_VALUE. Sob uma rajada sustentada de trabalho, cria threads sem limite — eventualmente esgota o limite de threads por processo do SO e derruba a JVM.

Esses são adequados para tarefas pequenas, demos e scripts pontuais. Para código em produção, construa um ThreadPoolExecutor diretamente com uma fila limitada, um max razoável e uma política de rejeição explícita.

A exceção: newVirtualThreadPerTaskExecutor (Java 21+) distribui threads virtuais, que são baratas o suficiente para que "uma por tarefa" realmente funcione. Abordamos isso no capítulo sobre threads virtuais.

Ciclo de vida: shutdown vs shutdownNow

Um pool continua em execução até você mandá-lo parar. Os dois 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 é a versão educada: novas submissões não são aceitas, o trabalho existente termina, depois o pool encerra. shutdownNow é a versão brusca: interrompe os workers, retorna a fila pendente. Use shutdown para saída limpa; use shutdownNow após um shutdown + prazo de awaitTermination se o trabalho não terminar.

O padrão combinado de shutdown da documentação do 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();
}

Você quase sempre vai querer exatamente esse formato em qualquer código que seja dono de um pool. Sem shutdown, a JVM mantém os workers vivos (não-daemon por padrão) e não encerra.

Nomeando workers via ThreadFactory

O Executors.defaultThreadFactory() padrão nomeia as threads pool-1-thread-1, pool-1-thread-2, etc. Isso é um pequeno avanço sobre Thread-7, mas ainda não é ótimo. Código em produção usa uma factory com nomes:

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());

A factory é sua chance de definir cada propriedade por thread: nome, flag de daemon, prioridade, handler de exceções não capturadas, thread-group. Em um heap dump com 200 threads, uma thread chamada image-worker-7 é uma thread que você consegue encontrar.

Um exemplo prático: construindo um pool limitado com contrapressão

O programa abaixo constrói um ThreadPoolExecutor com 4 workers, uma fila limitada de 8 e o handler de rejeição CallerRunsPolicy — para que o submissor seja desacelerado quando o pool estiver sobrecarregado, em vez de lançar uma exceção.

java— editable, runs on the server

O que observar na execução:

  • O pool tinha um limite estrito de 4 threads trabalhadoras. Com 40 tarefas de 50 ms cada, o tempo serial-por-pool idealizado é 40 * 50 / 4 = 500 ms. O tempo real de relógio foi próximo disso — menos o custo do CallerRunsPolicy desacelerando o submissor sempre que a fila enchia.
  • Algumas tarefas reportaram o nome de thread main. Isso é o CallerRunsPolicy em ação: quando a fila estava cheia e todos os workers estavam ocupados, pool.execute executou a tarefa na thread chamadora em vez de enfileirar ou lançar exceção. O submissor ficou mais lento; o sistema permaneceu limitado. Isso é contrapressão funcionando corretamente.
  • pool.getLargestPoolSize() foi 4 — o máximo permaneceu igual ao core. O pool não cresceu além do core mesmo sob carga sustentada porque a fila limitada tinha espaço para as rajadas breves. Com uma fila ilimitada (o padrão do Executors.newFixedThreadPool), a fila teria aceitado todas as tarefas e largestPoolSize teria permanecido em 4 — mas a memória teria aumentado enquanto as tarefas se acumulavam.
  • A sequência de shutdown é o padrão de produção. shutdown() disse ao pool para parar de aceitar novas submissões; awaitTermination(5, SECONDS) esperou até 5 segundos pelo trabalho em andamento; se o trabalho não tivesse terminado, shutdownNow() teria interrompido os workers restantes. Sem essas chamadas, a JVM não encerra — os workers não-daemon a mantêm viva.
  • A thread factory deu a cada worker um nome significativo (worker-1 ... worker-4) e um handler de exceções não capturadas. Em um thread dump ou profiler de produção, esses nomes fazem a diferença entre "sei qual subsistema é esse" e "não tenho ideia." Defina-os em todo pool que você criar.

O que vem a seguir

O próximo capítulo, Framework Executor em Java, apresenta a hierarquia de tipos que você usa para se comunicar com pools de threads — Executor, ExecutorService, ScheduledExecutorService — e como dimensionar um pool para cargas de trabalho CPU-bound e I/O-bound.

Prática

Prática
Você chama `Executors.newFixedThreadPool(8)` e submete tarefas mais rápido do que o pool consegue processar. O pool tem 8 threads. Qual é o modo de falha patológico sob sobrecarga sustentada?
Você chama `Executors.newFixedThreadPool(8)` e submete tarefas mais rápido do que o pool consegue processar. O pool tem 8 threads. Qual é o modo de falha patológico sob sobrecarga sustentada?
Was this page helpful?