CodeGym /Cursos /JAVA 25 SELF /StampedLock y contadores de baja contención

StampedLock y contadores de baja contención

JAVA 25 SELF
Nivel 58 , Lección 3
Disponible

1. Por qué ReadWriteLock no siempre basta

El problema de la alta contención en escritura

ReadWriteLock (con mayor frecuencia — ReentrantReadWriteLock) funciona bien cuando la mayoría de los hilos solo leen los datos y hay pocos escritores. En ese caso, varios hilos pueden leer a la vez y la escritura bloquea el acceso solo por un breve momento.

El problema surge cuando hay más hilos escribiendo de lo esperado, o cambian a menudo entre lectura y escritura. Si las operaciones de escritura llevan mucho tiempo, los hilos empiezan a esperar más para liberar los bloqueos. Como resultado, la contención por el acceso a los datos crece, la lectura y la escritura se ralentizan y la eficacia de ReadWriteLock cae.

El costoso cambio de bloqueos

Cuando un hilo pasa del modo de lectura al de escritura (o viceversa), bajo el capó se realiza una comprobación compleja: hay que asegurarse de que nadie más esté escribiendo, que todos los lectores hayan salido y solo entonces permitir la escritura.

Si estos cambios ocurren a menudo, eso introduce latencia. Los hilos empiezan a hacer cola y el rendimiento cae — especialmente cuando hay muchas operaciones de lectura y escritura simultáneamente.

ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();

// Lectura
readLock.lock();
try {
    // leemos los datos
} finally {
    readLock.unlock();
}

// Escritura
writeLock.lock();
try {
    // modificamos los datos
} finally {
    writeLock.unlock();
}

Cada vez que un hilo cambia entre readLock y writeLock, el sistema realiza todas las comprobaciones para evitar conflictos. Si estos cambios son frecuentes — resulta costoso.

2. StampedLock: un enfoque moderno de sincronización

StampedLock es un mecanismo de sincronización moderno introducido en Java 8. Combina ideas de ReadWriteLock, pero añade un nuevo modo — lectura optimista — y trabaja no con bloqueos, sino con «stamps» (sellos): tokens especiales que deben liberarse explícitamente.

Características clave:

  • Tres modos: write lock (escritura exclusiva), read lock (lectura compartida), optimistic read (lectura optimista sin bloqueo).
  • Sin reentrancy: no se puede entrar de nuevo en el bloqueo desde el mismo hilo.
  • Alto rendimiento con muchas lecturas y pocas escrituras.
  • Requiere gestión explícita de los stamps (stamp).

Lectura optimista: tryOptimisticRead + validate

La lectura optimista es un modo en el que el hilo lee datos sin ningún bloqueo, esperando que nadie esté escribiendo en ese momento. Tras leer, el hilo debe comprobar si ha habido alguna escritura durante la lectura con el 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;
        // Comprobamos si hubo escritura durante la lectura
        if (!lock.validate(stamp)) {
            // Si hubo escritura — tomamos un read lock normal
            stamp = lock.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return Math.hypot(currentX, currentY);
    }
}

Cómo funciona:

  • tryOptimisticRead() devuelve un stamp (long) que es «válido» mientras nadie escribe.
  • Leemos los valores x y y.
  • validate(stamp) comprueba si hubo escritura entre el inicio y el final de la lectura.
  • Si todo va bien — usamos los valores leídos; si hubo escritura — tomamos un readLock normal y volvemos a leer.

Esto compensa cuando hay muy pocas escrituras y muchas lecturas. En la mayoría de los casos, validate devolverá true y la lectura será casi gratuita.

Escenarios de «muchas lecturas — pocas escrituras»

  • Los datos cambian rara vez pero se leen con frecuencia (p. ej., caché, coordenadas, metadatos).
  • Es importante minimizar la latencia de lectura.
  • Se puede «releer» los datos si resulta que hubo una escritura.

Retroceso a read lock

Si la lectura optimista falla (validate devuelve false), el hilo «retrocede» al readLock normal. Esto garantiza la corrección de los datos, pero ocurre rara vez.

3. Escollos de StampedLock

Ausencia de reentrancy

A diferencia de ReentrantReadWriteLock, StampedLock no soporta la reentrada. Si un hilo ya sostiene el bloqueo e intenta tomarlo de nuevo — habrá un deadlock.

long stamp1 = lock.writeLock();
long stamp2 = lock.writeLock(); // DEADLOCK! El hilo se espera a sí mismo

Sensibilidad a las interrupciones

