Un guide pour ConcurrentMap

Un guide pour ConcurrentMap

1. Vue d'ensemble

LesMaps sont naturellement l'un des styles les plus répandus de la collection Java.

Et, plus important encore,HashMap n'est pas une implémentation thread-safe, tandis queHashtable assure la sécurité des threads en synchronisant les opérations.

Même siHashtable est thread-safe, ce n'est pas très efficace. Un autreMap,Collections.synchronizedMap, entièrement synchronisé ne présente pas non plus une grande efficacité. Si nous voulons une sécurité des threads avec un débit élevé avec une forte concurrence, ces implémentations ne sont pas la solution.

Pour résoudre le problème, lesJava Collections Frameworkintroduced ConcurrentMap in Java 1.5.

Les discussions suivantes sont basées surJava 1.8.

2. ConcurrentMap

ConcurrentMap est une extension de l'interfaceMap. Son objectif est de fournir une structure et des conseils pour résoudre le problème de la réconciliation du débit avec la sécurité des threads.

En remplaçant plusieurs méthodes d'interface par défaut,ConcurrentMap donne des instructions pour les implémentations valides pour fournir des opérations atomiques de sécurité des threads et cohérentes en mémoire.

Plusieurs implémentations par défaut sont remplacées, désactivant la prise en charge de la clé / valeurnull:

  • getOrDefault

  • pour chaque

  • remplace tout

  • computeIfAbsent

  • computeIfPresent

  • calculer

  • fusionner

LesAPIsuivants sont également remplacés pour prendre en charge l'atomicité, sans implémentation d'interface par défaut:

  • putIfAbsent

  • retirer

  • replace (clé, oldValue, newValue)

  • replace (clé, valeur)

Le reste des actions est directement hérité avec fondamentalement cohérent avecMap.

3. ConcurrentHashMap

ConcurrentHashMap est l'implémentation prête à l'emploi deConcurrentMap.

Pour de meilleures performances, il se compose d'un tableau de nœuds sous forme de compartiments de table (auparavant des segments de table avantJava 8) sous le capot, et utilise principalement les opérationsCAS lors de la mise à jour.

Les compartiments de table sont initialisés lentement, lors de la première insertion. Chaque compartiment peut être verrouillé indépendamment en verrouillant le tout premier noeud dans le compartiment. Les opérations de lecture ne bloquent pas et les contentions de mise à jour sont réduites au minimum.

Le nombre de segments requis est relatif au nombre de threads accédant à la table, de sorte que la mise à jour en cours par segment ne soit pas supérieure à un la plupart du temps.

AvantJava 8, le nombre de «segments» requis était relatif au nombre de threads accédant à la table afin que la mise à jour en cours par segment ne soit pas plus d'un la plupart du temps.

C’est pourquoi les constructeurs, par rapport àHashMap, fournissent l’argumentconcurrencyLevel supplémentaire pour contrôler le nombre estimé de threads à utiliser:

