Ein Leitfaden für ConcurrentMap

Eine Anleitung zu ConcurrentMap

1. Überblick

Maps sind natürlich einer der am weitesten verbreiteten Stile der Java-Sammlung.

Und wichtig ist, dassHashMap keine thread-sichere Implementierung ist, währendHashtable Thread-Sicherheit bietet, indem Operationen synchronisiert werden.

ObwohlHashtable threadsicher ist, ist es nicht sehr effizient. Ein anderes vollständig synchronisiertesMap,Collections.synchronizedMap, zeigt ebenfalls keine große Effizienz. Wenn wir Thread-Sicherheit mit hohem Durchsatz bei hoher Parallelität wünschen, sind diese Implementierungen nicht der richtige Weg.

Um das Problem zu lösen, werden dieJava Collections Frameworkintroduced ConcurrentMap in Java 1.5.

Die folgenden Diskussionen basieren aufJava 1.8.

2. ConcurrentMap

ConcurrentMap ist eine Erweiterung derMap-Schnittstelle. Ziel ist es, eine Struktur und Anleitung zur Lösung des Problems der Vereinbarkeit von Durchsatz und Threadsicherheit bereitzustellen.

Durch Überschreiben mehrerer Schnittstellenstandardmethoden gibtConcurrentMap Richtlinien für gültige Implementierungen an, um Thread-Sicherheit und speicherkonsistente atomare Operationen bereitzustellen.

Mehrere Standardimplementierungen werden überschrieben, wodurch die Schlüssel- / Wertunterstützung vonnulldeaktiviert wird:

  • getOrDefault

  • für jedes

  • alles ersetzen

  • computeIfAbsent

  • computeIfPresent

  • berechnen

  • verschmelzen

Die folgendenAPIs werden ebenfalls überschrieben, um die Atomizität ohne eine Standardschnittstellenimplementierung zu unterstützen:

  • putIfAbsent

  • entfernen

  • ersetzen (Schlüssel, alterWert, neuerWert)

  • ersetzen (Schlüssel, Wert)

Der Rest der Aktionen wird direkt vererbt, was im Wesentlichen mitMap übereinstimmt.

3. ConcurrentHashMap

ConcurrentHashMap ist die sofort einsatzbereite Implementierung vonConcurrentMap.

Für eine bessere Leistung besteht es aus einem Array von Knoten als Tabellen-Buckets (früher Tabellensegmente vorJava 8) unter der Haube und verwendet während der Aktualisierung hauptsächlichCAS-Operationen.

Die Tischbecher werden beim ersten Einsetzen träge initialisiert. Jeder Bucket kann unabhängig gesperrt werden, indem der allererste Knoten im Bucket gesperrt wird. Lesevorgänge werden nicht blockiert und Aktualisierungskonflikte werden minimiert.

Die Anzahl der erforderlichen Segmente hängt von der Anzahl der Threads ab, die auf die Tabelle zugreifen, sodass die Aktualisierung pro Segment die meiste Zeit nicht länger als eins dauert.

VorJava 8 war die Anzahl der erforderlichen "Segmente" relativ zur Anzahl der Threads, die auf die Tabelle zugreifen, so dass die laufende Aktualisierung pro Segment die meiste Zeit nicht mehr als eins betragen würde.

Aus diesem Grund liefern Konstruktoren im Vergleich zuHashMap das zusätzliche ArgumentconcurrencyLevel, um die Anzahl der geschätzten zu verwendenden Threads zu steuern:

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

Die beiden anderen Argumente:initialCapacity undloadFactor arbeiteten ziemlich gleichas 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. Thread-Sicherheit

ConcurrentMap garantiert Speicherkonsistenz bei Schlüssel- / Wertoperationen in einer Multithreading-Umgebung.

Aktionen in einem Thread vor dem Platzieren eines Objekts inConcurrentMap als Schlüssel oder Werthappen-before Aktionen nach dem Zugriff oder Entfernen dieses Objekts in einem anderen Thread.

Schauen wir uns zur Bestätigung einen inkonsistenten Speicherfall an:

@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;
}

Für jede parallele Aktion vonmap.computeIfPresent liefertHashMap keine konsistente Ansicht darüber, was der aktuelle ganzzahlige Wert sein sollte, was zu inkonsistenten und unerwünschten Ergebnissen führt.

FürConcurrentHashMap können wir ein konsistentes und korrektes Ergebnis erhalten:

@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. Null Schlüssel / Wert

Die meisten vonConcurrentMap bereitgestelltenAPIs erlauben keinen Schlüssel oder Wert vonnull, zum Beispiel:

@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);
}

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. Stream-Unterstützung

Java 8 bietetStream Unterstützung auch inConcurrentHashMap.

Im Gegensatz zu den meisten Stream-Methoden ermöglichen die Massenoperationen (sequentielle und parallele Operationen) die gleichzeitige sichere Änderung. ConcurrentModificationException werden nicht geworfen, was auch für die Iteratoren gilt. In Bezug auf Streams werden auch mehrereforEach*-,search- undreduce*-Methoden hinzugefügt, um umfassendere Durchquerungs- und Kartenreduzierungsvorgänge zu unterstützen.

3.4. Performance

Under the hood, ConcurrentHashMap is somewhat similar to HashMap, mit Datenzugriff und Aktualisierung basierend auf einer Hash-Tabelle (wenn auch komplexer).

Und natürlich sollten dieConcurrentHashMap in den meisten Fällen gleichzeitig eine viel bessere Leistung für das Abrufen und Aktualisieren von Daten liefern.

Schreiben wir einen schnellen Mikro-Benchmark für die Leistung vonget undput und vergleichen diesen mitHashtable undCollections.synchronizedMap, wobei beide Operationen 500.000 Mal in 4 Threads ausgeführt werden.

@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;
}

Beachten Sie, dass Mikro-Benchmarks nur ein einziges Szenario betrachten und nicht immer die Leistung der realen Welt widerspiegeln.

Auf einem OS X-System mit einem durchschnittlichen Entwicklungssystem sehen wir jedoch ein durchschnittliches Stichprobenergebnis für 100 aufeinanderfolgende Läufe (in Nanosekunden):

Hashtable: 1142.45
SynchronizedHashMap: 1273.89
ConcurrentHashMap: 230.2

In einer Multithreading-Umgebung, in der erwartet wird, dass mehrere Threads auf ein gemeinsamesMap zugreifen, ist dasConcurrentHashMap eindeutig vorzuziehen.

Wenn jedochMap nur einem einzelnen Thread zugänglich ist, kannHashMap aufgrund seiner Einfachheit und soliden Leistung eine bessere Wahl sein.

3.5. Fallstricke

Abrufvorgänge blockieren im Allgemeinen nicht inConcurrentHashMap und können sich mit Aktualisierungsvorgängen überschneiden. Für eine bessere Leistung spiegeln sie nur die Ergebnisse der zuletzt abgeschlossenen Aktualisierungsvorgänge wider, wie inofficial Javadocangegeben.

Es gibt noch einige andere Fakten zu beachten:

  • Ergebnisse von Aggregatstatusmethoden, einschließlichsize,isEmpty undcontainsValue, sind normalerweise nur dann nützlich, wenn eine Karte in anderen Threads nicht gleichzeitig aktualisiert wird:

@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());
}

Wenn gleichzeitige Aktualisierungen streng kontrolliert werden, ist der Gesamtstatus dennoch zuverlässig.

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

Beachten Sie, dass die Verwendung vonsize() vonConcurrentHashMap durchmappingCount() ersetzt werden sollte, da die letztere Methode eine Anzahl vonlong zurückgibt, obwohl sie tief im Inneren auf derselben Schätzung basieren.

  • hashCode matters: Beachten Sie, dass die Verwendung vieler Schlüssel mit genau demselbenhashCode() ein sicherer Weg ist, die Leistung einer Hash-Tabelle zu verlangsamen.

