W3docs

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

Lock lock = new ReentrantLock();        // non-fair (default) — high throughput
Lock fair = new ReentrantLock(true);    // fair — FIFO wait queue

Nã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 unlocking

getHoldCount é ú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:

  1. 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; synchronized não.
  2. Cancelamento. Você precisa interromper uma thread que está esperando pelo lock. lockInterruptibly faz isso; synchronized ignora interrupções durante a entrada no monitor.
  3. 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.
  4. 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 que ReentrantLock.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.

java— editable, runs on the server

O que observar na execução:

  • O lock não-justo distribuiu o pool compartilhado de aquisições de forma desigual — o spread = max-min relatado 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 permanecem BLOCKED. Esta é uma semântica idêntica à de synchronized; a diferença é que ReentrantLock expõe o contador para inspeção.
  • O unlock() extra após o hold count chegar a zero lançou IllegalMonitorStateException imediatamente — 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 getQueueLength de 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.

Prática

Prática
Você chama `lock.lock()` duas vezes da mesma thread em um `ReentrantLock` e depois chama `lock.unlock()` uma vez. O lock é liberado para que outras threads possam adquiri-lo?
Você chama `lock.lock()` duas vezes da mesma thread em um `ReentrantLock` e depois chama `lock.unlock()` uma vez. O lock é liberado para que outras threads possam adquiri-lo?
Was this page helpful?