CodeGym /Cursos /JAVA 25 SELF /StampedLock e contadores de baixa contenção

StampedLock e contadores de baixa contenção

JAVA 25 SELF
Nível 58, Lição 3
Disponível

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).

1
Tarefa
JAVA 25 SELF, nível 58, lição 3
Bloqueado
Centro de Controle de Voo: Sistema de Cache de Altitude 🛰️
Centro de Controle de Voo: Sistema de Cache de Altitude 🛰️
1
Tarefa
JAVA 25 SELF, nível 58, lição 3
Bloqueado
Navegador do Drone: Cálculo Otimista da Posição 🚁
Navegador do Drone: Cálculo Otimista da Posição 🚁
Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION