W3docs

Variables atomiques en Java

Opérations thread-safe sans verrou en Java avec les classes java.util.concurrent.atomic — compteurs, références et compare-and-set.

volatile rend une seule lecture ou une seule écriture thread-safe. Il ne peut pas rendre counter++ thread-safe — c'est trois opérations. Le package java.util.concurrent.atomic comble cette lacune. Ses classes encapsulent une valeur unique et exposent des opérations comme increment-and-get et compare-and-set en tant qu'instructions atomiques simples — sans verrou, sans bloc synchronized, juste une primitive CPU (compare-and-swap, ou CAS) que la JVM compile directement.

Les atomiques sont le bon outil pour un nombre surprenant de modèles multithread : compteurs, numéros de séquence, état de type drapeau et tout idiome "publier un nouvel instantané immuable". Ils sont plus rapides que synchronized sous contention et bien plus simples que d'écrire manuellement un verrou autour d'un seul champ.

La famille

Le package compte huit classes couramment utilisées :

ClasseEncapsuleOpérations courantes
AtomicIntegerintget, set, incrementAndGet, addAndGet, compareAndSet
AtomicLonglongidem, sur long
AtomicBooleanbooleanget, set, compareAndSet
AtomicReference<V>V (toute référence d'objet)get, set, compareAndSet, updateAndGet
AtomicIntegerArrayint[]opérations atomiques par index
AtomicLongArraylong[]opérations atomiques par index
AtomicReferenceArray<V>V[]opérations atomiques par index
LongAdder / LongAccumulatorlongcompteur à haute contention

Les quatre premiers sont ceux que vous utiliserez dans 99 % des cas.

AtomicInteger — le bon compteur

Le remplaçant de "volatile int plus ++" :

AtomicInteger counter = new AtomicInteger();          // starts at 0

counter.incrementAndGet();                            // ++counter, atomic
counter.getAndIncrement();                            // counter++, atomic
counter.addAndGet(5);                                 // counter += 5, atomic
counter.set(42);                                      // counter = 42, atomic
int n = counter.get();                                // read

counter.compareAndSet(42, 100);                       // if (counter == 42) counter = 100; return whether it changed

incrementAndGet est ce qu'il vous faut pour un compteur simple ; en interne, c'est une boucle CAS que le CPU exécute en une seule instruction sur un x86 moderne (LOCK XADD). L'ensemble de l'opération est une transaction mémoire au niveau du bus — bien moins coûteuse que l'acquisition d'un verrou synchronized même non contesté.

compareAndSet(expected, new) est le bloc de construction de presque tout le reste. Il écrit new de façon atomique uniquement si la valeur actuelle est expected, et retourne si l'écriture a eu lieu. Avec lui, vous pouvez construire n'importe quelle mise à jour atomique sur un seul champ :

AtomicInteger max = new AtomicInteger(Integer.MIN_VALUE);
void recordMax(int v) {
  int cur;
  do {
    cur = max.get();
    if (v <= cur) return;                             // nothing to do
  } while (!max.compareAndSet(cur, v));               // retry if someone else updated
}

La boucle CAS est le modèle standard : lire, calculer, tenter d'écrire, réessayer en cas de conflit. C'est ainsi qu'incrementAndGet est implémenté ; c'est ainsi que vous écririez toute mise à jour composée sur un seul champ.

Java 8 a simplifié la boucle :

max.updateAndGet(cur -> Math.max(cur, v));            // CAS loop hidden

updateAndGet, accumulateAndGet et getAndUpdate prennent une fonction et effectuent la boucle CAS pour vous. Préférez-les quand ils conviennent.

AtomicReference<V> — la bonne façon d'échanger un objet

Lorsque l'état partagé est plus qu'une primitive — une map de configuration, un instantané mis en cache, un conteneur immuable — AtomicReference vous permet d'échanger atomiquement l'objet entier :

AtomicReference<Config> currentConfig = new AtomicReference<>(initialConfig);

void reload() {
  Config c = readConfigFromDisk();                    // expensive, lock-free
  currentConfig.set(c);                               // publish atomically
}

Config get() { return currentConfig.get(); }

L'astuce : le contenu de Config doit être immuable (ou ne pas être modifié après publication). L'échange atomique publie une valeur terminée ; si d'autres threads mutent ensuite les données internes de la valeur, vous perdez la sécurité. C'est le modèle instantané immuable, et c'est ainsi que sont construits la plupart des caches concurrents, tables de routage et objets "configuration globale".

updateAndGet sur une référence est aussi extrêmement utile :

AtomicReference<List<String>> log = new AtomicReference<>(List.of());

void append(String line) {
  log.updateAndGet(old -> {
    var copy = new ArrayList<>(old);
    copy.add(line);
    return List.copyOf(copy);                         // immutable snapshot
  });
}

Chaque lecteur obtient une liste immuable cohérente. Les écrivains se disputent ; la boucle CAS réessaie pour les rares qui perdent la course. Peu coûteux sous faible contention, lent mais correct sous forte contention.

LongAdder — le compteur à haute contention

Sous forte contention, AtomicLong.incrementAndGet devient un goulot d'étranglement — tous les threads martèlent la même adresse mémoire, et le CPU doit sérialiser les transactions sur le bus. LongAdder résout ce problème en maintenant plusieurs compteurs internes, un par CPU, et en les sommant lors de la lecture :

LongAdder requestCount = new LongAdder();

void onRequest() { requestCount.increment(); }        // append-only, no contention

long snapshot() { return requestCount.sum(); }        // sums every cell — not atomic but eventually consistent

Utilisez LongAdder lorsque :

  • Le compteur est incrémenté depuis de nombreux threads simultanément (pensez : métriques par requête dans un serveur web).
  • Vous le lisez rarement (toutes les quelques secondes pour un tableau de bord).

Utilisez AtomicLong lorsque :

  • L'incrémentation est rare ou mono-threadée.
  • Vous avez besoin d'une lecture instantanée et précise.

LongAdder est l'un des compteurs concurrents les plus rapides qui soit — mais la contrepartie est que sum() n'est pas atomique avec les incréments concurrents. Pour le cas typique de reporting de métriques, c'est parfaitement acceptable.

Ce que les atomiques ne sont pas

Les atomiques s'appliquent à un seul champ. Ils ne se composent pas sur plusieurs champs :

AtomicInteger a = new AtomicInteger();
AtomicInteger b = new AtomicInteger();

a.incrementAndGet();                                  // atomic on its own
b.incrementAndGet();                                  // atomic on its own
// but the pair is NOT atomic — another thread can see new a, old b

Si votre invariant couvre plusieurs champs ("a == b + 1 toujours"), vous avez besoin d'un verrou (ou d'un seul atomique sur un objet conteneur qui possède les deux).

