Aller au contenu principal

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 classiqueCollection concurrente équivalenteDescription rapide
ArrayListCopyOnWriteArrayListOptimisée pour beaucoup de lectures, peu d'écritures
HashMapConcurrentHashMapOptimisée pour accès/écritures en parallèle
File (LinkedList)ConcurrentLinkedQueueFile d'attente non bloquante
Pile (Deque)ConcurrentLinkedDequePile/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 HashMap dans 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 ?

BesoinSolution
Beaucoup de lectures, peu d'écrituresCopyOnWriteArrayList
Besoin de stocker des paires clé-valeur concurrentesConcurrentHashMap
File d'attente multi-producteurs / multi-consommateursConcurrentLinkedQueue

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'écrituresCopyOnWriteArrayList copie toute la liste à chaque add(), ce qui devient coûteux si tu écris souvent.
  • Tu veux protéger une collection existante que tu ne contrôles pas (ex : une ArrayList reç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 synchronized manuel autour du for.
  • Tu veux de meilleures performances sous forte contentionConcurrentHashMap laisse plusieurs threads écrire sur des clés différentes en même temps, là où synchronizedMap bloque tout le monde.