Modèle de mémoire Java, Volatile, CAS et le problème ABA

Ce document aborde plusierus concepts fondamentaux de la concurrence en Java : le Modèle de Mémoire Java (JMM), le mot-clé volatile, l'opération CAS (Compare And Swap), les verrous à rotation (spinlocks) et le problème ABA.

Modèle de Mémoire Java (JMM)

Il est crucial de distinguer le JMM de la structure de mémoire JVM. La JVM se compose de plusieurs zones : le tas (Heap), la zone de métadonnées (Method Area), les piles JVM (JVM Stacks), le compteur de programmes (Program Counter) et les piles natives (Native Method Stacks). Les deux premières sont partagées entre les threads, tandis que les trois dernières sont privées à chaque thread.

Le JMM repose sur un modèle de données partagé : une mémoire principale commune à toutes les threads et une mémoire locale (privée) pour chaque thread. La mémoire principale correspond au tas, et la mémoire locale aux piles JVM, au compteur de programmes et aux piles natives.

Lors de l'exécution, chaque thread charge les données de la mémoire principale dans sa mémoire locale avant d'effectuer des opérations.

Le mot-clé volatile

Considérons le scénario suivant :

  1. Un thread A crée un objet Integer a = 10;. La JVM alloue de la mémoire pour a dans le tas. Le thread A se termine.
  2. Deux autres threads, B et C, sont créés et doivent modifier a. La JVM copie a dans les piles respectives de B et C. Chaque thread exécute a = a + 1;. Si B termine sa lecture, incrémentation et écriture avant que C ne commence, puis C fait de même, a sera correctement incrémenté deux fois. Cependant, si B lit a (valeur 10), C lit a (valeur 10), B incrémente sa copie locale à 11 et l'écrit dans la mémoire principale, puis C incrémente sa copie locale à 11 et l'écrit dans la mémoire principale, le résultat final sera 11, et non 12.

Pour résoudre ce problème, on peut utiliser le mot-clé volatile sur la variable a. Les variables déclarées volatile possèdent deux caractéristiques principales :

  1. Visibilité en mémoire :
    • Lorsqu'un thread modifie une variable volatile, la JVM est forcée de rafraîchir la valeur de cette variable dans la mémoire principale.
    • Ce rafraîchissement invalide les copies locales de cette variable précédemment chargées par d'autres threads.
  2. Interdiction de réordonnancement des instructions : Empêche le compilateur et le processeur de réorganiser les opérations relatives à cette variable de manière à compromettre la cohérence.

Bien que volatile garantisse la visibilité, il ne garantit pas l'atomicité des opérations composites comme l'incrémentation (qui se décompose en lecture, modification et écriture).

Imaginez :

  1. volatile Integer a = 10;
  2. Le thread A exécute a++;.
  3. Le thread B exécute a = 0;.

Si A est interrompu après avoir lu la valeur de a mais avant de l'avoir écrite, et que B exécute ensuite son opération, puis que A reprend, la valeur de a pourrait être incorrecte. Par exemple, si A lit 10, B met a à 0, puis A continue et écrit 11, le résultat est 11 au lieu de ce qui était attendu si les opérations s'étaient déroulées séquentiellement ou avec une synchronisation appropriée.

Pour pallier ces problèmes, on pourrait utiliser synchronized, un mécanisme de verrouillage bloquant et pessimiste. Cependant, il existe une alternative : les verrous optimistes, tels que les verrous à rotation (spinlocks), qui s'apppuient sur l'opération CAS.

CAS (Compare And Swap)

CAS est une pensée, une instruction atomique CPU, et la base de méthodes natives dans le package java.util.concurrent (comme compareAndSet).

Le principe de CAS est le suivant :

  1. Comparer la valeur actuelle en mémoire avec une valeur attendue.
  2. Si elles correspondent, remplacer la valeur en mémoire par une nouvelle valeur.

Voici un exemple utilisant AtomicInteger :


import java.util.concurrent.atomic.AtomicInteger;

