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 Lock — lock, tryLock, lockInterruptibly, unlock. O contrato para o par rw é:
- Muitas threads podem manter
rao mesmo tempo. Nenhuma delas bloqueia as outras. - Apenas uma thread pode manter
wpor vez. - Uma thread tentando adquirir
waguarda até que todos os leitores atuais sejam liberados. - Threads tentando adquirir
raguardam sewestiver 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
ReentrantLocksimples ou um snapshot imutável viaAtomicReference. - 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,
StampedLockou 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.
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,ReentrantReadWriteLockna verdade perde para umReentrantLocksimples (e parasynchronized). 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 estiloAtomicIntegercausam 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 umReadWriteLock. - O custo de
ReadWriteLocké mais estado a manter (um contador de leitores, uma flag de espera por escritor, a política de justiça) — cadar.lock()é mais caro do que umReentrantLock.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 umAtomicReferencegeralmente 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 (
w→r→ liberarw) é 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; libererprimeiro, adquiraw, 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ê.