Collections concurrentes
En Java, une collection (comme ArrayList, HashMap, etc.) est pensée pour un seul thread :
- Un thread ajoute des éléments
- Un thread lit des éléments
- Et tout fonctionne sans problème.
MAIS, dans la vraie vie :
- Les applications modernes sont multithread (ex: serveurs web, jeux vidéo, traitement d'images, etc.)
- Plusieurs threads veulent lire, écrire, parcourir en simultanée.
Si on utilise des structures classiques sans protection → erreurs, données corrompues, exceptions.
Quels problèmes arrivent sans collections concurrentes ?
ConcurrentModificationException
Exemple : un thread modifie une liste pendant qu’un autre la parcourt.
Données perdues ou incohérentes
Exemple : deux threads ajoutent en même temps → un ajout est écrasé.
Corruption mémoire
Exemple : des structures internes (comme le tableau d'un ArrayList) sont mises à jour en même temps → état illisible, parfois même plantage du programme.
Exemple concret : une liste corrompue
Deux guichets enregistrent leurs transactions dans la même ArrayList. Chaque guichet en ajoute 1000 — on s'attend à 2000 entrées au total.
List<String> transactions = new ArrayList<>();
Thread guichet1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
transactions.add("G1-retrait-" + i);
}
});
Thread guichet2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
transactions.add("G2-retrait-" + i);
}
});
guichet1.start();
guichet2.start();
guichet1.join();
guichet2.join();
System.out.println("Transactions : " + transactions.size());
// Devrait afficher 2000, mais peut afficher 1823, 1956...
// ...ou lancer une ArrayIndexOutOfBoundsException ou NullPointerException !
Pourquoi ça plante ?
ArrayList grossit en agrandissant son tableau interne. Quand les deux threads appellent add() en même temps :
Thread 1 : lit size = 500, prépare d'écrire à l'index 500
Thread 2 : lit size = 500, prépare d'écrire à l'index 500 ← même index !
Thread 1 : écrit "G1-retrait-499" à l'index 500, size = 501
Thread 2 : écrit "G2-retrait-499" à l'index 500, size = 501 ← écrase Thread 1 !
Résultat : une entrée est perdue, size est décalé, et si un agrandissement du tableau se produit pendant ce temps → exception.
Solution avec CopyOnWriteArrayList
List<String> transactions = new CopyOnWriteArrayList<>();
Thread guichet1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
transactions.add("G1-retrait-" + i);
}
});
Thread guichet2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
transactions.add("G2-retrait-" + i);
}
});
guichet1.start();
guichet2.start();
guichet1.join();
guichet2.join();
System.out.println("Transactions : " + transactions.size()); // Toujours 2000
Chaque add() crée une copie complète du tableau interne — les deux threads ne peuvent jamais écraser le travail de l'autre.
Solutions naïves pour rendre une collection thread-safe
Avant Java 5 (avant les vraies collections concurrentes), on avait deux stratégies principales :
Utiliser Collections.synchronizedXXX
Exemples :
List<String> list = Collections.synchronizedList(new ArrayList<>());
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
- Java fournit des wrappers qui synchronisent chaque accès à la collection.
- AVANTAGE : simple à utiliser.
- INCONVÉNIENT :
- Pas optimal : chaque opération est bloquante (lock global).
- Attention : lors de parcours (ex:
for-each), il faut synchroniser manuellement :
synchronized(list) {
for (String s : list) {
System.out.println(s);
}
}
Collections concurrentes modernes
-
Performance :
Verrouiller toute une collection pour chaque opération est trop coûteux. -
Sécurité :
Les collections concurrentes sont conçues pour être plus fines dans la gestion des verrous → souvent sans bloquer totalement tout. -
Simplicité :
Elles permettent de lire et écrire en parallèle sans avoir besoin de gérer nous-mêmes la synchronisation partout.
Les principales collections concurrentes en Java
| Collection classique | Collection concurrente équivalente | Description rapide |
|---|---|---|
| ArrayList | CopyOnWriteArrayList | Optimisée pour beaucoup de lectures, peu d'écritures |
| HashMap | ConcurrentHashMap | Optimisée pour accès/écritures en parallèle |
| File (LinkedList) | ConcurrentLinkedQueue | File d'attente non bloquante |
| Pile (Deque) | ConcurrentLinkedDeque | Pile/file double-entrée concurrente |
CopyOnWriteArrayList
- Chaque modification (
add,remove, etc.) copie toute la liste. - Super efficace pour des lectures fréquentes (ex: listeners, abonnés).
- Pas idéal si on modifie souvent (écriture coûteuse).
Exemple :
List<String> list = new CopyOnWriteArrayList<>();
list.add("hello");
list.add("world");
for (String s : list) {
System.out.println(s);
}
ConcurrentHashMap
- Remplace
HashMapdans les contextes multithread. - La map est découpée en segments internes → plusieurs threads peuvent écrire sur des clés différentes en même temps.
- Pas besoin de synchronisation externe.
Exemple :
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("apple", 1);
map.put("banana", 2);
System.out.println(map.get("apple"));
ConcurrentLinkedQueue / ConcurrentLinkedDeque
- Queue basée sur des algorithmes non-bloquants (compare-and-swap).
- Très rapide pour des files d'attente entre producteurs et consommateurs.
Exemple :
Queue<String> queue = new ConcurrentLinkedQueue<>();
queue.offer("one");
queue.offer("two");
System.out.println(queue.poll()); // "one"
Quand choisir quelle solution ?
| Besoin | Solution |
|---|---|
| Beaucoup de lectures, peu d'écritures | CopyOnWriteArrayList |
| Besoin de stocker des paires clé-valeur concurrentes | ConcurrentHashMap |
| File d'attente multi-producteurs / multi-consommateurs | ConcurrentLinkedQueue |
Conclusion : les principes clés à retenir
- Ne jamais utiliser directement des collections classiques sans protection dans un environnement multithread.
- Bien choisir la bonne collection concurrente selon ton usage (lecture vs écriture).
- Les collections concurrentes sont pensées pour améliorer les performances et éviter les erreurs de synchronisation manuelle.
synchronizedList / synchronizedMap vs collections concurrentes modernes
Utilise les wrappers Collections.synchronizedXXX quand :
- Tu as beaucoup d'écritures —
CopyOnWriteArrayListcopie toute la liste à chaqueadd(), ce qui devient coûteux si tu écris souvent. - Tu veux protéger une collection existante que tu ne contrôles pas (ex : une
ArrayListreçue en paramètre). - Ta logique nécessite un bloc d'opérations atomiques sur toute la collection (ex : vérifier si un élément existe puis l'ajouter) — tu peux tout envelopper dans un seul
synchronized.
Utilise CopyOnWriteArrayList / ConcurrentHashMap quand :
- Tu as beaucoup de lectures et peu d'écritures — les lectures ne bloquent jamais personne.
- Tu veux parcourir la collection en toute sécurité sans
synchronizedmanuel autour dufor. - Tu veux de meilleures performances sous forte contention —
ConcurrentHashMaplaisse plusieurs threads écrire sur des clés différentes en même temps, là oùsynchronizedMapbloque tout le monde.