public ConcurrentHashMap(
public ConcurrentHashMap(
 int initialCapacity, float loadFactor, int concurrencyLevel)

Les deux autres arguments:initialCapacity etloadFactor fonctionnaient à peu près de la même manièreas HashMap.

However, since Java 8, the constructors are only present for backward compatibility: the parameters can only affect the initial size of the map.

3.1. Fil-Sécurité

ConcurrentMap garantit la cohérence de la mémoire sur les opérations clé / valeur dans un environnement multi-threading.

Actions dans un thread avant de placer un objet dans unConcurrentMap en tant que clé ou valeurhappen-before actions après l'accès ou la suppression de cet objet dans un autre thread.

Pour confirmer, examinons un cas d'incohérence de la mémoire:

@Test
public void givenHashMap_whenSumParallel_thenError() throws Exception {
    Map map = new HashMap<>();
    List sumList = parallelSum100(map, 100);

    assertNotEquals(1, sumList
      .stream()
      .distinct()
      .count());
    long wrongResultCount = sumList
      .stream()
      .filter(num -> num != 100)
      .count();

    assertTrue(wrongResultCount > 0);
}

private List parallelSum100(Map map,
  int executionTimes) throws InterruptedException {
    List sumList = new ArrayList<>(1000);
    for (int i = 0; i < executionTimes; i++) {
        map.put("test", 0);
        ExecutorService executorService =
          Executors.newFixedThreadPool(4);
        for (int j = 0; j < 10; j++) {
            executorService.execute(() -> {
                for (int k = 0; k < 10; k++)
                    map.computeIfPresent(
                      "test",
                      (key, value) -> value + 1
                    );
            });
        }
        executorService.shutdown();
        executorService.awaitTermination(5, TimeUnit.SECONDS);
        sumList.add(map.get("test"));
    }
    return sumList;
}

Pour chaque actionmap.computeIfPresent en parallèle,HashMap ne fournit pas une vue cohérente de ce que devrait être la valeur entière actuelle, conduisant à des résultats incohérents et indésirables.

Quant àConcurrentHashMap, nous pouvons obtenir un résultat cohérent et correct:

@Test
public void givenConcurrentMap_whenSumParallel_thenCorrect()
  throws Exception {
    Map map = new ConcurrentHashMap<>();
    List sumList = parallelSum100(map, 1000);

    assertEquals(1, sumList
      .stream()
      .distinct()
      .count());
    long wrongResultCount = sumList
      .stream()
      .filter(num -> num != 100)
      .count();

    assertEquals(0, wrongResultCount);
}

3.2. Clé / valeurNull

La plupart desAPIs fournis parConcurrentMap n'autorisent pas la clé ou la valeurnull, par exemple:

@Test(expected = NullPointerException.class)
public void givenConcurrentHashMap_whenPutWithNullKey_thenThrowsNPE() {
    concurrentMap.put(null, new Object());
}

@Test(expected = NullPointerException.class)
public void givenConcurrentHashMap_whenPutNullValue_thenThrowsNPE() {
    concurrentMap.put("test", null);
}

Cependant,for compute* and merge actions, the computed value can be null, which indicates the key-value mapping is removed if present or remains absent if previously absent.

@Test
public void givenKeyPresent_whenComputeRemappingNull_thenMappingRemoved() {
    Object oldValue = new Object();
    concurrentMap.put("test", oldValue);
    concurrentMap.compute("test", (s, o) -> null);

    assertNull(concurrentMap.get("test"));
}

3.3. Prise en charge du flux

Java 8 fournit également le support deStream dans lesConcurrentHashMap.

Contrairement à la plupart des méthodes de flux, les opérations en bloc (séquentielles et parallèles) permettent une modification simultanée en toute sécurité. ConcurrentModificationException ne sera pas lancé, ce qui s'applique également à ses itérateurs. Concernant les flux, plusieurs méthodesforEach*,search etreduce* sont également ajoutées pour prendre en charge des opérations de traversée et de réduction de carte plus riches.

3.4. Performance

Under the hood, ConcurrentHashMap is somewhat similar to HashMap, avec accès aux données et mise à jour basés sur une table de hachage (bien que plus complexe).

Et bien sûr, lesConcurrentHashMap devraient donner de bien meilleures performances dans la plupart des cas simultanés pour la récupération et la mise à jour des données.

Écrivons un micro-benchmark rapide pour les performances deget etput et comparons-le àHashtable etCollections.synchronizedMap, en exécutant les deux opérations 500 000 fois en 4 threads.

@Test
public void givenMaps_whenGetPut500KTimes_thenConcurrentMapFaster()
  throws Exception {
    Map hashtable = new Hashtable<>();
    Map synchronizedHashMap =
      Collections.synchronizedMap(new HashMap<>());
    Map concurrentHashMap = new ConcurrentHashMap<>();

    long hashtableAvgRuntime = timeElapseForGetPut(hashtable);
    long syncHashMapAvgRuntime =
      timeElapseForGetPut(synchronizedHashMap);
    long concurrentHashMapAvgRuntime =
      timeElapseForGetPut(concurrentHashMap);

    assertTrue(hashtableAvgRuntime > concurrentHashMapAvgRuntime);
    assertTrue(syncHashMapAvgRuntime > concurrentHashMapAvgRuntime);
}

private long timeElapseForGetPut(Map map)
  throws InterruptedException {
    ExecutorService executorService =
      Executors.newFixedThreadPool(4);
    long startTime = System.nanoTime();
    for (int i = 0; i < 4; i++) {
        executorService.execute(() -> {
            for (int j = 0; j < 500_000; j++) {
                int value = ThreadLocalRandom
                  .current()
                  .nextInt(10000);
                String key = String.valueOf(value);
                map.put(key, value);
                map.get(key);
            }
        });
    }
    executorService.shutdown();
    executorService.awaitTermination(1, TimeUnit.MINUTES);
    return (System.nanoTime() - startTime) / 500_000;
}

Gardez à l'esprit que les micro-benchmarks ne regardent qu'un seul scénario et ne reflètent pas toujours bien les performances du monde réel.

Cela étant dit, sur un système OS X avec un système de développement moyen, nous voyons un résultat d'échantillon moyen pour 100 exécutions consécutives (en nanosecondes):

Hashtable: 1142.45
SynchronizedHashMap: 1273.89
ConcurrentHashMap: 230.2

Dans un environnement multi-thread, où plusieurs threads sont censés accéder à unMap commun, leConcurrentHashMap est clairement préférable.

Cependant, lorsque leMap n'est accessible qu'à un seul thread,HashMap peut être un meilleur choix pour sa simplicité et ses performances solides.

3.5. Les pièges

Les opérations de récupération ne se bloquent généralement pas enConcurrentHashMap et peuvent chevaucher les opérations de mise à jour. Ainsi, pour de meilleures performances, ils ne reflètent que les résultats des dernières opérations de mise à jour terminées, comme indiqué dans lesofficial Javadoc.

Il y a plusieurs autres faits à garder à l'esprit:

  • les résultats des méthodes d'état agrégé, y comprissize,isEmpty etcontainsValue, ne sont généralement utiles que lorsqu'une carte ne fait pas l'objet de mises à jour simultanées dans d'autres threads:

@Test
public void givenConcurrentMap_whenUpdatingAndGetSize_thenError()
  throws InterruptedException {
    Runnable collectMapSizes = () -> {
        for (int i = 0; i < MAX_SIZE; i++) {
            mapSizes.add(concurrentMap.size());
        }
    };
    Runnable updateMapData = () -> {
        for (int i = 0; i < MAX_SIZE; i++) {
            concurrentMap.put(String.valueOf(i), i);
        }
    };
    executorService.execute(updateMapData);
    executorService.execute(collectMapSizes);
    executorService.shutdown();
    executorService.awaitTermination(1, TimeUnit.MINUTES);

    assertNotEquals(MAX_SIZE, mapSizes.get(MAX_SIZE - 1).intValue());
    assertEquals(MAX_SIZE, concurrentMap.size());
}

Si les mises à jour simultanées sont sous contrôle strict, le statut d'agrégat reste fiable.

Bien que cesaggregate status methods do not guarantee the real-time accuracy, they may be adequate for monitoring or estimation purposes.

Notez que l'utilisation desize() deConcurrentHashMap doit être remplacée parmappingCount(), car cette dernière méthode renvoie un décompte delong, bien qu'au fond, elles soient basées sur la même estimation.

  • hashCode matters: notez que l'utilisation de plusieurs clés avec exactement les mêmeshashCode() est un moyen sûr de ralentir les performances de n'importe quelle table de hachage.

Pour améliorer l'impact lorsque les clés sontComparable,ConcurrentHashMap peut utiliser l'ordre de comparaison entre les clés pour aider à briser les égalités. Néanmoins, nous devrions éviter d'utiliser les mêmeshashCode() autant que possible.

  • les itérateurs sont uniquement conçus pour être utilisés dans un seul thread car ils fournissent une cohérence faible plutôt qu'une traversée à échec rapide, et ils ne lanceront jamaisConcurrentModificationException.

  • la capacité de la table initiale par défaut est de 16, et elle est ajustée en fonction du niveau de concurrence spécifié:

public ConcurrentHashMap(
  int initialCapacity, float loadFactor, int concurrencyLevel) {

    //...
    if (initialCapacity < concurrencyLevel) {
        initialCapacity = concurrencyLevel;
    }
    //...
}
  • attention sur les fonctions de remappage: bien que nous puissions faire des opérations de remappage avec les méthodescompute etmerge* fournies, nous devons les garder rapides, courtes et simples, et nous concentrer sur le mappage actuel pour éviter un blocage inattendu.

  • les clés deConcurrentHashMap ne sont pas triées, donc pour les cas où la commande est requise,ConcurrentSkipListMap est un choix approprié.

4. ConcurrentNavigableMap

Pour les cas où la commande des clés est requise, nous pouvons utiliserConcurrentSkipListMap, une version simultanée deTreeMap.

En complément deConcurrentMap,ConcurrentNavigableMap prend en charge l'ordre total de ses clés (par ordre croissant par défaut) et est navigable simultanément. Les méthodes qui renvoient des vues de la carte sont remplacées pour la compatibilité en accès simultané:

  • sous-carte

  • headMap

  • tailMap

  • sous-carte

  • headMap

  • tailMap

  • descendingMap

Les itérateurs et séparateurs des vues dekeySet()ont améliorés avec une cohérence de mémoire faible:

  • navigableKeySet

  • keySet

  • descendingKeySet

5. ConcurrentSkipListMap

Auparavant, nous avons couvert l'interfaceNavigableMap et son implémentationTreeMap. ConcurrentSkipListMap peut être vu comme une version simultanée évolutive deTreeMap.

En pratique, il n’existe pas d’implémentation simultanée de l’arbre rouge-noir en Java. Une variante simultanée deSkipLists est implémentée dansConcurrentSkipListMap, fournissant un coût en temps moyen log (n) prévu pour lescontainsKey,get,put etremove opérations et leurs variantes.

En plus des fonctionnalités deTreeMap, les opérations d'insertion, de suppression, de mise à jour et d'accès de clé sont garanties avec la sécurité des threads. Voici une comparaison avecTreeMap lors de la navigation simultanée:

@Test
public void givenSkipListMap_whenNavConcurrently_thenCountCorrect()
  throws InterruptedException {
    NavigableMap skipListMap
      = new ConcurrentSkipListMap<>();
    int count = countMapElementByPollingFirstEntry(skipListMap, 10000, 4);

    assertEquals(10000 * 4, count);
}

@Test
public void givenTreeMap_whenNavConcurrently_thenCountError()
  throws InterruptedException {
    NavigableMap treeMap = new TreeMap<>();
    int count = countMapElementByPollingFirstEntry(treeMap, 10000, 4);

    assertNotEquals(10000 * 4, count);
}

private int countMapElementByPollingFirstEntry(
  NavigableMap navigableMap,
  int elementCount,
  int concurrencyLevel) throws InterruptedException {

    for (int i = 0; i < elementCount * concurrencyLevel; i++) {
        navigableMap.put(i, i);
    }

    AtomicInteger counter = new AtomicInteger(0);
    ExecutorService executorService
      = Executors.newFixedThreadPool(concurrencyLevel);
    for (int j = 0; j < concurrencyLevel; j++) {
        executorService.execute(() -> {
            for (int i = 0; i < elementCount; i++) {
                if (navigableMap.pollFirstEntry() != null) {
                    counter.incrementAndGet();
                }
            }
        });
    }
    executorService.shutdown();
    executorService.awaitTermination(1, TimeUnit.MINUTES);
    return counter.get();
}

Une explication complète des problèmes de performances en coulisse dépasse le cadre de cet article. Les détails peuvent être trouvés dansConcurrentSkipListMap’s Javadoc, qui se trouve sousjava/util/concurrent dans le fichiersrc.zip.

6. Conclusion

Dans cet article, nous avons principalement présenté l'interfaceConcurrentMap et les fonctionnalités deConcurrentHashMap et couvert surConcurrentNavigableMap l'ordre de clé requis.

Le code source complet de tous les exemples utilisés dans cet article se trouve àin the GitHub project.