CodeGym /Cours /JAVA 25 SELF /StampedLock et compteurs à faible contention

StampedLock et compteurs à faible contention

JAVA 25 SELF
Niveau 58, Leçon 3
Disponible

1. Pourquoi ReadWriteLock ne suffit pas toujours

Problème de forte concurrence en écriture

ReadWriteLock (le plus souvent — ReentrantReadWriteLock) fonctionne bien lorsque la plupart des threads ne font que lire les données et que les écrivains sont peu nombreux. Dans ce cas, plusieurs threads peuvent lire simultanément, tandis que l’écriture ne bloque l’accès que pour un court moment.

Le problème survient lorsque les threads qui écrivent deviennent plus nombreux que prévu, ou lorsqu’ils basculent fréquemment entre lecture et écriture. Si les opérations d’écriture prennent du temps, les threads commencent à attendre plus longtemps la libération des verrous. Il en résulte une concurrence accrue pour l’accès aux données, la lecture et l’écriture ralentissent, et l’efficacité de ReadWriteLock diminue.

Commutation de verrous coûteuse

Lorsqu’un thread passe du mode lecture au mode écriture (ou inversement), une vérification complexe se produit sous le capot : il faut s’assurer que personne d’autre n’écrit, que tous les lecteurs sont sortis, puis seulement autoriser l’écriture.

Si ces transitions se produisent souvent, cela crée des retards. Les threads commencent à faire la queue, et les performances chutent — surtout lorsqu’il y a simultanément beaucoup d’opérations de lecture et d’écriture.

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

// Lecture
readLock.lock();
try {
    // lecture des données
} finally {
    readLock.unlock();
}

// Écriture
writeLock.lock();
try {
    // modification des données
} finally {
    writeLock.unlock();
}

Chaque fois qu’un thread passe entre readLock et writeLock, le système effectue toutes les vérifications pour éviter les conflits. Si ces transitions sont nombreuses — cela devient coûteux.

2. StampedLock : une approche moderne de la synchronisation

StampedLock est un mécanisme de synchronisation moderne, apparu avec Java 8. Il combine les idées de ReadWriteLock, mais ajoute un nouveau mode — la lecture optimiste — et fonctionne non pas avec des verrous, mais avec des « stamps » (jetons) — des jetons spéciaux qu’il faut libérer explicitement.

Caractéristiques clés :

  • Trois modes : write lock (écriture exclusive), read lock (lecture partagée), optimistic read (lecture optimiste sans verrou).
  • Pas de réentrance : impossible d’entrer à nouveau dans le verrou depuis le même thread.
  • Hautes performances lorsque les lectures sont nombreuses et les écritures rares.
  • Nécessite une gestion explicite des stamps (stamp).

Lecture optimiste : tryOptimisticRead + validate

La lecture optimiste est un mode où un thread lit les données sans aucun verrou, en espérant que personne n’écrit à ce moment-là. Après la lecture, le thread doit vérifier s’il n’y a pas eu d’écriture pendant la lecture via la méthode 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;
        // Vérifier si une écriture a eu lieu pendant la lecture
        if (!lock.validate(stamp)) {
            // En cas d'écriture — prendre un read lock classique
            stamp = lock.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return Math.hypot(currentX, currentY);
    }
}

Comment cela fonctionne :

  • tryOptimisticRead() renvoie un stamp (long) « valide » tant que personne n’écrit.
  • On lit les valeurs de x et y.
  • validate(stamp) vérifie s’il n’y a pas eu d’écriture entre le début et la fin de la lecture.
  • Si tout va bien — on utilise les valeurs lues ; s’il y a eu écriture — on prend un readLock classique et on relit.

C’est avantageux lorsque les écritures sont très rares et les lectures nombreuses. Dans la plupart des cas, validate renverra true, et la lecture sera presque gratuite.

Scénarios « beaucoup de lectures — peu d’écritures »

  • Les données changent rarement mais sont lues souvent (par exemple, cache, coordonnées, métadonnées).
  • Il est important de minimiser la latence de lecture.
  • On peut « relire » les données si jamais il y a eu écriture.

Repli sur le read lock

Si la lecture optimiste échoue (validate renvoie false), le thread « revient » à un readLock classique. Cela garantit la correction des données, mais cela arrive rarement.

3. Pièges de StampedLock

Absence de réentrance

Contrairement à ReentrantReadWriteLock, StampedLock ne supporte pas l’entrée répétée. Si un thread détient déjà un verrou et tente de le reprendre — deadlock.

long stamp1 = lock.writeLock();
long stamp2 = lock.writeLock(); // DEADLOCK! Le thread se met en attente de lui-même

Attention aux interruptions

StampedLock ne réagit pas aux interruptions des threads comme les verrous classiques. Si un thread est interrompu pendant l’attente d’un verrou, il ne « se réveille » pas toujours immédiatement. Pour les tâches où une réaction rapide à l’interruption est importante, utilisez d’autres mécanismes.

Libération correcte des stamps

