W3docs

Collections concurrentes Java

Collections thread-safe dans java.util.concurrent — ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue — et quand utiliser chacune.

HashMap, ArrayList, ArrayDeque — ce sont les collections du quotidien, et aucune d'elles n'est thread-safe. Les utiliser depuis plusieurs threads sans synchronisation externe provoque des mises à jour perdues, des invariants brisés, et la redoutable ConcurrentModificationException en pleine itération. La réponse historique était Collections.synchronizedMap(...), qui enveloppe une map classique dans un grand verrou unique. Ça fonctionne, mais sérialise chaque opération.

Le package java.util.concurrent a remplacé l'approche du verrou-enveloppeur par des collections conçues pour l'accès concurrent dès le départ : variantes à verrou fractionné, copy-on-write, et sans verrou, adaptées à différents ratios lecture/écriture. Ce chapitre est le tour d'horizon — ce que chaque classe fait de mieux et les pièges à connaître.

ConcurrentHashMap — le cheval de bataille

La collection concurrente la plus utilisée en Java. Une map à la forme d'un HashMap utilisable depuis plusieurs threads sans synchronisation externe :

ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();

counts.put("hits", 1);
counts.merge("hits", 1, Integer::sum);                // atomic add-or-increment
counts.computeIfAbsent("misses", k -> 0);
counts.computeIfPresent("hits", (k, v) -> v + 1);

Trois éléments la rendent rapide sous contention :

  1. Verrouillage fractionné. Différentes clés sont protégées par différents verrous internes, de sorte que les écritures sur des clés sans rapport ne se bloquent pas mutuellement.
  2. Lectures sans verrou. Les lectures ne prennent pas de verrou du tout (en régime stable). Un lecteur peut concurrencer un écrivain ; le résultat est l'ancienne valeur ou la nouvelle, jamais une valeur corrompue.
  3. Mises à jour composées atomiques. merge, compute, computeIfAbsent et putIfAbsent effectuent leur vérification-puis-action de façon atomique. Sans eux, le schéma non synchronisé if (!map.containsKey(k)) map.put(k, v) présente une fenêtre de compétition entre les deux appels ; les méthodes atomiques la ferment.

Utilisez ConcurrentHashMap chaque fois qu'un HashMap est touché par plus d'un thread. C'est la valeur par défaut correcte — plus rapide que Hashtable, plus rapide que synchronizedMap, et prend en charge les mises à jour composées atomiques que les autres ne supportent pas.

La seule règle : les clés null et les valeurs null ne sont pas autorisées. containsKey(k) est fiable ; map.get(k) == null est ambigu (clé absente vs valeur null). Interdire les null supprime l'ambiguïté.

ConcurrentSkipListMap — map concurrente triée

Quand vous avez besoin d'une forme de TreeMap (triée par clé) avec accès concurrent :

ConcurrentSkipListMap<Long, Event> byTimestamp = new ConcurrentSkipListMap<>();

byTimestamp.put(1700000000000L, e1);
byTimestamp.put(1700000005000L, e2);

byTimestamp.firstEntry();                              // earliest
byTimestamp.lastEntry();                               // latest
byTimestamp.subMap(start, end);                        // range query

Basée sur une skip list (une alternative probabiliste à un arbre équilibré, plus simple à rendre sans verrou). Elle supporte l'intégralité de l'API NavigableMap. Plus lente que ConcurrentHashMap pour la simple recherche de clé ; le bon choix quand vous avez besoin d'une itération ordonnée ou de requêtes de plage.

CopyOnWriteArrayList — petite liste à prédominance de lectures

CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
listeners.add(myListener);
for (Listener l : listeners) l.onEvent(e);             // never throws ConcurrentModificationException

Chaque écriture copie le tableau sous-jacent. Les lectures sont sans attente — pas de verrou, pas de synchronisation, pas de CME. Le compromis est évident : chaque add/remove/set est en O(n) car il copie l'intégralité du tableau.

C'est un mauvais compromis pour les charges d'écriture intensive. C'est un excellent compromis pour la charge de travail pour laquelle elle a été conçue :

  • Une petite liste (des dizaines, peut-être des centaines, d'éléments).
  • Les lectures sont bien plus nombreuses que les écritures.
  • L'itération est fréquente ; elle ne doit jamais lever CME.

Le cas d'utilisation typique : une liste d'écouteurs d'événements, d'entrées de configuration, ou d'abonnés enregistrés. Les lectures se produisent à chaque événement ; les écritures se produisent au démarrage ou lorsqu'un composant s'enregistre.

N'utilisez pas CopyOnWriteArrayList pour « n'importe quoi que je pourrais mettre dans un ArrayList ». Pour les collections partagées mutables qui ne sont ni petites ni majoritairement en lecture, utilisez Collections.synchronizedList autour d'un ArrayList, ou repensez la structure de données.

BlockingQueue — la file producteur/consommateur

L'abstraction la plus utile dans java.util.concurrent :

BlockingQueue<Task> queue = new ArrayBlockingQueue<>(1024);

queue.put(task);                                       // blocks if full
queue.offer(task, 100, TimeUnit.MILLISECONDS);         // blocks up to deadline
queue.add(task);                                       // throws if full

Task t = queue.take();                                 // blocks if empty
Task t2 = queue.poll(100, TimeUnit.MILLISECONDS);      // blocks up to deadline
Task t3 = queue.poll();                                // returns null if empty

put et take sont les opérations bloquantes : elles attendent que la file ne soit plus pleine / non vide. C'est toute l'épine dorsale du framework d'exécuteurs — chaque ThreadPoolExecutor contient en interne une BlockingQueue de tâches en attente ; les workers effectuent un take dessus ; le execute public y effectue un put.

Implémentations courantes :

ClasseBornée ?Quand utiliser
ArrayBlockingQueue(cap)Oui — capacité fixeBuffer de taille fixe ; contre-pression sur le producteur
LinkedBlockingQueue()Non (ou plafonnée)File généraliste à haut débit
SynchronousQueue0 — remise directeChaque put attend un take ; transfert thread à thread
PriorityBlockingQueueNonTâches ordonnées par priorité (pas par insertion)
DelayQueueNonChaque élément a un délai ; pris seulement à l'expiration du délai

ArrayBlockingQueue est le défaut en production — il plafonne le travail en vol, ce qui est essentiel pour la contre-pression. LinkedBlockingQueue sans plafond est le piège derrière Executors.newFixedThreadPool (file non bornée → mémoire non bornée).

ConcurrentLinkedQueue et ConcurrentLinkedDeque — les variantes non bornées sans verrou

ConcurrentLinkedQueue<Event> events = new ConcurrentLinkedQueue<>();
events.add(e);
Event e = events.poll();                               // null if empty; doesn't block

Non bloquantes, sans verrou, non bornées. poll renvoie null au lieu de bloquer ; il n'y a pas de take. Idéales quand :

  • Vous voulez un débit élevé.
  • Vous pouvez tolérer que « file vide » soit un retour rapide plutôt qu'une attente.
  • Vous n'avez pas besoin de contre-pression.

Ce ne sont pas des BlockingQueue — choisissez-les quand vous ne souhaitez vraiment pas la sémantique bloquante.

Itération : cohérence faible

Un itérateur de HashMap lève ConcurrentModificationException si la map change pendant l'itération. Les collections concurrentes font quelque chose de différent : leurs itérateurs sont faiblement cohérents. Cela signifie :

  • Ils ne lèveront pas ConcurrentModificationException même si d'autres threads modifient la collection.
  • Ils sont garantis de voir chaque élément présent au moment de la création de l'itérateur.
  • Ils peuvent ou non refléter les modifications effectuées après la création de l'itérateur.

C'est parfait pour la plupart des usages — un itérateur instantané est exactement ce que le code concurrent veut. Le compromis : size() est aussi « faiblement cohérent » — pour ConcurrentHashMap, c'est un comptage approximatif, pas une valeur d'instantané garantie. Si vous traitez size() comme une valeur de référence, vous utilisez mal l'API.

Quand choisir quoi

Un arbre de décision approximatif :

  • Magasin clé-valeurConcurrentHashMap (par défaut), ConcurrentSkipListMap (besoin de tri/plages).
  • Liste de listeners à prédominance de lecturesCopyOnWriteArrayList.
  • File de tâches producteur–consommateurArrayBlockingQueue (bornée), LinkedBlockingQueue (pas besoin de plafond), SynchronousQueue (remise directe).
  • File à priorité entre threadsPriorityBlockingQueue.
  • File à délai pour planifier plus tardDelayQueue.
  • Non bloquant sans verrou à haut débitConcurrentLinkedQueue / ConcurrentLinkedDeque.
  • EnsembleConcurrentHashMap.newKeySet(), CopyOnWriteArraySet, ConcurrentSkipListSet.

Chaque fois qu'une collection ordinaire est touchée par plus d'un thread, choisissez une collection concurrente ou enveloppez-la avec Collections.synchronizedX — ne vous contentez pas d'espérer que ça fonctionne.

Exemple concret : chaque collection faisant son travail

Le programme ci-dessous illustre quatre collections concurrentes sous une charge de travail partagée — un ConcurrentHashMap qui compte les événements, un CopyOnWriteArrayList de listeners, un ArrayBlockingQueue pour le modèle producteur/consommateur, et un ConcurrentLinkedQueue pour l'ajout sans verrou.

java— editable, runs on the server

Ce qu'il faut retenir de l'exécution :

  • La section 1's ConcurrentHashMap.merge a produit le comptage exact attendu de 40 000. La fonction de fusion (Integer::sum) s'est exécutée de façon atomique par clé, donc deux threads incrémentant la même clé n'ont jamais perdu une mise à jour — la mise à jour composée atomique est tout l'intérêt. Avec un HashMap simple + put, vous obtiendriez une fraction de la valeur attendue et probablement aussi un état interne corrompu.
  • L'itérateur de la section 2's CopyOnWriteArrayList a vu [a, b, c] (l'instantané au moment de la création de l'itérateur). Les écritures qui ont ajouté d et e pendant l'itération n'ont pas levé ConcurrentModificationException et n'ont pas été vues par l'itérateur en vol. La liste finale contenait les cinq éléments — les écritures ont bien eu lieu, elles étaient simplement invisibles pour l'itérateur déjà démarré.
  • L'ArrayBlockingQueue de la section 3 avec une capacité de 4 a forcé le producteur à bloquer sur put quand la file était pleine. La sortie montrait la file se remplissant à 4, puis le producteur se mettant en pause pendant que le consommateur vidait, puis le producteur reprenant. C'est la contre-pression assurée par la structure de données : le producteur ne peut pas aller plus vite que le consommateur, sans code de coordination.
  • Le ConcurrentLinkedQueue de la section 4 a accepté des écritures depuis quatre threads sans blocage ni contention de verrou. Le comptage final drainé correspondait exactement au comptage ajouté — chaque élément écrit a été lu avec succès. Le coût : pas de take() pour attendre une file vide ; poll() renvoie null et vous devez gérer cela vous-même.
  • Tout au long, les collections concurrentes n'ont jamais levé ConcurrentModificationException. Cette exception est une caractéristique des collections non concurrentes — c'est la façon de la JVM de dire « vous avez cassé ceci ». Les collections concurrentes sont conçues pour être modifiées depuis plusieurs threads, donc elles n'ont pas besoin de ce signal.

Et ensuite

Le prochain chapitre, Java Virtual Threads, couvre la fonctionnalité Java 21 qui change votre façon de penser au nombre de threads — des threads légers planifiés par la JVM qui rendent les I/O bloquantes de nouveau économiques.

Pratique

Pratique
Vous avez besoin d'une `Map` thread-safe modifiée par de nombreux threads qui supporte l'incrémentation atomique d'un compteur sous une clé. Quel est le bon choix ?
Vous avez besoin d'une `Map` thread-safe modifiée par de nombreux threads qui supporte l'incrémentation atomique d'un compteur sous une clé. Quel est le bon choix ?
Was this page helpful?