Différences entre ConcurrentHashMap, HashMap et Collections.synchronizedMap
1. Vue d’ensemble
Quand plusieurs threads accèdent en lecture et en écriture à une Map, vous avez trois choix courants : HashMap, Collections.synchronizedMap et ConcurrentHashMap. Ces trois options diffèrent nettement en termes de sécurité en environnement multithread, de mécanisme de verrouillage et de comportement lors du parcours.
Cet article explique chaque type via des exemples fonctionnels. Après lecture, vous saurez quel type choisir, comment écrire des opérations composites de façon atomique, et éviter les erreurs courantes.
2. Préparation
Les exemples utilisent Java 21. Les tests nécessitent JUnit 5 et AssertJ :
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Si votre projet est Spring Boot 3.x, spring-boot-starter-test inclut déjà ces deux bibliothèques.
3. HashMap : rapide mais pas thread-safe
HashMap n’a aucun mécanisme de synchronisation. Dans un seul thread, c’est le choix le plus rapide. Quand plusieurs threads écrivent simultanément, des données peuvent être perdues.
3.1. Perte de mises à jour lors du comptage concurrent
L’exemple suivant compte le nombre de commandes d’un client en utilisant 8 threads :
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class HashMapRaceDemo {
public static void main(String[] args) {
Map<String, Integer> orderCount = new HashMap<>();
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum));
}
} // close() attend la fin de toutes les tâches
System.out.println("Nombre de commandes: " + orderCount.get("customer-1"));
}
}
On envoie 10 000 tâches, chacune en ajoutant 1 via merge. Depuis Java 19, ExecutorService est AutoCloseable, donc try-with-resources attend la fin de toutes les tâches. Le résultat varie à chaque exécution :
Nombre de commandes: 9817
La raison en est que merge de HashMap comporte plusieurs étapes : lire l’ancienne valeur, calculer la nouvelle et la réécrire. Deux threads peuvent lire 100 en même temps, puis écrire 101, ce qui perd une addition. Pire encore, si deux threads déclenchent un redimensionnement en même temps, la structure interne peut être corrompue.
3.2. ConcurrentModificationException lors du parcours
L’itérateur de HashMap est fail-fast. Si la map est modifiée pendant son parcours en dehors de cet itérateur lui-même, l’appel suivant à next() peut lever une ConcurrentModificationException. Cette erreur peut survenir même dans un seul thread, par exemple si vous appelez map.remove() à l’intérieur d’une boucle for-each.
Remarque : Le mécanisme fail-fast est du meilleur effort (best-effort). L’absence d’exception ne signifie pas que votre code est correct.
4. Collections.synchronizedMap : un verrou pour tout
Collections.synchronizedMap encapsule n’importe quelle Map. Chaque méthode du wrapper s’exécute dans un synchronized (mutex), et par défaut le mutex est l’objet wrapper lui-même.
Map<String, Integer> orderCount = Collections.synchronizedMap(new HashMap<>());
Si vous remplacez l’initialisation dans l’exemple de la section 3.1 par la ligne suivante, le résultat est toujours correct :
Nombre de commandes: 10000
Depuis Java 8, ce wrapper surcharge aussi les méthodes par défaut comme merge, compute et putIfAbsent, donc chaque appel individuel est atomique.
4.1. Les opérations composites restent non-atomiques
Chaque méthode est synchronisée individuellement. Quand vous combinez deux appels ensemble, cette séquence n’est pas atomique :
Map<String, Customer> customers = Collections.synchronizedMap(new HashMap<>());
// INCORRECT : deux threads peuvent tous les deux voir « absent » et tous les deux faire put
if (!customers.containsKey(id)) {
customers.put(id, new Customer(id));
}
// CORRECT : maintenir le verrou pendant tout le check-then-act
synchronized (customers) {
if (!customers.containsKey(id)) {
customers.put(id, new Customer(id));
}
}
Un moyen plus simple est d’utiliser customers.putIfAbsent(id, new Customer(id)), car c’est un seul appel.
4.2. Vous devez verrouiller lors du parcours
L’itérateur du wrapper n’est pas synchronisé. Javadoc vous demande de verrouiller vous-même sur la map :
synchronized (customers) {
for (Customer c : customers.values()) {
System.out.println(c);
}
}
Si vous oubliez le bloc synchronized, vous risquez toujours une ConcurrentModificationException. Quand on verrouille, tous les autres threads voulant lire ou écrire doivent attendre la fin de la boucle.
4.3. Goulot d’étranglement de performance
Il n’y a qu’un seul verrou pour l’ensemble de la map. Deux threads lisant deux clés différentes doivent toujours faire la queue. Quand le nombre de threads augmente, la contention du verrou augmente et le débit diminue notablement.
5. ConcurrentHashMap : conçu pour le multithreading
ConcurrentHashMap (package java.util.concurrent) est conçu spécifiquement pour l’accès concurrent. Depuis Java 8, son implémentation fonctionne ainsi :
- Lecture sans verrou.
getutilise une lecturevolatile, donc elle n’est pratiquement jamais bloquée. - Écriture verrouillée par bucket. Quand un bucket est vide, il s’insère par CAS (compare-and-swap). Quand un bucket contient déjà des éléments, il ne verrouille que le nœud en tête du bucket. Les threads écrivant dans des buckets différents s’exécutent en parallèle.
- Redimensionnement collaboratif. Plusieurs threads participent ensemble à la migration des données vers une nouvelle table, au lieu de verrouiller l’ensemble de la map.
5.1. Les opérations composites sont atomiques
Les méthodes putIfAbsent, computeIfAbsent, computeIfPresent, compute et merge sont tous atomiques par clé :
import java.util.concurrent.ConcurrentHashMap;
public class InventoryService {
private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
public void addStock(String product, int quantity) {
stock.merge(product, quantity, Integer::sum);
}
public boolean reserve(String product) {
Integer result = stock.computeIfPresent(product,
(key, current) -> current > 0 ? current - 1 : current);
return result != null && result >= 0;
}
public int available(String product) {
return stock.getOrDefault(product, 0);
}
}
addStock cumule la quantité via merge. Si la clé n’existe pas, merge utilise simplement quantity comme valeur initiale. reserve diminue le stock de 1 dans computeIfPresent. Toute l’opération de lecture, vérification et écriture se produit en une étape atomique pour cette clé, donc aucun deux threads ne peuvent acquérir la dernière unité.
Pour compter à très haute fréquence, combiner avec LongAdder réduira encore plus la contention :
ConcurrentHashMap<String, LongAdder> hits = new ConcurrentHashMap<>();
hits.computeIfAbsent("/orders", k -> new LongAdder()).increment();
5.2. Itérateur faiblement cohérent
L’itérateur de ConcurrentHashMap ne lève jamais ConcurrentModificationException. Vous pouvez modifier la map pendant le parcours :
ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>(
Map.of("apple", 10, "banana", 0, "cherry", 3));
for (String product : stock.keySet()) {
if (stock.get(product) == 0) {
stock.remove(product);
}
}
System.out.println(stock);
{apple=10, cherry=3}
L’itérateur reflète l’état de la map à un moment donné depuis sa création. L’itérateur peut voir ou non les modifications qui surviennent après. Chaque élément est retourné au maximum une fois. C’est un compromis intentionnel : vous ne êtes pas bloqué et ne levez pas d’exception, mais vous n’avez pas non plus un snapshot exact.
5.3. N’accepte pas null
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("apple", null); // NullPointerException
map.put(null, 1); // NullPointerException
Avec HashMap, get(key) retournant null peut signifier deux choses : la clé n’existe pas, ou la clé pointe vers null. Dans un seul thread, vous appelez containsKey pour différencier. Dans un environnement concurrent, la map peut avoir changé entre ces deux appels. C’est pourquoi ConcurrentHashMap refuse null : ainsi get retournant null n’a qu’un seul sens : la clé n’existe pas.
5.4. size() n’est qu’une estimation
Quand d’autres threads écrivent, size() et isEmpty() reflètent seulement un état approximatif. Si la map peut dépasser Integer.MAX_VALUE éléments, utilisez mappingCount(), qui retourne un long. N’écrivez pas de logique métier basée sur size() devant être précis à un moment exact.
6. Tester le comportement concurrent
Le test suivant confirme que ConcurrentHashMap compte correctement sous charge concurrente et rejette les valeurs null :
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ConcurrentMapTest {
@Test
void givenManyThreads_whenMerge_thenNoUpdateIsLost() {
Map<String, Integer> orderCount = new ConcurrentHashMap<>();
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum)));
}
assertThat(orderCount).containsEntry("customer-1", 10_000);
}
@Test
void givenNullValue_whenPut_thenThrowNpe() {
Map<String, Integer> stock = new ConcurrentHashMap<>();
assertThatThrownBy(() -> stock.put("apple", null))
.isInstanceOf(NullPointerException.class);
}
}
Le premier test passe toujours. Si vous le changez en new HashMap<>(), il échouerait à la plupart des exécutions, avec une valeur réelle inférieure à 10 000.
Remarque : Un test multithread qui passe ne prouve pas que le code est correct. Les race conditions peuvent n’apparaître que sous charge réelle. Basez-vous sur les garanties de l’API, comme l’atomicité de
merge, plutôt que sur le simple fait que le test est vert.
7. Erreurs courantes avec ConcurrentHashMap
Combiner get puis put. Utiliser ConcurrentHashMap ne rend pas votre code automatiquement sûr :
// INCORRECT : race condition entre get et put
Integer current = stock.get("apple");
stock.put("apple", current - 1);
// CORRECT
stock.computeIfPresent("apple", (k, v) -> v - 1);
La fonction dans compute* s’exécute longtemps ou a des effets secondaires. La fonction de mapping s’exécute pendant que le bucket est verrouillé. Si vous appelez HTTP ou interrogez une base de données dans computeIfAbsent, tous les autres threads écrivant dans ce bucket seront bloqués. Gardez la fonction courte et sans effets secondaires.
Modifier la map elle-même dans compute*. Appeler map.put ou map.computeIfAbsent imbriqué dans la fonction de mapping de la même map peut lever IllegalStateException: Recursive update ou causer un deadlock. L’exemple typique est un cache récursif de type memoization Fibonacci via computeIfAbsent.
Besoin d’atomicité sur plusieurs clés. ConcurrentHashMap garantit l’atomicité seulement par clé. Par exemple, transférer du stock de l’entrepôt A au B est une opération sur deux clés. Cela nécessite un verrou séparé, ou une redéfinition de la structure des données pour la regrouper en une seule clé.
Besoin d’ordre. ConcurrentHashMap ne préserve pas l’ordre d’insertion. Si vous avez besoin de clés triées, utilisez ConcurrentSkipListMap.
8. Comparaison synthétique
Critère | HashMap | synchronizedMap | ConcurrentHashMap
---------------------|------------------|--------------------------|-----------------------
Thread-safe | Non | Oui | Oui
Mécanisme de verrou | Aucun | Un verrou global | CAS + verrou par bucket
Lecture concurrente | Non sûr | Séquentiel (bloqué) | Sans verrou
Opérations composites | Non atomique | Verrous externes | compute/merge atomique
Itérateur | Fail-fast | Fail-fast, verrous manuels | Faiblement cohérent
Clé/valeur null | Autorisé | Dépend de la map | Non autorisé
Performances multithread | — | Faible avec contention | Bon
Alors, lequel choisir ?
HashMap: la map n’est accédée que d’un seul thread, ou créée puis publiée de manière sûre et seulement lue. Pour le cas de la lecture seule,Map.copyOfest encore mieux.Collections.synchronizedMap: vous avez besoin d’encapsuler uneMapexistante commeLinkedHashMapouTreeMappour préserver l’ordre ou le comportement spécifique, charge concurrente faible, ou vous avez besoin de verrouiller l’ensemble de la map pour une longue séquence d’opérations.ConcurrentHashMap: choix par défaut pour les caches, les compteurs, les registres et toute map partagée entre plusieurs threads.
9. Conclusion
HashMap est rapide mais perd les données quand plusieurs threads écrivent. Collections.synchronizedMap est sûr grâce à un seul verrou. En contrepartie, cela crée un goulot d’étranglement, et vous devez toujours verrouiller manuellement lors du parcours ou de la combinaison d’opérations. ConcurrentHashMap permet les lectures sans verrou et l’écriture parallèle par bucket. Il fournit des opérations atomiques comme merge ou computeIfAbsent, et son itérateur ne lève pas d’exception quand la map change.
Rappelez-vous deux principes. Premièrement, une collection thread-safe ne rend pas votre séquence d’opérations automatiquement atomique, donc utilisez compute* et merge au lieu de get puis put. Deuxièmement, gardez la fonction de mapping courte et ne modifiez pas la map elle-même à l’intérieur.