Chaque appel à readLock(), writeLock() ou tryOptimisticRead() renvoie un stamp unique (long). Il faut impérativement le passer à la méthode d’unlock correspondante :

  • unlockRead(stamp)
  • unlockWrite(stamp)

Erreur : si vous mélangez les stamps ou oubliez d’appeler unlock — vous aurez une fuite de verrous et le programme se figera.

4. Comparaison avec ReentrantReadWriteLock

Caractéristique ReentrantReadWriteLock StampedLock
Réentrance (entrée répétée) Oui Non
Lecture optimiste Non Oui
Performances sous forte concurrence Moyennes Élevées (si peu d’écritures)
Gestion explicite du verrou Non (automatique) Oui (stamps)
Réaction aux interruptions Oui Pas toujours
Équité (fairness) Oui (activable) Non

Modes d’équité et impact sur le phénomène de starvation

  • Dans ReentrantReadWriteLock, on peut activer le mode « équitable » (fair) afin que les threads soient servis dans l’ordre de la file. Cela évite le phénomène de starvation.
  • Dans StampedLock, il n’y a pas d’équité : les threads peuvent attendre plus longtemps si d’autres « capturent » en permanence le verrou. Un starvation rare est possible.

5. Compteurs : LongAdder/LongAccumulator vs AtomicLong

Problème avec AtomicLong sous forte concurrence

AtomicLong est une variable atomique qui assure un incrément thread-safe. Mais lorsque de nombreux threads appellent simultanément incrementAndGet(), ils « se battent » pour une seule variable, ce qui entraîne une baisse des performances.

LongAdder : compteurs « striped » (par bandes)

LongAdder résout le problème différemment : il divise le compteur en plusieurs « bandes » (stripes), chacune desservant son propre groupe de threads. Un thread incrémente l’une des bandes, et la valeur finale est la somme de toutes les bandes.

Avantage :

  • Sous forte concurrence, les threads se gênent très peu.
  • Performances plusieurs fois supérieures à 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("Valeur finale: " + adder.sum());

LongAccumulator

LongAccumulator est une version généralisée de LongAdder, où l’on peut définir une fonction d’accumulation arbitraire (par exemple, maximum, minimum, 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("Maximum: " + max.get()); // 42

Verrous par bandes (striped) et réduction de la concurrence

La technique des striped locks divise une ressource partagée en plusieurs parties indépendantes (bandes), chacune protégée par son propre verrou ou sa propre variable. Les threads sont répartis uniformément entre les bandes, ce qui réduit la concurrence et augmente les performances. C’est précisément cette approche qu’utilisent LongAdder et LongAccumulator.

6. Pratique : cache à dominante lecture

Objectif : nous avons une Map avec des données et des métadonnées (par exemple, un compteur d’accès). Les lectures sont fréquentes, les écritures — rares.

Implémentation avec 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();
    }
}
  • Pour la lecture, on utilise le mode optimiste : si personne n’écrit — la lecture est presque gratuite.
  • Pour l’écriture — write lock.
  • Pour le comptage des accès — LongAdder : même sous forte concurrence, les incréments ne se gênent pas.

7. Profilage de LongAdder vs AtomicLong sous charge

Test : 8 threads avec 1 million d’incréments

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

Résultat typique :

AtomicLong: 2500 ms, LongAdder: 200 ms

Conclusion : Sous forte concurrence, LongAdder est plusieurs fois plus rapide que AtomicLong.

8. Erreurs courantes avec StampedLock et LongAdder

Erreur n°1 : oubli d’appeler unlockRead/unlockWrite. Si vous ne libérez pas le stamp, les autres threads attendront indéfiniment. Utilisez toujours try/finally !

Erreur n°2 : tentative de réentrance. StampedLock ne prend pas en charge l’entrée répétée. Ne prenez pas le verrou deux fois depuis le même thread.

Erreur n°3 : mauvaise utilisation de validate. Si vous ne vérifiez pas validate après tryOptimisticRead, vous risquez d’obtenir des données incohérentes.

Erreur n°4 : utiliser AtomicLong sous forte concurrence. AtomicLong convient pour 1–2 threads, mais à partir de 8+ threads, il devient un goulot d’étranglement. Utilisez LongAdder.

Erreur n°5 : oubli des verrous par bandes. Si vous implémentez votre propre striped-lock, assurez-vous que les threads sont répartis uniformément sur les bandes, sinon certaines seront surchargées tandis que d’autres resteront inactives.

Erreur n°6 : attendre de l’équité de StampedLock. StampedLock ne garantit pas l’ordre de service des threads. Dans de rares cas, un starvation est possible.

1
Mission
JAVA 25 SELF, niveau 58, leçon 3
Bloqué
Centre de Contrôle des Vols : Système de Mise en Cache de l'Altitude 🛰️
Centre de Contrôle des Vols : Système de Mise en Cache de l'Altitude 🛰️
1
Mission
JAVA 25 SELF, niveau 58, leçon 3
Bloqué
Navigateur de drone: Calcul optimiste de la position 🚁
Navigateur de drone: Calcul optimiste de la position 🚁
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION