1. Pourquoi i++ ne fonctionne pas en multithreading ?
Commençons par un cas classique : nous avons une variable‑compteur, par exemple le nombre de demandes traitées ou le nombre de fichiers téléchargés. Nous voulons que plusieurs threads augmentent ce compteur. Qu’est‑ce qui peut mal se passer si l’on écrit simplement i++ ?
Exemple : course de données lors de l’incrément
public class Counter {
public int count = 0;
public void increment() {
count++; // Pas atomique !
}
}
Supposons que deux threads appellent increment() simultanément. Les deux lisent l’ancienne valeur, l’augmentent de 1 et écrivent… la même nouvelle valeur ! Au final, un incrément est « perdu ». Si l’on répète cela de nombreuses fois, la valeur finale sera inférieure à celle attendue.
Pourquoi cela se produit‑il ?
L’opération i++ se compose en réalité de trois étapes :
- Lire la valeur de la variable (par exemple, 5).
- Augmenter cette valeur de 1.
- Écrire la nouvelle valeur en mémoire.
Et dans un environnement multithread, un autre thread peut modifier la variable entre ces étapes. Résultat — « course de données » (race condition).
Qu’est‑ce qu’une opération atomique ?
Une opération atomique est une action qui est soit exécutée entièrement, soit pas du tout, et aucun autre thread ne peut s’intercaler au milieu de cette opération.
En Java, un ensemble de classes fournit de telles opérations pour les types primitifs et les références. Elles se trouvent dans le package java.util.concurrent.atomic. Les plus populaires :
- AtomicInteger — type entier atomique.
- AtomicLong — long atomique.
- AtomicBoolean — boolean atomique.
- AtomicReference<T> — référence atomique vers un objet de n’importe quel type.
2. AtomicInteger : compteur thread‑safe
Déclaration et utilisation de base
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // Incrément atomique
}
public int get() {
return count.get();
}
}
Ici, incrementAndGet() exécute « incrémenter et retourner la nouvelle valeur » comme une opération indivisible. Même si 100 threads appellent cette méthode simultanément, aucun incrément ne sera perdu.
Méthodes utiles :
| Méthode | Description |
|---|---|
|
Obtenir la valeur actuelle |
|
Définir la valeur |
|
Augmenter de 1 et retourner la nouvelle valeur |
|
Retourner la valeur actuelle puis augmenter de 1 |
|
Augmenter de delta et retourner la nouvelle valeur |
|
Si la valeur actuelle vaut expect, définir update (CAS) |
Exemple : compteur multi‑thread
Supposons que nous ayons une classe qui compte le nombre de messages traités dans un chat.
public class MessageStatistics {
private final AtomicInteger messageCount = new AtomicInteger(0);
public void onMessageReceived() {
int newCount = messageCount.incrementAndGet();
System.out.println("Nombre total de messages : " + newCount);
}
public int getMessageCount() {
return messageCount.get();
}
}
Sous le capot : comment fonctionne AtomicInteger ?
À l’intérieur, AtomicInteger utilise une instruction spéciale du processeur — CAS (Compare-And-Swap, « comparer‑et‑échanger »). C’est une opération atomique qui compare la valeur actuelle de la variable avec la valeur attendue et, si elles coïncident, écrit la nouvelle valeur. Si un autre thread a eu le temps de modifier la variable, l’opération n’est pas effectuée et la tentative est réessayée.
Schéma de fonctionnement :
1. Lire la valeur actuelle (par exemple, 5)
2. Comparer avec la valeur attendue (5)
3. Si elles coïncident — écrire la nouvelle valeur (6)
4. Sinon — réessayer
Tout cela se fait très rapidement et sans verrous (lock‑free). Par conséquent, les classes atomiques sont souvent plus rapides que synchronized, surtout avec un grand nombre de threads.
3. AtomicReference : référence atomique vers un objet
AtomicReference<T> est un conteneur atomique universel pour n’importe quel objet. Il permet de modifier en toute sécurité la référence à un objet depuis plusieurs threads.
Exemple : mise à jour thread‑safe d’une référence
import java.util.concurrent.atomic.AtomicReference;
public class AtomicReferenceExample {
private final AtomicReference<String> latestMessage = new AtomicReference<>("");
public void updateMessage(String message) {
latestMessage.set(message);
}
public String getLatestMessage() {
return latestMessage.get();
}
}
Utilisation de compareAndSet
L’opération la plus intéressante est compareAndSet(expected, newValue). Elle permet de mettre à jour la valeur uniquement si elle n’a pas changé depuis la dernière lecture.
public void safeUpdate(String oldValue, String newValue) {
boolean success = latestMessage.compareAndSet(oldValue, newValue);
if (success) {
System.out.println("Mise à jour réussie !");
} else {
System.out.println("Quelqu'un a déjà modifié la valeur, réessayez.");
}
}
C’est la base des algorithmes non bloquants : des files et piles aux caches, où il est important d’éviter des verrous inutiles.
4. Exemples d’utilisation dans une application
Exemple 1 : compteur de messages multi‑thread
public class ChatRoom {
private final AtomicInteger messageCount = new AtomicInteger(0);
public void receiveMessage(String message) {
// ... traitement du message ...
int count = messageCount.incrementAndGet();
System.out.println("Nouveau message : " + message + ". Nombre total de messages : " + count);
}
}
Exemple 2 : mise à jour sûre de la référence vers le dernier message
public class ChatRoom {
private final AtomicReference<String> lastMessage = new AtomicReference<>("");
public void receiveMessage(String message) {
lastMessage.set(message);
// ... traitement ...
}
public String getLastMessage() {
return lastMessage.get();
}
}
Si vous devez mettre à jour la référence uniquement si le dernier message n’a pas changé (pour éviter une « perte » lors de mises à jour simultanées), utilisez compareAndSet.
5. Limites et pièges
Quand les classes atomiques ne sont‑elles pas la panacée ?
Les variables atomiques conviennent parfaitement aux opérations simples : incrément, définition d’une valeur, vérification et remplacement. Mais si vous devez mettre à jour plusieurs variables simultanément, l’atomicité n’est plus garantie. Par exemple, si vous avez deux compteurs et que vous voulez les augmenter tous les deux en une seule opération — il faut ici synchronized ou un autre mécanisme de synchronisation.
Exemple de mauvais usage
// Pas atomique !
if (ref.get() == null) {
ref.set("Hello");
}
Entre get() et set(...), un autre thread peut modifier la valeur, et la condition ne sera déjà plus vraie. Pour de tels cas, utilisez compareAndSet.
Classes atomiques ≠ objets thread‑safe
Si l’objet référencé par AtomicReference n’est pas lui‑même thread‑safe, alors le remplacement de la référence sera atomique, mais la modification des champs de l’objet ne le sera pas. Par exemple, si vous stockez dans AtomicReference<List<String>> un ArrayList classique, la liste elle‑même ne devient pas thread‑safe.
6. Classes atomiques avancées
Le package java.util.concurrent.atomic contient d’autres classes utiles :
- AtomicLong, AtomicBoolean — pour long et boolean.
- AtomicIntegerArray, AtomicReferenceArray — opérations atomiques sur des tableaux.
- LongAdder, LongAccumulator — pour des compteurs à forte contention.
LongAdder et LongAccumulator
Si vous avez énormément de threads et que le AtomicInteger classique devient un « goulot d’étranglement » (tous les threads se disputent la même variable), utilisez LongAdder. Il découpe le compteur en plusieurs cellules internes et les additionne lors de la lecture de la valeur, ce qui apporte un gain en cas de forte contention.
import java.util.concurrent.atomic.LongAdder;
public class FastCounter {
private final LongAdder adder = new LongAdder();
public void increment() {
adder.increment();
}
public long getCount() {
return adder.sum();
}
}
7. Erreurs courantes avec les variables atomiques
Erreur n° 1: attendre l’atomicité d’opérations complexes.
S’il faut effectuer plusieurs actions sur une valeur, les classes atomiques ne suffiront pas — entre les étapes, un autre thread peut modifier les données. Pour les opérations composées, utilisez compareAndSet ou la synchronisation.
Erreur n° 2: ignorer la sécurité des threads des objets imbriqués.
Si un objet stocké dans une AtomicReference est classique, ses méthodes et champs ne deviennent pas thread‑safe. Seul le remplacement de la référence est atomique.
Erreur n° 3: utiliser des classes atomiques sans nécessité.
Dans du code mono‑thread, les types atomiques sont superflus et légèrement plus lents que les variables ordinaires à cause de vérifications supplémentaires.
Erreur n° 4: optimisation prématurée.
Il est parfois plus simple et plus fiable d’utiliser synchronized, surtout lorsque la logique est complexe et touche plusieurs variables à la fois. Les solutions lock‑free ne sont pas toujours justifiées.
Erreur n° 5: oublier le problème ABA.
Cas rare mais important : la valeur passe de A à B puis revient à A — compareAndSet « pense » que rien n’a changé. Pour ces scénarios, utilisez des classes spéciales comme AtomicStampedReference (ou AtomicMarkableReference).
GO TO FULL VERSION