Encerrando um ExecutorService, e Como a JVM Decide Sair
Objective
Iniciar um ExecutorService ou uma worker thread é a metade fácil do seu ciclo de vida;
pará-lo de forma limpa é a metade fácil de errar. shutdown() e
shutdownNow() oferecem dois modos de encerramento diferentes com um trade-off real entre
segurança e responsividade, nenhum dos dois bloqueia por si só, um design produtor-consumidor
construído sobre uma BlockingQueue precisa de seu próprio sinal cooperativo para parar, uma
thread que morre por uma exceção não capturada faz isso silenciosamente a menos que algo esteja
registrado para perceber, e a própria JVM decide se sai enquanto suas threads ainda estão
rodando com base em um único boolean por thread. Este conceito cobre os quatro: encerrar um
executor, sinalizar um consumidor baseado em fila para parar, capturar a morte não percebida de
uma thread, e a mecânica de daemon/user thread e shutdown hook que governa a saída da JVM.
Use Cases
- Encerrar um thread pool quando uma aplicação (ou um componente com um ciclo de vida mais curto que a JVM inteira) termina de usá-lo, sem abandonar trabalho que já está enfileirado.
- Construir um pipeline produtor-consumidor sobre uma
BlockingQueueonde o consumidor precisa de um sinal confiável de "não vem mais trabalho" que não pode entrar em race com itens ainda sendo enfileirados. - Garantir que o crash de uma background thread de longa duração apareça nos logs em vez de desaparecer silenciosamente.
- Decidir se uma background thread deve manter a JVM viva até terminar, ou deve ser abandonada automaticamente assim que toda outra thread tiver terminado.
- Registrar limpeza (flush de buffers, fechamento de arquivos) que precisa rodar quando a JVM encerra normalmente, inclusive com Ctrl-C.
Deep Dive
shutdown() vs shutdownNow(), e esperar por qualquer um dos dois terminar
shutdown() é o modo gracioso: o executor para de aceitar novas tasks, mas
tudo que já está enfileirado ou rodando tem permissão para terminar.
javaExecutorService pool = Executors.newFixedThreadPool(4);
pool.execute(task1);
pool.execute(task2);
pool.shutdown(); // no new tasks accepted; task1/task2 run to completion
pool.execute(task3); // rejected — throws RejectedExecutionExceptionshutdownNow() é o modo agressivo: também para de aceitar novas tasks, mas
além disso interrompe toda task atualmente rodando (usando o mesmo
mecanismo de interrupção coberto no conceito complementar sobre
cancelamento de tasks e interrupção —
se uma task realmente para depende de se o código dela checa
isInterrupted() ou chama um método bloqueante interrompível) e retorna a
List<Runnable> de tasks que foram submetidas mas nunca chegaram a começar, para que
quem chamou possa logá-las ou resubmetê-las depois:
javaList<Runnable> neverStarted = pool.shutdownNow();
// neverStarted contains queued tasks that hadn't begun running yet;
// tasks already in progress get interrupt()ed, not force-killedNenhuma das duas chamadas bloqueia até o pool realmente terminar — shutdown() e
shutdownNow() retornam imediatamente. awaitTermination(long, TimeUnit) é a
chamada separada que bloqueia até o pool ter terminado de encerrar ou o timeout
expirar (ela retorna true/false de acordo). Combinar as duas em
"encerre graciosamente, mas desista e force depois de um prazo" é um idioma
padrão:
javapool.shutdown(); // 1. stop accepting new work, let existing work finish
try {
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
pool.shutdownNow(); // 2. timeout elapsed — force it
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
System.err.println("Pool did not terminate");
}
}
} catch (InterruptedException e) {
pool.shutdownNow(); // this thread was interrupted while waiting — force it too
Thread.currentThread().interrupt();
}ExecutorService estende AutoCloseable desde o Java 19, através de um método close()
default que é um atalho para aproximadamente esse idioma: ele chama shutdown(), depois
espera (por padrão indefinidamente, em pequenas esperas repetidas) pelo término, chamando
shutdownNow() se a thread chamadora for interrompida enquanto espera. Isso torna um
executor usável diretamente em try-with-resources para o caso comum:
javatry (ExecutorService pool = Executors.newFixedThreadPool(4)) {
pool.execute(task1);
pool.execute(task2);
} // close() runs automatically: shutdown() + wait for terminationclose() é uma conveniência para o caso comum, não um substituto para o
idioma explícito acima — recorra a shutdown() + awaitTermination() (com um
fallback de shutdownNow()) sempre que precisar de um timeout específico, precisar inspecionar
a lista de tasks que nunca começaram, ou precisar reagir de forma diferente a um timeout
do que "continuar esperando."
Poison pills: um sinal cooperativo de encerramento através da fila de trabalho
O shutdown() de um thread pool funciona porque o executor é dono da fila e das
worker threads. Um design produtor-consumidor feito à mão sobre uma BlockingQueue
simples não tem um método de ciclo de vida embutido assim — a thread consumidora simplesmente
faz loop em queue.take() para sempre. Uma poison pill resolve isso sem um canal de
cancelamento separado: é um valor sentinela designado que significa "pare" quando o
consumidor o retira da fila, enviado através da mesma fila usada para itens de trabalho reais.
javaprivate static final Task POISON_PILL = new Task(); // recognizable sentinel
// consumer
while (true) {
Task task = queue.take();
if (task == POISON_PILL) {
break; // no more work is coming — exit the loop
}
process(task);
}
// producer, when done submitting real work
queue.put(POISON_PILL);Como a fila é FIFO, quaisquer itens de trabalho reais enfileirados antes da pílula têm garantia de serem retirados (e processados) antes da pílula chegar — o produtor só precisa parar de submeter trabalho real assim que submeter a pílula. Isso só funciona de forma limpa quando o número de produtores e consumidores é conhecido de antemão: com múltiplos consumidores, uma pílula só diz a um único consumidor para parar, então o produtor precisa enfileirar uma pílula por consumidor; com múltiplos produtores, cada um precisaria concordar sobre quando submeter sua própria pílula para que a fila não acabe com trabalho ativo misturado depois que a primeira chegar. Também depende de a fila ser efetivamente ilimitada do ponto de vista do produtor — um produtor bloqueado em uma fila limitada cheia não consegue colocar sua pílula.
Handlers de exceção não capturada: percebendo uma thread que morreu silenciosamente
Quando o run() de uma thread lança uma exceção que ninguém dentro dela capturou, a thread
simplesmente termina. Por padrão, nada nisso é ruidoso: o tratamento embutido da JVM
imprime um stack trace em System.err e a thread se vai — nenhuma
exceção é lançada para nenhuma outra thread, nenhuma flag é setada em lugar algum que o
resto do programa possa ver. Para uma thread de vida curta isso costuma ser tranquilo; para
um worker de longa duração ou um poller em background, significa que a thread pode
desaparecer e ninguém percebe até que o trabalho que ela deveria estar fazendo pare de acontecer.
javaThread worker = new Thread(() -> {
throw new RuntimeException("boom");
});
worker.start();
// stack trace goes to stderr; the rest of the application keeps running,
// unaware the thread is gone, unless something below is registeredThread.setUncaughtExceptionHandler registra um handler em uma thread;
Thread.setDefaultUncaughtExceptionHandler registra um fallback usado por qualquer
thread que não tenha o seu próprio. Registrar um transforma a morte silenciosa em
um evento logado:
javaThread worker = new Thread(() -> {
throw new RuntimeException("boom");
});
worker.setUncaughtExceptionHandler((t, e) ->
logger.log(Level.SEVERE, "Thread " + t.getName() + " died", e));
worker.start();Para threads de pool, defina o handler através de uma ThreadFactory customizada passada ao
construtor do ThreadPoolExecutor, já que você não tem uma referência direta a cada
worker thread que o pool cria. Note a assimetria entre os estilos de submissão:
uma task submetida com execute() chega ao handler de exceção não capturada se
lançar uma exceção; uma task submetida com submit() não chega — sua exceção é capturada
no Future retornado e só surge quando algo chama get(), então
um Future não verificado cuja task lançou uma exceção falha tão silenciosamente quanto uma
thread não tratada, mas por um mecanismo diferente.
Shutdown hooks da JVM e daemon threads
O encerramento ordenado da JVM — disparado quando a última thread não-daemon termina,
alguém chama System.exit(), ou um sinal externo como Ctrl-C chega — primeiro
executa todo shutdown hook registrado. Um shutdown hook é uma Thread comum,
registrada mas não iniciada, passada para Runtime.getRuntime().addShutdownHook();
a JVM a inicia (concorrentemente com quaisquer outros hooks, em ordem não especificada) como
parte do encerramento:
javaRuntime.getRuntime().addShutdownHook(new Thread(() -> {
logger.info("Flushing and closing resources before exit");
resource.close();
}));Hooks são úteis para limpeza de última chance — fazer flush de logs, liberar recursos
nativos que o SO não vai recuperar sozinho — mas cada hook que a JVM precisa executar
atrasa a saída do processo inteiro, então eles devem fazer seu trabalho rapidamente e não
depender de outros serviços que já podem estar sendo encerrados concorrentemente. Um
encerramento abrupto (Runtime.halt(), ou o SO matando o processo) pula os
hooks completamente.
Separadamente dos hooks, toda thread é ou uma thread daemon ou uma thread comum
("user"), controlada por Thread.setDaemon(true) antes da thread iniciar.
A distinção só importa para uma coisa: se a JVM espera por aquela thread
antes de sair. Uma user thread mantém a JVM viva — o processo não sai enquanto
mesmo uma thread não-daemon ainda estiver rodando. Uma daemon thread não faz isso: assim que
todas as threads restantes forem daemon, a JVM inicia seu encerramento ordenado
independentemente do que aquelas daemon threads ainda estejam fazendo, e quando a JVM efetivamente
para, quaisquer daemon threads ainda rodando são simplesmente abandonadas — nenhum bloco finally
roda, nenhuma stack é desenrolada, a thread simplesmente deixa de existir.
javaThread daemon = new Thread(() -> {
while (true) {
cleanupExpiredCacheEntries();
sleepQuietly(60_000);
}
});
daemon.setDaemon(true); // must be called before start()
daemon.start();
// the JVM can exit while this loop is still running mid-iteration —
// no cleanup code here is guaranteed to run before that happens
Thread user = new Thread(() -> writeImportantFile());
user.start();
// the JVM will NOT exit until this thread finishes on its ownIsso torna daemon threads apropriadas para tarefas de manutenção em background cuja perda no encerramento é inofensiva (um limpador de cache em memória, um logger periódico de estatísticas), e inapropriadas para qualquer coisa que precise rodar até o fim ou limpar de forma confiável — esse trabalho pertence a uma user thread, ou atrás de um método de shutdown explícito que a aplicação chama e espera.
Trade-offs
shutdown()é mais seguro mas em aberto;shutdownNow()é limitado no tempo mas mais arriscado — o encerramento gracioso nunca corrompe uma task interrompendo-a no meio do trabalho, mas não dá nenhum limite de quanto tempo esvaziar a fila leva; o encerramento agressivo retorna rapidamente mas qualquer task que não trate a interrupção de forma limpa pode deixar trabalho pela metade.- Esquecer
awaitTermination()transforma o encerramento em um no-op do ponto de vista de quem chamou —shutdown()retorna imediatamente, então código que encerra e logo em seguida prossegue (fecha um recurso que as tasks do pool ainda precisam, sai demain) entra em race com as tasks ainda em execução.javapool.shutdown(); resource.close(); // may run while pool tasks are still using resource — // shutdown() alone gave no guarantee they'd finished - Poison pills precisam de um número conhecido e fixo de produtores e consumidores — o esquema degrada rapidamente com um conjunto dinâmico de qualquer um dos dois: um número variável de produtores precisa coordenar quem envia a pílula final, e múltiplos consumidores precisam cada um da sua própria pílula, ou um pode sair enquanto outros ainda estão esperando por trabalho que nunca vai chegar.
- Uma exceção não tratada em uma task submetida com
submit()falha tão silenciosamente quanto uma exceção não tratada em uma thread comum — registrar um handler de exceção não capturada não faz nada nesse caso, porquesubmit()nunca deixa a exceção chegar ao handler; só chamarFuture.get()(e capturarExecutionException) a traz à tona.javaFuture<?> f = pool.submit(() -> { throw new RuntimeException("boom"); }); // no handler fires, nothing is logged automatically — // the exception is sitting inside f until something calls f.get() - O código de limpeza de uma daemon thread não tem garantia de rodar — código que
depende de um bloco
finallyou de uma flag de shutdown dentro de uma daemon thread para liberar um recurso pode simplesmente nunca ter a chance, se a JVM decidir sair enquanto aquela thread está no meio de um loop.
Documentation Links
- ExecutorService — java.util.concurrent
- ExecutorService.shutdown() — java.util.concurrent
- ExecutorService.shutdownNow() — java.util.concurrent
- ExecutorService.awaitTermination(long, TimeUnit) — java.util.concurrent
- ExecutorService.close() — java.util.concurrent
- BlockingQueue — java.util.concurrent
- Thread.setUncaughtExceptionHandler — java.lang.Thread API docs
- Thread.setDefaultUncaughtExceptionHandler — java.lang.Thread API docs
- Thread.UncaughtExceptionHandler — java.lang.Thread API docs
- Runtime.addShutdownHook(Thread) — java.lang.Runtime API docs
- Thread.setDaemon(boolean) — java.lang.Thread API docs
- Thread.isDaemon() — java.lang.Thread API docs