StampedLock no reacciona a las interrupciones de los hilos igual que los bloqueos clásicos. Si un hilo fue interrumpido mientras esperaba el bloqueo, no siempre «despierta» de inmediato. Para tareas donde la reacción rápida a la interrupción es importante, usa otros mecanismos.

Liberación correcta de los stamps

Cada llamada a readLock(), writeLock() o tryOptimisticRead() devuelve un stamp único (long). Es imprescindible pasarlo al método unlock correspondiente:

  • unlockRead(stamp)
  • unlockWrite(stamp)

Error: Si confundes los stamps o te olvidas de llamar a unlock, tendrás fugas de bloqueos y la aplicación se quedará colgada.

4. Comparación con ReentrantReadWriteLock

Característica ReentrantReadWriteLock StampedLock
Reentrancy (reentrancia) No
Lectura optimista No
Rendimiento bajo alta contención Medio Alto (con pocas escrituras)
Gestión explícita del bloqueo No (automática) Sí (stamps)
Respuesta a interrupciones No siempre
Imparcialidad (fairness) Sí (se puede activar) No

Modos de imparcialidad y su efecto en la inanición (starvation)

  • En ReentrantReadWriteLock puedes activar el modo «fair» para que los hilos se atiendan en orden de cola. Esto evita la inanición (starvation).
  • En StampedLock no hay imparcialidad: los hilos pueden esperar más si otros «interceptan» el bloqueo continuamente. Es posible una inanición rara.

5. Contadores: LongAdder/LongAccumulator vs AtomicLong

El problema de AtomicLong bajo alta contención

AtomicLong es una variable atómica que proporciona un incremento thread-safe. Pero con muchos hilos llamando simultáneamente a incrementAndGet(), todos «pelean» por una única variable, lo que lleva a una caída del rendimiento.

LongAdder: contadores por franjas (striped)

LongAdder resuelve el problema de otra manera: divide el contador en varias «franjas» (stripes), cada una de las cuales atiende a su propio grupo de hilos. El hilo incrementa una de las franjas, y el valor total es la suma de todas ellas.

Ventaja:

  • Con alta contención, los hilos casi no se estorban entre sí.
  • El rendimiento es varias veces mayor que con 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 es la versión generalizada de LongAdder, donde se puede establecer una función de acumulación arbitraria (por ejemplo, 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

Bloqueos por franjas (striped) y reducción de la contención

La técnica de striped locks divide un recurso compartido en varias partes independientes (franjas), cada una protegida por su propio bloqueo o variable. Los hilos se distribuyen uniformemente por las franjas, lo que reduce la contención y aumenta el rendimiento. Este enfoque es precisamente el que usan LongAdder y LongAccumulator.

6. Práctica: caché con predominio de lectura

Tarea: tenemos un mapa (Map) con datos y metadatos (por ejemplo, un contador de accesos). Las lecturas ocurren a menudo y las escrituras son raras.

Implementación con 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 leer se usa el modo optimista: si nadie escribe — la lectura es casi gratuita.
  • Para escribir — write lock.
  • Para contar accesos — LongAdder: incluso con alta contención los incrementos no se estorban entre sí.

7. Perfilado de LongAdder vs AtomicLong bajo carga

Prueba: 8 hilos con 1 millón 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

Conclusión: Bajo alta contención LongAdder es varias veces más rápido que AtomicLong.

8. Errores típicos al usar StampedLock y LongAdder

Error n.º 1: olvidar llamar a unlockRead/unlockWrite. Si no liberas el stamp, otros hilos esperarán para siempre. ¡Usa siempre try/finally!

Error n.º 2: intento de reentrancia. StampedLock no soporta reentrada. No tomes el bloqueo dos veces desde el mismo hilo.

Error n.º 3: uso incorrecto de validate. Si no compruebas validate después de tryOptimisticRead, puedes obtener datos inconsistentes.

Error n.º 4: uso de AtomicLong bajo alta contención. AtomicLong va bien con 1–2 hilos, pero con 8+ hilos se convierte en un «cuello de botella». Usa LongAdder.

Error n.º 5: olvidar los bloqueos por franjas. Si implementas tu propio striped-lock, asegúrate de que los hilos se reparten uniformemente entre las franjas; de lo contrario, algunas se sobrecargarán mientras otras quedarán ociosas.

Error n.º 6: esperar imparcialidad de StampedLock. StampedLock no garantiza el orden de atención de los hilos. En casos raros es posible la inanición (starvation).

Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION