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:
- 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.
- Limites de recursos. Uma thread de plataforma em uma JVM 64 bits consome cerca de 1 MB de pilha —
64 GBde RAM equivalem a~64.000threads, 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. - Paralelismo previsível. Um pool com
Nworkers fornece exatamenteNtarefas 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:
- 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).
- 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. - 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âmetro | O que controla |
|---|---|
corePoolSize | Número mínimo de workers mantidos vivos mesmo quando ociosos. Threads até esse número não são encerradas. |
maximumPoolSize | Limite máximo total de workers. O pool só cresce além do core quando a fila está cheia. |
keepAliveTime | Por quanto tempo uma worker ociosa acima do tamanho core espera antes de ser encerrada. |
workQueue | Onde as tarefas pendentes ficam. LinkedBlockingQueue (ilimitada) vs ArrayBlockingQueue (limitada) vs SynchronousQueue (sem buffer) determina completamente o comportamento do pool. |
threadFactory | Como as threads trabalhadoras são construídas. Use isso para definir nomes, status de daemon, prioridade, handlers de exceções não capturadas. |
handler | O 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ítica | Comportamento |
|---|---|
AbortPolicy (padrão) | Lança RejectedExecutionException. O chamador sabe que a tarefa foi descartada. |
CallerRunsPolicy | A thread chamadora executa a tarefa ela mesma. Desacelera o chamador, fornecendo contrapressão. |
DiscardPolicy | Descarta silenciosamente a tarefa. Use apenas para trabalho de telemetria do tipo "melhor esforço". |
DiscardOldestPolicy | Descarta 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 taskDois deles têm armadilhas conhecidas:
newFixedThreadPoolusa umaLinkedBlockingQueueilimitada. Sob sobrecarga sustentada, a fila cresce sem limite — eventualmente OOM. O tamanho do pool é fixo; o trabalho acumulado atrás dele não é.newCachedThreadPooltemmaximum = 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.
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 doCallerRunsPolicydesacelerando o submissor sempre que a fila enchia. - Algumas tarefas reportaram o nome de thread
main. Isso é oCallerRunsPolicyem ação: quando a fila estava cheia e todos os workers estavam ocupados,pool.executeexecutou 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 docoremesmo sob carga sustentada porque a fila limitada tinha espaço para as rajadas breves. Com uma fila ilimitada (o padrão doExecutors.newFixedThreadPool), a fila teria aceitado todas as tarefas elargestPoolSizeteria 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.