CodeGym /Corsi /JAVA 25 SELF /StampedLock e contatori a bassa contesa

StampedLock e contatori a bassa contesa

JAVA 25 SELF
Livello 58 , Lezione 3
Disponibile

1. Perché ReadWriteLock non basta sempre

Il problema dell’elevata concorrenza in scrittura

ReadWriteLock (più spesso — ReentrantReadWriteLock) funziona bene quando la maggior parte dei thread legge soltanto i dati e gli scrittori sono pochi. In questo caso più thread possono leggere contemporaneamente, mentre la scrittura blocca l’accesso solo per un breve momento.

Il problema nasce quando i thread che scrivono diventano più numerosi del previsto o passano spesso dalla lettura alla scrittura. Se le operazioni di scrittura richiedono molto tempo, i thread iniziano ad attendere più a lungo il rilascio dei lock. Di conseguenza la contesa per l’accesso ai dati cresce, lettura e scrittura rallentano e l’efficienza di ReadWriteLock cala.

Cambio di lock costoso

Quando un thread passa dalla modalità di lettura a quella di scrittura (o viceversa), sotto il cofano avviene un controllo complesso: bisogna assicurarsi che nessun altro stia scrivendo, che tutti i lettori siano usciti e solo dopo consentire la scrittura.

Se questi passaggi avvengono spesso, si introducono ritardi. I thread iniziano a mettersi in coda e le prestazioni calano — soprattutto quando ci sono molte operazioni di lettura e scrittura contemporaneamente.

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

// Lettura
readLock.lock();
try {
    // leggiamo i dati
} finally {
    readLock.unlock();
}

// Scrittura
writeLock.lock();
try {
    // modifichiamo i dati
} finally {
    writeLock.unlock();
}

Ogni volta che un thread passa tra readLock e writeLock, il sistema esegue tutti i controlli per evitare conflitti. Se questi passaggi sono numerosi — è costoso.

2. StampedLock: un approccio moderno alla sincronizzazione

StampedLock è un meccanismo di sincronizzazione moderno apparso in Java 8. Integra le idee di ReadWriteLock, ma aggiunge una nuova modalità — la lettura ottimistica — e lavora non con lock bensì con «stamp» (token speciali) che vanno rilasciati esplicitamente.

Caratteristiche chiave:

  • Tre modalità: write lock (scrittura esclusiva), read lock (lettura condivisa), optimistic read (lettura ottimistica senza lock).
  • Niente reentrancy: non è possibile acquisire di nuovo il lock dallo stesso thread.
  • Prestazioni elevate con molte letture e scritture rare.
  • Richiede la gestione esplicita degli stamp (stamp).

Lettura ottimistica: tryOptimisticRead + validate

La lettura ottimistica è la modalità in cui un thread legge i dati senza alcun lock, confidando che in quel momento nessuno stia scrivendo. Dopo la lettura il thread deve verificare se ci siano state scritture durante la lettura tramite il metodo 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;
        // Verifichiamo se c'è stata una scrittura durante la lettura
        if (!lock.validate(stamp)) {
            // Se c'è stata una scrittura — prendiamo un normale read lock
            stamp = lock.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return Math.hypot(currentX, currentY);
    }
}

Come funziona:

  • tryOptimisticRead() restituisce uno stamp (long) che è «valido» finché nessuno scrive.
  • Si leggono i valori di x e y.
  • validate(stamp) verifica che non ci sia stata una scrittura tra l’inizio e la fine della lettura.
  • Se tutto è ok — usiamo i valori letti; se c’è stata una scrittura — prendiamo un normale readLock e rileggiamo.

Conviene quando le scritture sono molto rare e le letture numerose. Nella maggior parte dei casi validate restituirà true e la lettura sarà quasi gratuita.

Scenari «molte letture — poche scritture»

  • I dati cambiano raramente ma sono letti spesso (per esempio cache, coordinate, metadati).
  • È importante minimizzare la latenza della lettura.
  • È accettabile «rileggere» i dati nel caso in cui ci sia stata una scrittura.

Fallback al read lock

Se la lettura ottimistica non riesce (validate restituisce false), il thread effettua il «fallback» al normale readLock. Questo garantisce la correttezza dei dati, ma accade di rado.

3. Insidie di StampedLock

Assenza di reentrancy

A differenza di ReentrantReadWriteLock, StampedLock non supporta il ri‑ingresso. Se un thread detiene già il lock e prova ad acquisirlo di nuovo — si entra in deadlock.

long stamp1 = lock.writeLock();
long stamp2 = lock.writeLock(); // DEADLOCK! Il thread attende sé stesso

Attenzione alle interruzioni

