1. Pourquoi les collections ordinaires ne conviennent pas au multithreading
Rappelons comment nous travaillions avec les collections dans notre application principale (par exemple, une salle de discussion) :
List<String> messages = new ArrayList<>();
messages.add("Bonjour!");
messages.add("Comment ça va ?");
Dans un programme mono-thread, tout va bien. Mais si plusieurs threads ajoutent, suppriment ou lisent des éléments simultanément depuis une même collection — bienvenue dans le monde des conditions de course (race conditions), des états incohérents et des bugs mystérieux.
Par exemple, un thread ajoute un élément, un autre le supprime, un troisième itère — et soudain on obtient ConcurrentModificationException, voire parfois même ArrayIndexOutOfBoundsException ou simplement une collection « corrompue ».
Classique du genre :
List<String> list = new ArrayList<>();
Runnable writer = () -> {
for (int i = 0; i < 1000; i++) {
list.add("msg-" + i);
}
};
Runnable reader = () -> {
for (String msg : list) {
// ...
}
};
// Lancer writer et reader dans des threads différents — bugs garantis !
Conclusion : Les collections ordinaires (ArrayList, HashMap, HashSet, etc.) ne sont PAS thread-safe. On ne peut pas les utiliser depuis plusieurs threads sans synchronisation supplémentaire (synchronized, verrous, etc.).
2. Quelles collections thread-safe existent en Java
Java ne vous laisse pas livré à vous-même. Pour les tâches multithread, le package java.util.concurrent propose toute une collection de collections (pardonnez le pléonasme) qui peuvent être utilisées en toute sécurité depuis plusieurs threads.
Collections thread-safe principales :
| Collection | Où l’utiliser | Particularités |
|---|---|---|
|
Map, cache, accès fréquent | Hautes performances, pas de verrou global |
|
List, peu de modifications, lectures fréquentes | Lectures rapides, modifications lentes |
|
Set, peu de modifications, lectures fréquentes | Semblable à la liste en Copy-On-Write |
|
File, FIFO | Rapide, non bloquant, files de tâches |
|
Map triée (NavigableMap) | Équivalent thread-safe de TreeMap |
|
Set trié | Équivalent thread-safe de TreeSet |
|
Files avec blocage (pools de threads) | Interface, de nombreuses implémentations |
Important ! Le vénérable Collections.synchronizedList(list) et consorts — ce n’est pas tout à fait la même chose que les collections modernes de java.util.concurrent. Plus de détails ci‑dessous.
3. ConcurrentHashMap : votre ami dans le monde du multithreading
ConcurrentHashMap<K, V>, c’est en substance le même HashMap, mais dopé pour le multithreading. Il permet à plusieurs threads de lire et d’écrire en toute sécurité simultanément, sans bloquer la carte entière.
Dans un HashMap classique, si l’on veut rendre l’accès thread-safe, on met un verrou sur toute la structure — et elle se transforme immédiatement en « goulot d’étranglement » : tant qu’un thread travaille, les autres attendent.
ConcurrentHashMap règle ce problème plus intelligemment. Dans les anciennes versions, la carte était divisée en segments avec des verrous séparés ; dans les implémentations récentes, on utilise des opérations atomiques légères (CAS) au niveau de buckets individuels. Grâce à cela, les threads peuvent travailler en parallèle sans problème, s’ils ne touchent pas aux mêmes données.
Exemple d’utilisation de ConcurrentHashMap
import java.util.concurrent.ConcurrentHashMap;
public class ChatStats {
private final ConcurrentHashMap<String, Integer> userMessageCount = new ConcurrentHashMap<>();
public void increment(String user) {
// Incrémenter la valeur de manière atomique
userMessageCount.merge(user, 1, Integer::sum);
}
public int getCount(String user) {
return userMessageCount.getOrDefault(user, 0);
}
}
Points importants :
- On peut appeler les méthodes depuis différents threads — tout restera correct.
- La méthode merge est atomique : si plusieurs threads augmentent le compteur en même temps, le résultat sera correct.
- Aucune synchronisation supplémentaire n’est nécessaire pour la lecture.
En quoi ConcurrentHashMap est-il meilleur que synchronizedMap ?
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
Lorsque vous utilisez synchronizedMap, toute opération — lecture, écriture ou suppression — bloque la carte entière. Pendant qu’un thread manipule les données, les autres doivent attendre leur tour.
ConcurrentHashMap est conçu de manière bien plus élégante : il permet à plusieurs threads de lire et même de modifier les données simultanément, tant qu’ils n’accèdent pas aux mêmes zones de la carte (buckets). Dans des systèmes réellement multithread, il affiche des performances nettement meilleures — la différence peut atteindre des dizaines de fois.
4. CopyOnWriteArrayList et CopyOnWriteArraySet
CopyOnWriteArrayList et CopyOnWriteArraySet sont des collections particulières qui, à chaque modification (par exemple lors de l’appel de add() ou remove()), créent une nouvelle copie de tout le tableau. En revanche, la lecture s’effectue sans aucune synchronisation et est totalement sûre pour les threads.
Imaginez que vous avez une liste d’invités à une fête. Chaque fois que quelqu’un arrive ou part, vous réécrivez la liste et vous distribuez des copies fraîches à tout le monde. Un peu coûteux, mais personne ne se trompe sur qui est présent.
Quand est-ce vraiment utile
- Les lectures sont fréquentes, les modifications sont rares.
- Cas classique : une liste d’écouteurs d’événements : les handlers s’ajoutent rarement, mais les événements arrivent en permanence.
Exemple : listeners du chat
import java.util.concurrent.CopyOnWriteArrayList;
public class ChatRoom {
private final CopyOnWriteArrayList<ChatListener> listeners = new CopyOnWriteArrayList<>();
public void addListener(ChatListener listener) {
listeners.add(listener);
}
public void removeListener(ChatListener listener) {
listeners.remove(listener);
}
public void sendMessage(String message) {
// Sans danger en multithread, même si quelqu’un s’abonne/se désabonne au même moment
for (ChatListener listener : listeners) {
listener.onMessage(message);
}
}
}
Points importants :
- L’itération sur CopyOnWriteArrayList ne lèvera jamais ConcurrentModificationException.
- Les modifications (add/remove) sont coûteuses en temps et en mémoire (le tableau entier est copié !).
- À éviter pour de grandes collections avec des modifications fréquentes.
5. Autres collections thread-safe
ConcurrentLinkedQueue
ConcurrentLinkedQueue est une file non bloquante, fonctionnant en FIFO. Elle permet à plusieurs threads d’ajouter et de récupérer des éléments en toute sécurité simultanément, sans utiliser de verrous explicites. Elle est souvent utilisée pour transférer des tâches entre threads — rapidement et sans « bouchons ».
import java.util.concurrent.ConcurrentLinkedQueue;
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.add("task1");
String task = queue.poll(); // retournera null si la file est vide
ConcurrentSkipListMap et ConcurrentSkipListSet
- Équivalents thread-safe de TreeMap et TreeSet.
- Les éléments sont toujours triés.
- Utile lorsqu’il faut préserver l’ordre des clés.
import java.util.concurrent.ConcurrentSkipListMap;
ConcurrentSkipListMap<Integer, String> sortedMap = new ConcurrentSkipListMap<>();
sortedMap.put(10, "a");
sortedMap.put(2, "b");
System.out.println(sortedMap.firstEntry()); // 2=b
BlockingQueue et ses implémentations
- Interface de file qui prend en charge des opérations de blocage (attendre qu’un emplacement apparaisse/se libère).
- Implémentations : ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue, etc.
- Utilisées dans les pools de threads, pour le pattern « producteur-consommateur ».
import java.util.concurrent.ArrayBlockingQueue;
ArrayBlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(10);
blockingQueue.put("task"); // Bloque si la file est pleine
String t = blockingQueue.take(); // Bloque si la file est vide
6. Exemples : opérations sûres avec les collections
Exemple 1 : Map thread-safe pour compter les messages
import java.util.concurrent.ConcurrentHashMap;
ConcurrentHashMap<String, Integer> messageCount = new ConcurrentHashMap<>();
// Thread 1
messageCount.put("Anna", 1);
// Thread 2
messageCount.put("Anna", messageCount.getOrDefault("Anna", 0) + 1); // Non atomique !
// Correct (atomique) :
messageCount.merge("Anna", 1, Integer::sum);
Exemple 2 : itération sur CopyOnWriteArrayList
import java.util.concurrent.CopyOnWriteArrayList;
CopyOnWriteArrayList<String> users = new CopyOnWriteArrayList<>();
users.add("Anton");
users.add("Maria");
for (String user : users) {
System.out.println(user);
users.remove(user); // Ne lèvera pas ConcurrentModificationException !
}
System.out.println(users); // []
Exemple 3 : file de tâches entre threads
import java.util.concurrent.ConcurrentLinkedQueue;
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
// Producteur
queue.add("task-1");
// Consommateur
String task = queue.poll(); // null si vide
7. Nuances utiles
Quand (et pourquoi) utiliser des collections thread-safe
L’utilisation de collections thread-safe est justifiée si :
- Une même collection est partagée entre plusieurs threads.
- On ne veut pas synchroniser manuellement chaque opération.
- Il est important d’éviter les conditions de course et les incohérences.
Scénarios typiques :
- Cache dans un système multithread (par exemple, ConcurrentHashMap pour stocker les sessions utilisateur).
- Files de tâches entre threads (ConcurrentLinkedQueue, BlockingQueue).
- Listes de listeners d’événements (CopyOnWriteArrayList).
- Traitement de données multithread (par exemple, style MapReduce).
Limitations et pièges
- Les opérations portant sur plusieurs éléments ne sont pas atomiques. Une construction du type if (!map.containsKey(k)) map.put(k, v) n’est pas atomique. Utilisez putIfAbsent, computeIfAbsent, merge.
- CopyOnWriteArrayList est inefficace en cas de modifications fréquentes. Pour des tailles importantes et des add/remove fréquents, les surcoûts explosent.
- L’itération sur ConcurrentHashMap est « faible ». Le parcours donne un instantané à cohérence faible : on peut ne pas voir une partie des modifications parallèles.
- Les collections thread-safe ne résolvent pas tous les problèmes de synchronisation. Si la logique touche simultanément plusieurs collections/variables, une synchronisation externe sera nécessaire (synchronized, locks, classes atomiques).
8. Erreurs typiques lors de l’utilisation des collections thread-safe
Erreur n° 1 : Attendre de la magie des collections thread-safe. « Puisqu’une collection est thread-safe, on peut tout faire sans se soucier de la synchronisation ». Hélas, les séquences de plusieurs opérations (vérification + ajout) ne sont pas atomiques. Utilisez des méthodes spécialisées : putIfAbsent, compute, merge.
Erreur n° 2 : Utiliser CopyOnWriteArrayList pour de grandes collections souvent modifiées. Adaptée aux listes de listeners, mais avec 10 000+ éléments et des modifications fréquentes, vous aurez de forts coûts en mémoire et en temps.
Erreur n° 3 : ConcurrentModificationException avec des collections ordinaires. Vous itérez sur ArrayList ou HashMap alors qu’un autre thread modifie la collection — vous attrapez ConcurrentModificationException. Utilisez des collections spécialisées ou verrouillez l’accès manuellement.
Erreur n° 4 : Oublier l’atomicité des opérations complexes. Si vous devez modifier plusieurs collections à la fois ou exécuter une série d’actions liées — les collections thread-safe ne suffisent pas. Appliquez une synchronisation externe ou une logique transactionnelle.
Erreur n° 5 : Problèmes d’itération sur ConcurrentHashMap. L’itération est à cohérence faible : on ne peut pas utiliser l’itérateur comme un « instantané » de l’état de la carte. Pour un instantané cohérent, copiez les données dans une structure séparée.
GO TO FULL VERSION