Les atomiques n'aident pas non plus pour la visibilité des champs non liés. Écrire dans un atomique ne publie pas d'autres champs comme le ferait volatile. Rendez ces autres champs volatile (ou final, ou écrivez-les à travers l'atomique).

compareAndExchange et la nouvelle API (Java 9+)

Java 9 a ajouté compareAndExchange (retourne la valeur actuelle, pas seulement un boolean) :

int prev = counter.compareAndExchange(expected, newVal);
if (prev == expected) {                               // we won
  ...
} else {                                              // somebody else got there first
  // prev is the actual current value
}

Java 9 a également ajouté l'API VarHandle qui expose un CAS faible, un accès ordonné, etc., pour les bibliothèques concurrentes de bas niveau. Vous en aurez très rarement besoin ; nous le mentionnons ici pour que vous ayez vu le nom.

Exemple concret : compteur et instantané

Le programme ci-dessous compare quatre compteurs : non synchronisé, volatile, AtomicInteger et LongAdder. Les quatre sont sollicités par 8 threads effectuant chacun 100 000 incréments.

java— editable, runs on the server

Ce qu'il faut retenir de l'exécution :

  • plain et volatile ont tous deux perdu des mises à jour — parfois de façon spectaculaire (un compteur final bien en dessous des 800 000 attendus). volatile corrige le problème de visibilité mais n++ reste trois opérations. C'est la chose la plus importante à retenir à propos de volatile : il ne rend pas les mises à jour composées atomiques.
  • AtomicInteger a produit le résultat exact attendu, à chaque exécution. Le coût par incrément était de quelques nanosecondes — significativement plus élevé que n++ sur un int simple (qui en coûte une ou deux), mais sans acquisition de verrou ni blocage de thread. Sous contention, il est bien plus rapide que synchronized.
  • LongAdder était le compteur le plus rapide sous la charge de 8 threads — il disperse les écritures sur des cellules séparées par CPU, de sorte que les threads ne se disputent pas une seule ligne de cache. La contrepartie est que sum() n'est pas atomique avec increment() (un lecteur peut voir un total légèrement obsolète), ce qui est exactement le bon compromis pour les métriques et compteurs où la précision instantanée n'est pas nécessaire.
  • La boucle CAS-max a enregistré la valeur la plus grande vue parmi tous les échantillons. La boucle est le modèle général : lire la valeur actuelle, calculer la nouvelle valeur souhaitée, tenter de l'écrire ; si quelqu'un d'autre a écrit en premier, le CAS échoue et vous réessayez. La plupart des appels updateAndGet et accumulateAndGet sont cette boucle avec le code répétitif masqué.
  • L'AtomicReference<List<String>> a produit un instantané immuable du journal. Chaque écrivain a construit une nouvelle copie immuable et a tenté de la publier ; sous contention, deux écrivains peuvent tous deux construire une copie et le CAS de l'un échoue — ce thread réessaie, lit la liste fraîchement mise à jour et fusionne. Le modèle est coûteux sous forte contention (beaucoup de copies jetables) mais idéal pour les instantanés "lecture intensive, reconstruction occasionnelle".

La suite

Le chapitre suivant, Java Locks, commence l'histoire de java.util.concurrent.locks — l'interface Lock, pourquoi elle existe aux côtés de synchronized, et les capacités (tryLock, lockInterruptibly, Condition) qu'elle ajoute et que le moniteur intrinsèque ne possède pas.

Pratique

Pratique
Quelle est la bonne façon d'incrémenter en toute sécurité un compteur partagé depuis de nombreux threads dans une boucle serrée ?
Quelle est la bonne façon d'incrémenter en toute sécurité un compteur partagé depuis de nombreux threads dans une boucle serrée ?
Was this page helpful?