1. Por que nem sempre ReadWriteLock é suficiente
Problema de alta contenção na escrita
ReadWriteLock (na maioria das vezes — ReentrantReadWriteLock) funciona bem quando a maioria das threads apenas lê os dados e há poucos escritores. Nesse caso, várias threads podem ler simultaneamente, e a escrita bloqueia o acesso apenas por um curto momento.
O problema surge quando o número de threads que escrevem fica maior do que o esperado, ou elas alternam frequentemente entre leitura e escrita. Se as operações de escrita levam muito tempo, as threads começam a esperar mais pela liberação dos bloqueios. Como resultado, a contenção pelo acesso aos dados aumenta, leitura e escrita ficam mais lentas e a eficiência do ReadWriteLock cai.
Alternância cara de bloqueios
Quando uma thread passa do modo de leitura para o modo de escrita (ou vice-versa), acontece uma verificação complexa por baixo dos panos: é preciso garantir que ninguém mais esteja escrevendo, que todos os leitores saíram e só então permitir a escrita.
Se essas alternâncias ocorrem com frequência, isso cria atrasos. As threads começam a formar fila e a performance cai — especialmente quando há muitas operações de leitura e escrita ao mesmo tempo.
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
// Leitura
readLock.lock();
try {
// ler dados
} finally {
readLock.unlock();
}
// Escrita
writeLock.lock();
try {
// alterar dados
} finally {
writeLock.unlock();
}
Toda vez que uma thread alterna entre readLock e writeLock, o sistema executa todas as verificações para evitar conflitos. Se houver muitas alternâncias — isso é caro.
2. StampedLock: abordagem moderna de sincronização
StampedLock é um mecanismo moderno de sincronização, introduzido no Java 8. Ele combina ideias do ReadWriteLock, mas adiciona um novo modo — leitura otimista — e trabalha não com bloqueios, e sim com “selos” (stamps) — tokens especiais que precisam ser liberados explicitamente.
Principais características:
- Três modos: write lock (escrita exclusiva), read lock (leitura compartilhada), optimistic read (leitura otimista sem bloqueio).
- Sem reentrância: não é possível entrar novamente no bloqueio a partir da mesma thread.
- Alta performance quando há muitas leituras e poucas escritas.
- Exige gerenciamento explícito dos selos (stamp).
Leitura otimista: tryOptimisticRead + validate
Leitura otimista é o modo em que a thread lê os dados sem qualquer bloqueio, contando que ninguém esteja escrevendo naquele momento. Após a leitura, a thread deve verificar se não houve escrita durante a leitura com o método validate(stamp).
import java.util.concurrent.locks.StampedLock;
public class Point {
private double x, y;
private final StampedLock lock = new StampedLock();
public double distanceFromOrigin() {
long stamp = lock.tryOptimisticRead();
double currentX = x;
double currentY = y;
// Verifique se houve escrita durante a leitura
if (!lock.validate(stamp)) {
// Se houve escrita — obtenha um read lock normal
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
return Math.hypot(currentX, currentY);
}
}
Como funciona:
- tryOptimisticRead() retorna um selo (long) que é “válido” enquanto ninguém estiver escrevendo.
- Lemos os valores de x e y.
- validate(stamp) verifica se não houve escrita entre o início e o fim da leitura.
- Se estiver tudo ok — usamos os valores lidos; se houve escrita — pegamos um readLock normal e lemos novamente.
Isso é vantajoso quando há pouquíssimas escritas e muitas leituras. Na maioria dos casos, validate retornará true, e a leitura será quase de graça.
Cenários “muita leitura — pouca escrita”
- Os dados raramente mudam, mas são lidos com frequência (por exemplo, cache, coordenadas, metadados).
- É importante minimizar a latência de leitura.
- É aceitável “reler” os dados caso tenha ocorrido uma escrita.
Fallback para read lock
Se a leitura otimista falhar (validate retornar false), a thread faz fallback para um readLock normal. Isso garante a correção dos dados, mas acontece raramente.
3. Armadilhas do StampedLock
Ausência de reentrância
Diferente do ReentrantReadWriteLock, o StampedLock não suporta entrada repetida. Se a thread já detém o bloqueio e tentar obtê-lo novamente — ocorrerá deadlock.
long stamp1 = lock.writeLock();
long stamp2 = lock.writeLock(); // DEADLOCK! A thread espera por si mesma
Atenção a interrupções
O StampedLock não reage a interrupções de threads da mesma forma que os bloqueios clássicos. Se uma thread for interrompida enquanto espera por um bloqueio, ela nem sempre “acorda” imediatamente. Para tarefas em que é importante reagir rapidamente a interrupções, use outros mecanismos.
Liberação correta dos selos
Cada chamada a readLock(), writeLock() ou tryOptimisticRead() retorna um selo exclusivo (long). É obrigatório passá-lo ao método de desbloqueio correspondente:
- unlockRead(stamp)
- unlockWrite(stamp)
Erro: Se você confundir os selos ou esquecer de chamar o unlock — terá vazamento de bloqueios e travamento do programa.
4. Comparação com ReentrantReadWriteLock
| Característica | ReentrantReadWriteLock | StampedLock |
|---|---|---|
| Reentrância (reentrada) | Sim | Não |
| Leitura otimista | Não | Sim |
| Desempenho sob alta contenção | Médio | Alto (com poucas escritas) |
| Controle explícito do bloqueio | Não (automático) | Sim (selos) |
| Reação a interrupções | Sim | Nem sempre |
| Justiça (fairness) | Sim (pode ser ativado) | Não |
Modos de justiça e impacto na inanição (starvation)
- No ReentrantReadWriteLock é possível ativar o modo “justo” (fair) para que as threads sejam atendidas na ordem da fila. Isso evita inanição (starvation).
- No StampedLock não há justiça: as threads podem esperar mais tempo se outras estiverem sempre “capturando” o bloqueio. É possível uma inanição rara.
5. Contadores: LongAdder/LongAccumulator vs AtomicLong
Problema com AtomicLong sob alta contenção
AtomicLong é uma variável atômica que garante incremento thread-safe. Mas com um grande número de threads chamando incrementAndGet() simultaneamente, todas “disputam” a mesma variável, o que leva à queda de desempenho.
LongAdder: contadores “striped” (por faixas)
O LongAdder resolve o problema de outra forma: ele divide o contador em várias “faixas” (stripes), cada uma atendendo a seu próprio grupo de threads. A thread incrementa uma das faixas, e o valor final é a soma de todas elas.
Vantagem:
- Com alta contenção, as threads quase não interferem umas nas outras.
- Desempenho várias vezes maior que o do AtomicLong.
import java.util.concurrent.atomic.LongAdder;
LongAdder adder = new LongAdder();
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
adder.increment();
}
};
Thread[] threads = new Thread[8];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(task);
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("Valor final: " + adder.sum());
LongAccumulator
LongAccumulator é uma versão generalizada do LongAdder, na qual é possível definir uma função de acumulação arbitrária (por exemplo, máximo, mínimo, etc.).
import java.util.concurrent.atomic.LongAccumulator;
LongAccumulator max = new LongAccumulator(Long::max, Long.MIN_VALUE);
max.accumulate(10);
max.accumulate(42);
max.accumulate(7);
System.out.println("Máximo: " + max.get()); // 42
Bloqueios “striped” (por faixas) e redução de contenção
A técnica de striped locks divide um recurso compartilhado em várias partes independentes (faixas), cada uma protegida por seu próprio bloqueio ou variável. As threads são distribuídas uniformemente pelas faixas, o que reduz a contenção e aumenta o desempenho. É exatamente essa abordagem que LongAdder e LongAccumulator usam.
6. Prática: cache com predominância de leitura
Tarefa: temos um mapa (Map) com dados e metadados (por exemplo, um contador de acessos). Leituras ocorrem com frequência, escritas — raramente.
Implementação com StampedLock:
import java.util.*;
import java.util.concurrent.locks.StampedLock;
import java.util.concurrent.atomic.LongAdder;
public class MetadataCache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final StampedLock lock = new StampedLock();
private final LongAdder hits = new LongAdder();
public V get(K key) {
long stamp = lock.tryOptimisticRead();
V value = map.get(key);
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
value = map.get(key);
} finally {
lock.unlockRead(stamp);
}
}
if (value != null) hits.increment();
return value;
}
public void put(K key, V value) {
long stamp = lock.writeLock();
try {
map.put(key, value);
} finally {
lock.unlockWrite(stamp);
}
}
public long getHits() {
return hits.sum();
}
}
- Para leitura, usa-se o modo otimista: se ninguém estiver escrevendo — a leitura é quase de graça.
- Para escrita — write lock.
- Para contar acessos — LongAdder: mesmo com alta contenção, os incrementos não se atrapalham.
7. Perfil de desempenho: LongAdder vs AtomicLong sob carga
Teste: 8 threads com 1 milhão de incrementos
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
public class CounterBenchmark {
public static void main(String[] args) throws InterruptedException {
int threads = 8;
int increments = 1_000_000;
// AtomicLong
AtomicLong atomic = new AtomicLong();
long start = System.nanoTime();
Thread[] t1 = new Thread[threads];
for (int i = 0; i < threads; i++) {
t1[i] = new Thread(() -> {
for (int j = 0; j < increments; j++) atomic.incrementAndGet();
});
t1[i].start();
}
for (Thread t : t1) t.join();
long timeAtomic = System.nanoTime() - start;
// LongAdder
LongAdder adder = new LongAdder();
start = System.nanoTime();
Thread[] t2 = new Thread[threads];
for (int i = 0; i < threads; i++) {
t2[i] = new Thread(() -> {
for (int j = 0; j < increments; j++) adder.increment();
});
t2[i].start();
}
for (Thread t : t2) t.join();
long timeAdder = System.nanoTime() - start;
System.out.printf("AtomicLong: %d ms, LongAdder: %d ms%n", timeAtomic / 1_000_000, timeAdder / 1_000_000);
}
}
Resultado típico:
AtomicLong: 2500 ms, LongAdder: 200 ms
Conclusão: Com alta contenção, o LongAdder é várias vezes mais rápido que o AtomicLong.
8. Erros típicos ao usar StampedLock e LongAdder
Erro nº 1: esqueceu de chamar unlockRead/unlockWrite. Se o selo não for liberado, outras threads vão esperar para sempre. Sempre use try/finally!
Erro nº 2: tentativa de reentrância. O StampedLock não suporta entrada repetida. Não adquira o bloqueio duas vezes a partir da mesma thread.
Erro nº 3: uso incorreto de validate. Se você não verificar validate após tryOptimisticRead, pode obter dados inconsistentes.
Erro nº 4: usar AtomicLong sob alta contenção. O AtomicLong é bom para 1–2 threads, mas com 8+ threads vira gargalo. Use LongAdder.
Erro nº 5: esquecer dos bloqueios por faixas. Se você implementar seu próprio striped lock, garanta que as threads sejam distribuídas uniformemente pelas faixas; caso contrário, algumas faixas ficarão sobrecarregadas e outras ociosas.
Erro nº 6: esperar justiça do StampedLock. O StampedLock não garante a ordem de atendimento das threads. Em casos raros, é possível inanição (starvation).
GO TO FULL VERSION