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 :
| Classe | Encapsule | Opérations courantes |
|---|---|---|
AtomicInteger | int | get, set, incrementAndGet, addAndGet, compareAndSet |
AtomicLong | long | idem, sur long |
AtomicBoolean | boolean | get, set, compareAndSet |
AtomicReference<V> | V (toute référence d'objet) | get, set, compareAndSet, updateAndGet |
AtomicIntegerArray | int[] | opérations atomiques par index |
AtomicLongArray | long[] | opérations atomiques par index |
AtomicReferenceArray<V> | V[] | opérations atomiques par index |
LongAdder / LongAccumulator | long | compteur à 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 changedincrementAndGet 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 hiddenupdateAndGet, 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 consistentUtilisez 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 bSi 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.
Ce qu'il faut retenir de l'exécution :
plainetvolatileont tous deux perdu des mises à jour — parfois de façon spectaculaire (un compteur final bien en dessous des800 000attendus).volatilecorrige le problème de visibilité maisn++reste trois opérations. C'est la chose la plus importante à retenir à propos devolatile: il ne rend pas les mises à jour composées atomiques.AtomicIntegera produit le résultat exact attendu, à chaque exécution. Le coût par incrément était de quelques nanosecondes — significativement plus élevé quen++sur unintsimple (qui en coûte une ou deux), mais sans acquisition de verrou ni blocage de thread. Sous contention, il est bien plus rapide quesynchronized.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 quesum()n'est pas atomique avecincrement()(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
updateAndGetetaccumulateAndGetsont 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.