Java Deadlock
O que são deadlocks em Java, como ocorrem e os padrões que os previnem.
Um deadlock é o modo de falha do bloqueio. Duas ou mais threads cada uma segura um lock que a outra precisa; nenhuma pode prosseguir; nenhuma exceção é lançada; nada no log diz "estamos travados." De fora, o programa parece não estar fazendo nada — exatamente o mesmo sintoma externo de um loop ocupado ou uma longa chamada de rede.
Deadlocks acontecem em qualquer programa que adquire mais de um lock ao mesmo tempo. São assustadoramente fáceis de escrever e assustadoramente difíceis de reproduzir — o escalonamento que dispara um pode aparecer uma vez por semana em produção e nunca nos testes. A estratégia correta não é "depurar quando acontecer", mas "estruturar o código para que não possam acontecer."
As quatro condições (condições de Coffman)
Um deadlock requer que todas as quatro sejam verdadeiras ao mesmo tempo:
- Exclusão mútua. Algum recurso (um lock) só pode ser mantido por uma thread por vez.
- Hold and wait. Uma thread mantém pelo menos um recurso enquanto espera para adquirir outro.
- Sem preempção. Recursos não podem ser tirados da thread que os mantém; a thread deve liberá-los voluntariamente.
- Espera circular. Há um ciclo no grafo de espera — A espera pelo lock de B, B espera pelo lock de C, ..., Z espera pelo lock de A.
Quebre qualquer uma delas e deadlocks se tornam impossíveis. As técnicas padrão de prevenção cada uma quebra uma das quatro:
- Ordenação de locks (mais comum): quebra a espera circular sempre adquirindo locks em uma ordem globalmente acordada.
tryLockcom timeout: quebra o hold-and-wait desistindo se não conseguir o segundo lock rápido o suficiente.- Lock grande único: quebra completamente a estrutura de múltiplos locks. Rudimentar, mas funciona para baixa contenção.
- Dados lock-free / imutáveis: quebra a exclusão mútua removendo o recurso. Os atômicos e coleções concorrentes mais adiante nesta parte do livro usam essa abordagem.
O exemplo das duas contas
A demonstração canônica:
void transfer(Account from, Account to, int amount) {
synchronized (from) {
synchronized (to) {
from.debit(amount);
to.credit(amount);
}
}
}
// Thread A: transfer(accountX, accountY, 100)
// Thread B: transfer(accountY, accountX, 100)Escalonamento:
- Thread A adquire o monitor de
accountX. - Thread B adquire o monitor de
accountY. - Thread A tenta adquirir
accountY— bloqueada, mantida por B. - Thread B tenta adquirir
accountX— bloqueada, mantida por A.
Nenhuma thread jamais será liberada. Ambas ficam BLOCKED para sempre. A correção:
void transfer(Account from, Account to, int amount) {
Account first = from.id() < to.id() ? from : to;
Account second = from.id() < to.id() ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(amount);
to.credit(amount);
}
}
}Ambas as threads agora adquirem accountX e então accountY independentemente da direção da transferência. A espera circular não pode se formar.
A chave de ordenação não precisa ser um id — System.identityHashCode(obj) funciona como um desempatador estável para qualquer objeto, mas colisões são possíveis, portanto código de produção tipicamente usa uma chave real (o ID do banco de dados, o ID do usuário, etc.) e recorre a um lock desempatador quando as chaves coincidem.
Ordenação de locks em todo o programa
A ordenação de locks só funciona se todo caminho de código que toma dois locks do mesmo tipo os toma na mesma ordem. Um único método renegado que faz synchronized (b) { synchronized (a) { ... } } é suficiente para trazer de volta o deadlock.
A forma de impor isso de forma consistente em uma base de código maior:
- Documente a ordem. "Sempre adquira
parentantes dechild." Comente na classe. - Canalize por um único helper. Todas as chamadas de "transferência" passam por um método que faz a ordenação — para que um ponto de chamada individual não possa errar.
-XX:+PrintConcurrentLocksem um thread dump é uma forma de inspecionar grafos reais de aquisição de locks em produção.
A disciplina importa tanto quanto a regra.
tryLock com timeout
Quando você não pode garantir a ordenação — bibliotecas diferentes, equipes diferentes, grafos de objetos complexos — ReentrantLock.tryLock(timeout, unit) oferece uma saída:
boolean done = false;
while (!done) {
if (firstLock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (secondLock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
doWork();
done = true;
} finally { secondLock.unlock(); }
}
} finally { firstLock.unlock(); }
}
// back off briefly, retry — eventually we'll get both
}Se o segundo lock não puder ser obtido em 100 ms, a thread libera o primeiro lock e tenta novamente mais tarde. A condição de hold-and-wait é quebrada — nenhuma thread fica bloqueada para sempre, mesmo que ambas tentem os mesmos locks em ordens opostas.
O custo são novas tentativas ocupadas e o código de recuo ao redor. Use ordenação de locks quando puder; recorra ao tryLock quando não puder.
Como detectar um deadlock em tempo de execução
Duas ferramentas principais.
Thread dump. jstack <pid> ou kill -3 <pid> imprime o estado e a pilha de cada thread. Um deadlock aparece claramente: duas threads com estado BLOCKED, cada uma - waiting to lock <0x...> em um objeto que a outra mostra - locked <0x...>. A JVM do Java é gentil o suficiente para sinalizar ciclos óbvios no final do dump:
Found one Java-level deadlock:
=============================
"thread-2":
waiting to lock monitor 0x00007fcd0e..., which is held by "thread-1"
"thread-1":
waiting to lock monitor 0x00007fcd0e..., which is held by "thread-2"ThreadMXBean.findDeadlockedThreads(). Uma versão programática — útil para incorporar em um endpoint de verificação de saúde:
ThreadMXBean mx = ManagementFactory.getThreadMXBean();
long[] deadlocked = mx.findDeadlockedThreads();
if (deadlocked != null) log.error("deadlock detected: {} threads", deadlocked.length);Isso encontra apenas deadlocks em monitores intrínsecos e ReentrantLock. Não encontra livelocks ou casos gerais de "thread está apenas lenta".
Livelock e starvation — primos do deadlock
Dois modos de falha que parecem deadlocks mas não são:
- Livelock. Threads continuam mudando de estado mas não fazem progresso. O caso clássico: dois chamadores
tryLockcada um reintenta para sempre porque nenhum cede primeiro. A CPU está ocupada; o trabalho não está sendo feito. - Starvation. Uma thread está tecnicamente
RUNNABLEou acordável, mas o escalonador / política de lock nunca a deixa realmente executar. Locks não equitativos sob alta contenção podem privar um escritor enquanto os leitores fluem.
Ambos têm o mesmo sintoma superficial que o deadlock ("nada parece estar progredindo") mas o diagnóstico é diferente — o thread dump não mostra BLOCKED em um ciclo mútuo; mostra threads girando ou apenas uma esperando perpetuamente.
Um exemplo elaborado: deadlock criado e depois prevenido
O programa abaixo executa o padrão de transferência nos dois sentidos — primeiro com a versão de lock aninhado quebrado (que causará deadlock sob contenção), e depois com a correção de ordenação de locks que o previne. A versão quebrada é envolta em um timeout de watchdog para que a demonstração não trave para sempre.
O que tirar da execução:
- A variante
BROKENnão completou todas as 100 transferências. Sob contenção,t1acabou segurandoae esperando porbenquantot2seguravabe esperava pora. O watchdog atingiu seu prazo de 3 segundos;findDeadlockedThreads()confirmou o ciclo. Isso é deadlock — sem exceção, sem log, nada de errado com qualquer linha individual de código. - A variante
FIXEDterminou de forma limpa. A regra de ordenação (first = id-min, second = id-max) significa que ambas as threads adquiremaprimeiro ebsegundo, independentemente da direção da transferência. O ciclo não pode se formar porque ambas as threads percorrem o grafo de locks na mesma direção. Thread.sleep(1)dentro do primeirosynchronizedda versão quebrada torna o deadlock altamente reproduzível. No código real, você quase nunca vê esse tipo de sleep explícito — mas I/O, GC ou uma troca de contexto podem produzir a mesma janela. É por isso que deadlocks se reproduzem de forma intermitente em produção e nunca nos testes.ThreadMXBean.findDeadlockedThreads()retornou um array não nulo para a variante quebrada e confirmou o número de threads em ciclo. Essa chamada é sua rede de segurança para detecção in-process — conecte-a a um endpoint de saúde e você será notificado sobre o deadlock antes do usuário.- Depois que o watchdog declarou a variante quebrada travada, o programa interrompeu ambas as threads.
interrupt()não acorda uma thread bloqueada em um monitorsynchronized— só acorda threads emsleep,wait,joinouLockSupport.park. É por isso que interromper um deadlock não o desbloqueia; você teria que matar a JVM (ou usarReentrantLock.lockInterruptibly).
O que vem a seguir
O próximo capítulo, Java volatile, trata da metade de visibilidade da história de segurança — a palavra-chave que corrige "uma thread escreve, outra thread lê o valor antigo para sempre" sem envolver locks.