W3docs

Java ReadWriteLock

Permita leitores concorrentes e escritores exclusivos em Java com ReadWriteLock — e quando StampedLock é a melhor escolha.

Um ReentrantLock (ou um bloco synchronized) concede acesso exclusivo a uma thread — leitores e escritores compartilham o mesmo slot. Para cargas de trabalho com muitas leituras, isso é ineficiente: se cem threads querem ler um valor e uma quer escrevê-lo, não há conflito real entre os leitores, apenas entre leitores e o escritor. A interface ReadWriteLock e sua implementação padrão ReentrantReadWriteLock dividem o lock em duas partes — um lock de leitura que múltiplas threads podem manter simultaneamente, e um lock de escrita que é exclusivo contra tudo. Usado corretamente, reduz drasticamente a contenção. Usado incorretamente, é mais lento do que um lock simples.

A interface

public interface ReadWriteLock {
  Lock readLock();
  Lock writeLock();
}

Dois Locks, conectados entre si pelo pai: o lock de leitura e o lock de escrita obedecem à regra de que ou o lock de escrita é mantido por exatamente uma thread ou o lock de leitura é mantido por zero ou mais threads. Nunca ambos, nunca um de cada.

A implementação padrão:

ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
Lock r = rw.readLock();
Lock w = rw.writeLock();

Ambos os locks têm a API padrão Locklock, tryLock, lockInterruptibly, unlock. O contrato para o par rw é:

  • Muitas threads podem manter r ao mesmo tempo. Nenhuma delas bloqueia as outras.
  • Apenas uma thread pode manter w por vez.
  • Uma thread tentando adquirir w aguarda até que todos os leitores atuais sejam liberados.
  • Threads tentando adquirir r aguardam se w estiver mantido ou (dependendo da política) se um escritor já estiver na fila.

O último ponto é a política de prevenção de starvation de escritores — abordada abaixo.

O caso de uso de cache

O exemplo clássico. Um cache que é lido constantemente e atualizado ocasionalmente:

class ConfigCache {
  private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
  private final Lock r = rw.readLock();
  private final Lock w = rw.writeLock();
  private Map<String, String> data = new HashMap<>();

  public String get(String k) {
    r.lock();
    try {
      return data.get(k);                               // many readers, no contention
    } finally { r.unlock(); }
  }

  public void reload(Map<String, String> fresh) {
    w.lock();
    try {
      data = new HashMap<>(fresh);                       // exclusive: blocks readers and other writers
    } finally { w.unlock(); }
  }
}

Sob uma carga de trabalho típica de 99% de leituras, isso escala muito melhor do que um único ReentrantLock. Os leitores não se bloqueiam entre si; o escritor raro para brevemente o mundo, e todos continuam.

A mesma disciplina try/finally que Lock aplica — cada lock() deve ser pareado com unlock() em um finally. O lock de leitura não é mais tolerante do que o lock de escrita em relação a vazamentos.

Justiça e starvation de escritores

ReentrantReadWriteLock tem duas políticas:

new ReentrantReadWriteLock();          // non-fair (default)
new ReentrantReadWriteLock(true);      // fair (FIFO)

A política não-justa padrão permite que novos leitores entrantes adquiram o lock mesmo que um escritor já esteja aguardando — alta taxa de transferência, mas escritores podem sofrer starvation sob carga contínua de leitura. A política justa enfileira cada solicitante em ordem FIFO: um escritor em espera bloqueia leitores subsequentes, e os leitores aguardam sua vez.

O padrão correto ainda é não-justo. Se você observar escritores em produção ficando na fila para sempre (uma das coisas que getQueueLength expõe), mude para justo.

Há também uma proteção mais sutil. Mesmo no modo não-justo, se um escritor está "próximo na fila" (no início da fila), os leitores entrantes são bloqueados. Isso previne a pior forma de starvation; novos leitores ainda podem entrar se nenhum escritor estiver na fila.

Downgrade de lock: escrita → leitura

Um truque útil: você pode manter o lock de escrita, adquirir também o lock de leitura e depois liberar o lock de escrita — sem nunca deixar entrar outro escritor. Isso é chamado de downgrading:

w.lock();
try {
  data = recompute();                                   // exclusive write
  r.lock();                                              // before releasing w
} finally { w.unlock(); }
// now holding only r — readers can join, but no writer can sneak in until I release r
try {
  process(data);                                         // read-only work, multiple threads can do it
} finally { r.unlock(); }

O objetivo do downgrading: realizar a mutação real sob o lock de escrita, depois continuar lendo o resultado sem impedir o acesso de outros aos dados. O "adquirir leitura enquanto mantém escrita" funciona porque o lock permite isso — você está atualizando a reserva do lock de escrita de "exclusivo" para "compartilhado com você especificamente ainda permitido."

O inverso — upgrade de leitura → escrita — não funciona. Tentar adquirir w enquanto você mantém r causa deadlock: o lock de escrita aguarda todos os leitores serem liberados, e você é um deles. O lock vai bloqueá-lo para sempre esperando por si mesmo.

r.lock();
try {
  if (needsRefresh()) {
    w.lock();                                            // DEADLOCK on the same thread
    ...
  }
} finally { r.unlock(); }

Para ir de leitura → escrita, você deve liberar o lock de leitura primeiro, depois adquirir o lock de escrita e re-verificar a condição (alguém pode ter atualizado enquanto você estava desbloqueado).

Quando ReadWriteLock supera ReentrantLock