public class CasExample {
    public static void main(String[] args) {
        AtomicInteger counter = new AtomicInteger(10);

        // Tentative de mise à jour : attend 10, nouvelle valeur 20
        // Succès car la valeur actuelle est 10. Le counter devient 20.
        System.out.println("Tentative 1: " + counter.compareAndSet(10, 20) + " | Valeur actuelle: " + counter.get());

        // Tentative de mise à jour : attend 10, nouvelle valeur 30
        // Échec car la valeur actuelle est 20, pas 10. Le counter reste 20.
        System.out.println("Tentative 2: " + counter.compareAndSet(10, 30) + " | Valeur actuelle: " + counter.get());

        // Tentative de mise à jour : attend 20, nouvelle valeur 30
        // Succès car la valeur actuelle est 20. Le counter devient 30.
        System.out.println("Tentative 3: " + counter.compareAndSet(20, 30) + " | Valeur actuelle: " + counter.get());
    }
}

La méthode compareAndSet(expectedValue, newValue) retourne true si la valeur actuelle est égale à expectedValue et que la mise à jour vers newValue a réussi, false sinon.

Verrous à Rotation (Spinlocks) et CAS

Les verrous à rotation sont une implémentation de verrouillage optimiste. Ils tentent d'effectuer une opération sans acquérir de verrou, en supposant qu'il n'y aura pas de conflit. En cas de conflit (échec de l'opération), ils réessaient jusqu'à ce que l'opération réussisse. Le mécanisme sous-jacent est souvent CAS.

Voici un exemple d'incrémenteur utilisant CAS pour une implémentation de verrou optimiste :


import java.util.concurrent.atomic.AtomicInteger;

public class OptimisticCounter {
    private AtomicInteger value = new AtomicInteger(0); // La valeur atomique

    public void increment() {
        int expectedValue;
        int newValue;

        do {
            expectedValue = value.get(); // Obtenir la valeur attendue
            newValue = expectedValue + 1; // Calculer la nouvelle valeur
            // Tenter de mettre à jour : si la valeur actuelle est toujours expectedValue,
            // la remplacer par newValue. Sinon, la boucle recommence.
        } while (!value.compareAndSet(expectedValue, newValue));
    }

    public int getValue() {
        return value.get();
    }
}

Dans ce code :

  • Si l'opération est interrompue entre value.get() et value.compareAndSet(), et que la valeur a été modifiée par un autre thread, compareAndSet échouera. La boucle do-while reprendra alors, relisant la nouvelle valeur et tentant à nouveau l'incrémentation.
  • L'opération est non bloquante car les threads ne s'endorment pas ; ils réessaient activement (rotation).
  • Elle est optimiste car elle suppose l'absence de conflit.
  • Elle est une forme de rotation (spin) car elle réessaie en boucle.

Il est important de noter que CAS et spinlock sont des concepts distincts : CAS est l'outil, spinlock est une stratégie qui utilise souvent CAS.

Problème ABA

Le problème ABA survient avec CAS. Le processus typique est :

  1. Lire la valeur A à l'adresse V.
  2. Calculer la nouvelle valeur B à partir de A.
  3. Utiliser CAS pour tenter de remplacer A par B à l'adresse V.

Le problème survient si un autre thread modifie V de A à C, puis le remplace à nouveau par A avant que le premier thread n'exécute l'étape 3. Le premier thread, voyant que la valeur est toujours A, conclura à tort qu'il n'y a pas eu de conflit et effectuera la mise à jour, potentiellement dans un état incohérent.

Solution au Problème ABA

Pour contrer le problème ABA, on peut utiliser AtomicStampedReference. Cette classe associe à la valeur une "estampille" (un numéro de version). Toute modification inclut une vérification à la fois de la valeur et de l'estampille. Si l'estampille a changé (même si la valeur est revenue à son état d'origine), la mise à jour échouera.

La classe StampedLock utilise une approche similaire pour ses verrous optimistes de lecture. La méthode tryOptimisticRead() renvoie une "estampille" (un long). Si un verrou d'écriture est acquis entre-temps, l'estampille interne est mise à jour. La méthode validate(stamp) permet de vérifier si l'estampille est toujours valide, indiquant qu'aucun verou d'écriture n'a été pris depuis l'obtention de l'estampille optimiste.

En pratique, il faut évaluer si le problème ABA peut réellement causer une erreur logique dans votre application. Si ce n'est pas le cas, l'utilisation des classes atomiques standard est souvent suffisante. Sinon, un verrou synchronized plus traditionnel pourrait être une solution plus simple à implémenter.

Étiquettes: Java JMM volatile CAS atomicinteger

Publié le 8 septembre à 01h53