Um die Auswirkung zu verbessern, wenn die SchlüsselComparable sind, kannConcurrentHashMap die Vergleichsreihenfolge zwischen den Schlüsseln verwenden, um das Aufbrechen von Bindungen zu unterstützen. Trotzdem sollten wir vermeiden, so vielhashCode() wie möglich zu verwenden.

  • Iteratoren sind nur für die Verwendung in einem einzelnen Thread konzipiert, da sie eher eine schwache Konsistenz als eine schnelle Fehlerdurchquerung bieten und niemalsConcurrentModificationException. auslösen

  • Die Standardkapazität für die anfängliche Tabelle beträgt 16 und wird um die angegebene Parallelitätsstufe angepasst:

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

    //...
    if (initialCapacity < concurrencyLevel) {
        initialCapacity = concurrencyLevel;
    }
    //...
}
  • Vorsicht bei Neuzuordnungsfunktionen: Obwohl wir Neuzuordnungsvorgänge mit den bereitgestellten Methodencompute undmerge*ausführen können, sollten wir sie schnell, kurz und einfach halten und uns auf die aktuelle Zuordnung konzentrieren, um unerwartetes Blockieren zu vermeiden.

  • Schlüssel inConcurrentHashMap sind nicht in sortierter Reihenfolge, daher istConcurrentSkipListMap für Fälle, in denen eine Bestellung erforderlich ist, eine geeignete Wahl.

4. ConcurrentNavigableMap

In Fällen, in denen die Bestellung von Schlüsseln erforderlich ist, können wirConcurrentSkipListMap verwenden, eine gleichzeitige Version vonTreeMap.

Als Ergänzung fürConcurrentMap unterstütztConcurrentNavigableMap die vollständige Reihenfolge seiner Schlüssel (standardmäßig in aufsteigender Reihenfolge) und ist gleichzeitig navigierbar. Methoden, die Ansichten der Karte zurückgeben, werden aus Gründen der Parallelitätskompatibilität überschrieben:

  • Unterkarte

  • headMap

  • tailMap

  • Unterkarte

  • headMap

  • tailMap

  • descendingMap

Die Iteratoren und Spliteratoren derkeySet()-Ansichten werden durch eine schwache Speicherkonsistenz verbessert:

  • navigableKeySet

  • Schlüsselsatz

  • descendingKeySet

5. ConcurrentSkipListMap

Zuvor haben wir die Schnittstelle vonNavigableMapund deren ImplementierungTreeMapbehandelt. ConcurrentSkipListMap ist eine skalierbare gleichzeitige Version vonTreeMap.

In der Praxis gibt es keine gleichzeitige Implementierung des rot-schwarzen Baums in Java. Eine gleichzeitige Variante vonSkipLists wird inConcurrentSkipListMap implementiert und liefert einen erwarteten durchschnittlichen log (n) Zeitaufwand für diecontainsKey,get,put undremove Operationen und ihre Varianten.

Zusätzlich zu den Funktionen vonTreeMapwird das Einfügen, Entfernen, Aktualisieren und Zugreifen von Schlüsseln mit Thread-Sicherheit garantiert. Hier ist ein Vergleich mitTreeMap bei gleichzeitiger Navigation:

@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();
}

Eine vollständige Erläuterung der Leistungsprobleme hinter den Kulissen würde den Rahmen dieses Artikels sprengen. Die Details finden Sie inConcurrentSkipListMap’s Javadoc, das sich unterjava/util/concurrent in dersrc.zip-Datei befindet.

6. Fazit

In diesem Artikel haben wir hauptsächlich dieConcurrentMap-Schnittstelle und die Funktionen vonConcurrentHashMap vorgestellt und behandelt, wieConcurrentNavigableMap für die Schlüsselreihenfolge erforderlich sind.

Der vollständige Quellcode für alle in diesem Artikel verwendeten Beispiele istin the GitHub project.