Uma regra prática. ReentrantReadWriteLock vence quando:

  • Leituras superam amplamente as escritas (digamos, 100:1 ou mais).
  • A seção protegida por leitura é não trivial — longa o suficiente para que deixar muitas threads executá-la concorrentemente seja uma vantagem real.
  • A escrita também é longa o suficiente para que bloquear brevemente os leitores seja aceitável.

Perde (ou empata) quando:

  • As leituras são extremamente curtas (uma busca em um mapa). O overhead de aquisição do lock é comparável ao trabalho; você ficaria melhor com um ReentrantLock simples ou um snapshot imutável via AtomicReference.
  • A proporção leitor/escritor não é extrema.
  • Você tem muitas threads. A contabilidade interna que o lock de leitura/escrita faz para contar os leitores fica mais cara conforme escala. Para estruturas de dados com muita leitura em muitos núcleos, StampedLock ou copy-on-write geralmente é uma escolha melhor.

StampedLock — a alternativa moderna

O Java 8 adicionou java.util.concurrent.locks.StampedLock com três modos — escrita, leitura e leitura otimista. O modo otimista permite que um leitor prossiga sem adquirir nenhum lock; após a leitura, ele verifica se o valor não mudou via um stamp. Se mudou, o leitor recorre a uma aquisição adequada de lock de leitura.

StampedLock sl = new StampedLock();
long stamp = sl.tryOptimisticRead();
String val = data.get(k);                                // read without locking
if (!sl.validate(stamp)) {                                // somebody wrote during our read
  stamp = sl.readLock();
  try {
    val = data.get(k);                                    // re-read under proper lock
  } finally { sl.unlockRead(stamp); }
}

Para cargas de trabalho dominadas por leitura, StampedLock geralmente é mais rápido do que ReentrantReadWriteLock. O custo: não é reentrante, não suporta Condition, e a API é muito mais fácil de usar incorretamente. Recorra a ele quando tiver um profiler apontando para um ReadWriteLock; use ReentrantReadWriteLock por padrão pela ergonomia.

Um exemplo prático: cache com muita leitura, três contendores

O programa abaixo contrasta três implementações do mesmo cache com muita leitura sob 16 leitores e 2 escritores: um mapa com synchronized, um mapa protegido por ReentrantLock e um mapa protegido por ReentrantReadWriteLock.

java— editable, runs on the server

O que tirar da execução — e o resultado provavelmente não é o que você imaginaria:

  • A seção crítica aqui é um único HashMap.get — alguns nanosegundos. Nesse tamanho, ReentrantReadWriteLock na verdade perde para um ReentrantLock simples (e para synchronized). Em uma execução típica, o rwlock faz menos leituras, não mais, porque o trabalho dentro do lock é ínfimo comparado ao custo de adquiri-lo. Essa é a coisa mais importante a internalizar: um lock de leitura-escrita não é uma atualização gratuita.
  • Por que perde aqui: r.lock() precisa incrementar atomicamente um contador compartilhado de leitores, e esse contador em contenção se torna o novo gargalo. Dezesseis núcleos todos martelando um campo estilo AtomicInteger causam cache bounce tão mal quanto fariam em um único mutex — às vezes pior, porque a contabilidade do rwlock é mais pesada do que a de um lock simples. O ganho teórico de "leitores não se bloqueiam entre si" nunca se materializa quando a leitura é curta demais para haver sobreposição real.
  • O rwlock só se destaca quando cada leitura mantém o lock por tempo suficiente para que a sobreposição real valha a pena — pense em uma busca, uma desserialização, ou qualquer coisa na faixa de microssegundos ou mais, não uma única busca em mapa. Substitua o corpo de get() por trabalho somente leitura genuinamente custoso e execute novamente: agora os leitores concorrentes vencem de forma decisiva. Perfile sua carga de trabalho real antes de recorrer a um ReadWriteLock.
  • O custo de ReadWriteLock é mais estado a manter (um contador de leitores, uma flag de espera por escritor, a política de justiça) — cada r.lock() é mais caro do que um ReentrantLock.lock(). Para baixa contenção ou seções críticas muito curtas, o lock mais simples é mais rápido. Para carga extremamente dominada por leitura em muitos núcleos, StampedLock (leituras otimistas não adquirem nada) ou um snapshot imutável por trás de um AtomicReference geralmente supera ambos.
  • Qualquer que seja o lock escolhido, os escritores ainda obtêm acesso exclusivo via o caminho de escrita e nunca corrompem o mapa — a correção é a mesma para os três. A diferença é puramente em throughput, e o throughput depende inteiramente do que está dentro do lock.
  • Downgrading (wr → liberar w) é o que o código de produção usa quando a reconstrução precisa acontecer sob um lock de escrita, mas o restante da requisição pode permanecer sob um lock de leitura. Fazer upgrade no sentido contrário causa deadlock; libere r primeiro, adquira w, re-verifique o estado, depois continue.

O que vem a seguir

O próximo capítulo, Java Thread Pools, inicia a história de alto nível do executor framework — a ideia de que você para de criar threads manualmente e em vez disso submete trabalho a um pool que gerencia as threads por você.

Prática

Prática
Você mantém o lock de leitura de um `ReentrantReadWriteLock` e decide fazer upgrade para o lock de escrita chamando `w.lock()` enquanto ainda mantém `r`. O que acontece?
Você mantém o lock de leitura de um `ReentrantReadWriteLock` e decide fazer upgrade para o lock de escrita chamando `w.lock()` enquanto ainda mantém `r`. O que acontece?
Was this page helpful?