Java ReentrantLock
Use ReentrantLock para bloqueio explícito e flexível em Java — tryLock, lockInterruptibly, política de equidade e a API de diagnóstico.
ReentrantLock é a implementação padrão da interface Lock. "Reentrant" significa que a mesma thread pode adquirir o lock múltiplas vezes sem se bloquear — a mesma propriedade que synchronized possui. Tudo o que o capítulo sobre Lock descreveu — tryLock, lockInterruptibly, Condition — é fornecido por esta única classe.
Este capítulo é o aprofundamento: os dois construtores, a opção de equidade, os métodos de diagnóstico, o emparelhamento com Condition para produtor/consumidor e os casos concretos em que ReentrantLock justifica o código extra de try/finally em relação ao synchronized simples.
O que esta página aborda
- Dois construtores — justo vs não-justo, e qual usar por padrão.
- Reentrância e
getHoldCount— como a re-entrada é contada e liberada. - Outros métodos de diagnóstico — a introspecção que
synchronizednão oferece. - Quando
ReentrantLocksuperasynchronized— os quatro motivos reais para trocar. - O par com
Condition, composição comtryLocke um exemplo funcional.
Dois construtores
Lock lock = new ReentrantLock(); // non-fair (default) — high throughput
Lock fair = new ReentrantLock(true); // fair — FIFO wait queueNão-justo (o padrão) significa que, quando o lock fica disponível, a thread esperando que o escalonador executar primeiro vence. Threads recém-chegadas também podem "passar na frente" — adquirir o lock sem esperar na fila, caso ele esteja livre no momento da chamada. Isso é rápido: sem manipulação de fila, sem dica ao escalonador. A desvantagem é a possibilidade de starvation — uma thread pode ficar na fila de espera por muito tempo enquanto outras continuam obtendo o lock.
Justo significa que o lock é concedido à thread que espera há mais tempo. A fila de espera é um FIFO verdadeiro. Isso elimina o starvation. O custo: throughput notavelmente menor, porque cada aquisição envolve uma decisão do escalonador e a JVM não pode tomar atalhos pelo caminho rápido.
O padrão correto é o não-justo. Use o justo somente quando identificar um problema real de starvation (tipicamente detectado por uma consulta a getWaitQueueLength que continua crescendo) ou quando a correção da aplicação depende da ordem de processamento.
Reentrância e getHoldCount
Assim como synchronized, um ReentrantLock pode ser readquirido pela thread que já o detém:
ReentrantLock lock = new ReentrantLock();
lock.lock();
lock.lock(); // same thread re-enters — fine
try {
doStuff();
} finally {
lock.unlock();
lock.unlock(); // must unlock as many times as locked
}Cada lock() incrementa um contador interno de posse; cada unlock() o decrementa. O lock é de fato liberado — visível para outras threads — apenas quando o contador chega a zero. A chamada de diagnóstico:
int n = lock.getHoldCount(); // how many times THIS thread has acquired without unlockinggetHoldCount é útil para asserções ("este método deve ser chamado com o lock mantido") e para verificar invariantes em testes.
A regra de unlock correspondente é estrita. Se você chamar lock duas vezes e unlock uma vez, o lock permanece mantido — vazamento silencioso. Se você chamar unlock mais vezes do que chamou lock, IllegalMonitorStateException é lançada imediatamente. Sempre que possível, emparelhe aquisição e liberação dentro do mesmo método; distribuir entre métodos torna o controle frágil rapidamente.
Outros métodos de diagnóstico
ReentrantLock expõe uma boa quantidade de introspecção que synchronized não oferece:
lock.isLocked(); // is anybody holding it?
lock.isHeldByCurrentThread(); // do I hold it?
lock.getQueueLength(); // how many threads are waiting?
lock.hasQueuedThreads(); // are any waiting?
lock.hasQueuedThread(t); // is thread t waiting?
lock.getHoldCount(); // how many times have I re-entered?Esses métodos são principalmente para monitoramento e testes — a lógica de produção não deve depender de "alguém está esperando?" porque a resposta já pode estar desatualizada no momento em que você a verifica. Mas para métricas, a "proporção de aquisições com contenção" é um sinal útil de que a granularidade do lock está errada.
Quando ReentrantLock supera synchronized
Os quatro motivos para escolher ReentrantLock:
- Aquisição com prazo limite. Você precisa falhar rapidamente ou recuar se o lock não estiver disponível dentro de um tempo limitado.
tryLock(timeout)faz isso;synchronizednão. - Cancelamento. Você precisa interromper uma thread que está esperando pelo lock.
lockInterruptiblyfaz isso;synchronizedignora interrupções durante a entrada no monitor. - Múltiplas variáveis de condição. Você precisa sinalizar separadamente diferentes categorias de waiters.
Lock.newCondition()faz isso; o monitor intrínseco tem exatamente um conjunto de espera. - Equidade. Você precisa de ordenação FIFO dos waiters.
new ReentrantLock(true)é a única forma integrada.
Qualquer coisa fora dessas quatro situações — código bem escrito sem necessidades especiais — deve permanecer com synchronized. As otimizações da JVM para monitores intrínsecos são reais, e a disciplina try/finally que Lock exige é algo que pode ser esquecido. Não use Lock "porque é mais moderno."
O par com Condition, em detalhes
O padrão produtor/consumidor com um ReentrantLock e duas condições é o exemplo clássico. Reproduzido aqui de forma independente porque também é a estrutura que ArrayBlockingQueue usa internamente:
class BoundedBuffer<T> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final Object[] items;
private int count, head, tail;
BoundedBuffer(int cap) { items = new Object[cap]; }
public void put(T x) throws InterruptedException {
lock.lock();
try {
while (count == items.length) notFull.await(); // release lock, park, re-acquire on wake
items[tail] = x;
tail = (tail + 1) % items.length;
count++;
notEmpty.signal(); // wake exactly one consumer
} finally { lock.unlock(); }
}
@SuppressWarnings("unchecked")
public T take() throws InterruptedException {
lock.lock();
try {
while (count == 0) notEmpty.await();
T x = (T) items[head];
items[head] = null;
head = (head + 1) % items.length;
count--;
notFull.signal(); // wake exactly one producer
return x;
} finally { lock.unlock(); }
}
}A vantagem sobre wait/notifyAll: um produtor acorda um consumidor, não todas as threads esperando. Sob alta contenção, essa é a diferença entre tempestades de notifyAll (todo waiter acorda, disputa o lock, todos exceto um voltam a dormir) e uma transferência limpa.
signal vs signalAll segue a mesma regra que notify vs notifyAll: prefira signalAll a menos que possa provar que todos os waiters nesta condição são intercambiáveis. Neste buffer, todo waiter em notEmpty é um consumidor querendo um slot — são intercambiáveis; signal é seguro.
A troca entre loop CAS e monitor
Uma questão comum: quando uma variável atômica como AtomicInteger vence sobre um ReentrantLock? Resumidamente:
- Para um único campo com uma atualização simples, atômicos vencem — são instruções CAS, sem chamada ao kernel, sem parking.
AtomicInteger.incrementAndGeté mais rápido do queReentrantLock.lock+int+++unlock. - Para múltiplos campos que devem ser atualizados juntos ou para semântica de bloqueio (aguardar uma fila não estar vazia), o lock vence — você pode agrupar o trabalho e sinalizar através dele.
Uma verificação somente leitura como "o cache é válido?" é volatile; um incremento é atômico; "trocar um item por outro em uma fila" é um lock. Use a ferramenta mais leve que o trabalho exige.
tryLock para composição sem deadlock
O padrão mais simples para combinar dois locks sem ordenação:
boolean done = false;
while (!done) {
lockA.lock();
try {
if (lockB.tryLock(50, TimeUnit.MILLISECONDS)) { // bounded wait for the second lock
try {
doCriticalWork();
done = true;
} finally { lockB.unlock(); }
}
// else: couldn't get lockB in time — fall through, release lockA, retry
} finally {
lockA.unlock(); // always release lockA before looping
}
}Se tryLock em lockB expirar, o finally libera lockA e o loop tenta novamente do zero. Como nenhuma thread mantém um lock enquanto bloqueia para sempre em outro, a condição clássica de deadlock por hold-and-wait é eliminada.
Isso evita a armadilha de deadlock por ordenação sem exigir uma regra global de ordem de aquisição de locks. O trade-off é mais código, potencial de livelock sob alta contenção e pior comportamento de cache. Use para locks entre subsistemas (locks em objetos que você não escreveu); prefira uma ordem fixa de aquisição de locks quando você controla ambos os lados.
Um exemplo funcional: contenção, equidade e reentrância
O programa abaixo contrasta um ReentrantLock justo vs não-justo sob alta contenção, e depois demonstra reentrância e contabilidade de hold count.
O que observar na execução:
- O lock não-justo distribuiu o pool compartilhado de aquisições de forma desigual — o
spread = max-minrelatado foi grande (comumente vários milhares em 50.000 por thread). É o caminho rápido em ação: a JVM não impõe ordenação, então uma thread que acabou de liberar o lock pode imediatamente voltar e vencer antes que uma thread na fila seja escalonada. - O lock justo distribuiu as aquisições de forma quase uniforme — seu spread foi uma pequena fração do spread não-justo, porque a fila de espera FIFO dá a cada thread a sua vez. O tempo total foi notavelmente maior. A ordenação justa troca throughput por progresso previsível. Não pague esse custo a menos que tenha medido um problema de starvation. (Os números exatos variam por execução e por máquina; o que é estável é que o spread não-justo é muito maior do que o spread justo.)
- A seção de reentrância mostrou o hold count subindo e descendo a cada
lock/unlock. O lock é de fato liberado apenas quando o contador cai a zero; até lá, outras threads esperando por ele permanecemBLOCKED. Esta é uma semântica idêntica à desynchronized; a diferença é queReentrantLockexpõe o contador para inspeção. - O
unlock()extra após o hold count chegar a zero lançouIllegalMonitorStateExceptionimediatamente — não há "double unlock" silencioso que tenha sucesso. É a JVM impondo o invariante do lock: apenas o detentor pode liberar, e apenas tantas vezes quantas adquiriu. - A leitura de
getQueueLengthde 3 confirmou que as três threads esperando estavam genuinamente na fila atrás de nós. Em produção, este método é útil para alertas do tipo "a contenção está aumentando?" — um comprimento de fila que cresce ao longo do tempo é um sinal de que o trabalho dentro do lock está muito lento.
O que vem a seguir
O próximo capítulo, Java ReadWriteLock, aborda ReentrantReadWriteLock — o lock que divide a aquisição em "muitos leitores OU um escritor", para as cargas de trabalho com muita leitura onde locks exclusivos são excessivos.