StampedLock non reagisce alle interruzioni dei thread allo stesso modo dei lock classici. Se un thread viene interrotto mentre attende un lock, non sempre «si sveglia» subito. Per task in cui è importante reagire rapidamente all’interruzione, usate altri meccanismi.

Rilascio corretto degli stamp

Ogni chiamata a readLock(), writeLock() o tryOptimisticRead() restituisce uno stamp unico (long). Bisogna necessariamente passarlo al corrispondente metodo di unlock:

  • unlockRead(stamp)
  • unlockWrite(stamp)

Errore: se si confondono gli stamp o si dimentica di chiamare unlock — si ottiene una perdita di lock e l’applicazione resta bloccata.

4. Confronto con ReentrantReadWriteLock

Caratteristica ReentrantReadWriteLock StampedLock
Reentrancy (ri‑ingresso) No
Lettura ottimistica No
Prestazioni con alta concorrenza Medie Alte (con poche scritture)
Gestione esplicita del lock No (automatico) Sì (stamp)
Reazione alle interruzioni Non sempre
Fairness (equità) Sì (opzionale) No

Modalità di fairness e impatto sullo starvation

  • In ReentrantReadWriteLock è possibile abilitare la modalità «fair» (fair) per servire i thread in ordine di coda. Questo previene lo starvation.
  • In StampedLock non c’è fairness: i thread possono attendere più a lungo se altri «intercettano» continuamente il lock. È possibile un raro starvation.

5. Contatori: LongAdder/LongAccumulator vs AtomicLong

Problema di AtomicLong con alta concorrenza

AtomicLong è una variabile atomica che garantisce incrementi thread‑safe. Ma con molti thread che chiamano simultaneamente incrementAndGet(), tutti si contendono la stessa variabile, il che porta a un calo di prestazioni.

LongAdder: contatori a bande (striped)

LongAdder affronta il problema in modo diverso: suddivide il contatore in più «bande» (stripes), ognuna delle quali serve un gruppo di thread. Un thread incrementa una delle bande e il valore finale è la somma di tutte le bande.

Vantaggi:

  • Con alta concorrenza i thread si ostacolano a malapena.
  • Le prestazioni sono molte volte superiori rispetto ad 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("Valore finale: " + adder.sum());

LongAccumulator

LongAccumulator è una versione generalizzata di LongAdder, in cui è possibile specificare una funzione di accumulo arbitraria (per esempio massimo, minimo, ecc.).

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("Massimo: " + max.get()); // 42

Lock a bande (striped) e riduzione della concorrenza

La tecnica delle striped locks suddivide una risorsa condivisa in più parti indipendenti (stripes), ognuna protetta dal proprio lock o variabile. I thread si distribuiscono uniformemente sulle bande, riducendo la contesa e aumentando le prestazioni. Proprio questo approccio è usato da LongAdder e LongAccumulator.

6. Pratica: cache con prevalenza di lettura

Obiettivo: abbiamo una mappa (Map) con dati e metadati (per esempio, un contatore di accessi). Le letture sono frequenti, le scritture rare.

Implementazione 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();
    }
}
  • Per la lettura si usa la modalità ottimistica: se nessuno scrive — la lettura è quasi gratuita.
  • Per la scrittura — write lock.
  • Per contare gli accessi — LongAdder: anche con alta concorrenza gli incrementi non si ostacolano.

7. Profilazione di LongAdder vs AtomicLong sotto carico

Test: 8 thread per 1 milione di incrementi

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);
    }
}

Risultato tipico:

AtomicLong: 2500 ms, LongAdder: 200 ms

Conclusione: Con alta concorrenza LongAdder è molte volte più veloce di AtomicLong.

8. Errori tipici nell’uso di StampedLock e LongAdder

Errore n. 1: dimenticato di chiamare unlockRead/unlockWrite. Se non si rilascia lo stamp, gli altri thread attenderanno all’infinito. Usate sempre try/finally!

Errore n. 2: tentativo di reentrancy. StampedLock non supporta il ri‑ingresso. Non acquisire il lock due volte dallo stesso thread.

Errore n. 3: uso scorretto di validate. Se non si verifica validate dopo tryOptimisticRead, si possono ottenere dati inconsistenti.

Errore n. 4: usare AtomicLong con alta concorrenza. AtomicLong va bene per 1–2 thread, ma con 8+ thread diventa un «collo di bottiglia». Usate LongAdder.

Errore n. 5: dimenticare i lock a bande. Se implementate un vostro striped‑lock, assicuratevi che i thread si distribuiscano uniformemente tra le bande, altrimenti alcune saranno sovraccariche e altre inattive.

Errore n. 6: aspettarsi fairness da StampedLock. StampedLock non garantisce l’ordine di servizio dei thread. In rari casi è possibile lo starvation.

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