ConcurrentMapのガイド

ConcurrentMapのガイド

1. 概要

Mapsは、当然、最も広く使用されているJavaコレクションのスタイルの1つです。

そして重要なことに、HashMapはスレッドセーフな実装ではありませんが、Hashtableは操作を同期することによってスレッドセーフを提供します。

Hashtableはスレッドセーフですが、あまり効率的ではありません。 別の完全に同期されたMap,Collections.synchronizedMap,も、大きな効率を示しません。 高い同時実行性の下で高いスループットでスレッドセーフが必要な場合、これらの実装は適切な方法ではありません。

この問題を解決するには、Java Collections Frameworkintroduced ConcurrentMap in Java 1.5.

以下の説明は、Java 1.8に基づいています。

2. ConcurrentMap

ConcurrentMapは、Mapインターフェースの拡張です。 スループットとスレッドセーフの調整の問題を解決するための構造とガイダンスを提供することを目的としています。

ConcurrentMapは、いくつかのインターフェイスのデフォルトメソッドをオーバーライドすることにより、スレッドセーフでメモリ整合性のあるアトミック操作を提供するための有効な実装のガイドラインを提供します。

いくつかのデフォルトの実装がオーバーライドされ、nullのキー/値のサポートが無効になります。

  • getOrDefault

  • forEach

  • replaceAll

  • computeIfAbsent

  • computeIfPresent

  • 計算する

  • マージ

次のAPIsも、デフォルトのインターフェイス実装なしで、アトミック性をサポートするためにオーバーライドされます。

  • putIfAbsent

  • 削除する

  • replace(key、oldValue、newValue)

  • replace(key、value)

残りのアクションは、基本的にMapと一致して直接継承されます。

3. ConcurrentHashMap

ConcurrentHashMapは、すぐに使用できるConcurrentMapの実装です。

パフォーマンスを向上させるために、内部ではテーブルバケット(Java 8より前のテーブルセグメントとして使用)としてノードの配列で構成され、主に更新中にCAS操作を使用します。

テーブルバケットは、最初の挿入時に遅延初期化されます。 バケットの最初のノードをロックすることにより、各バケットを個別にロックできます。 読み取り操作はブロックされず、更新の競合は最小限に抑えられます。

必要なセグメントの数は、テーブルにアクセスするスレッドの数に比例するため、セグメントごとに進行中の更新はほとんどの場合1回になります。

Java 8より前は、必要な「セグメント」の数はテーブルにアクセスするスレッドの数に比例していたため、セグメントごとに進行中の更新はほとんどの場合1つだけでした。

そのため、コンストラクターは、HashMapと比較して、使用する推定スレッド数を制御するための追加のconcurrencyLevel引数を提供します。

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

他の2つの引数:initialCapacityloadFactorはまったく同じas 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. スレッドセーフ

ConcurrentMapは、マルチスレッド環境でのキー/値操作でのメモリの一貫性を保証します。

オブジェクトをキーまたは値としてConcurrentMapに配置する前のスレッド内のアクション、または別のスレッド内のそのオブジェクトへのアクセスまたは削除後のhappen-beforeアクション。

確認するために、メモリに一貫性のないケースを見てみましょう。

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

並列の各map.computeIfPresentアクションについて、HashMapは、現在の整数値がどうあるべきかについて一貫したビューを提供せず、一貫性のない望ましくない結果につながります。

ConcurrentHashMapについては、一貫性のある正しい結果を得ることができます。

@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キー/値

ConcurrentMapによって提供されるほとんどのAPIsは、nullのキーまたは値を許可しません。次に例を示します。

@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. ストリームサポート

Java 8は、ConcurrentHashMapでもStreamのサポートを提供します。

ほとんどのストリームメソッドとは異なり、一括(順次および並列)操作では、安全に同時変更が可能です。 ConcurrentModificationExceptionはスローされません。これは、そのイテレータにも適用されます。 ストリームに関連して、いくつかのforEach*search、およびreduce*メソッドも追加され、より豊富なトラバーサルおよびmap-reduce操作をサポートします。

3.4. パフォーマンス

Under the hood, ConcurrentHashMap is somewhat similar to HashMap、データアクセスとハッシュテーブルに基づく更新(より複雑ですが)。

そしてもちろん、ConcurrentHashMapは、データの取得と更新のほとんどの同時ケースではるかに優れたパフォーマンスをもたらすはずです。

getputのパフォーマンスの簡単なマイクロベンチマークを作成し、それをHashtableCollections.synchronizedMapと比較して、4つのスレッドで両方の操作を500,000回実行してみましょう。

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

マイクロベンチマークは単一のシナリオのみを対象としており、実際のパフォーマンスを常に適切に反映しているとは限らないことに注意してください。

そうは言っても、平均的な開発システムを備えたOS Xシステムでは、100回の連続実行(ナノ秒単位)の平均サンプル結果が表示されます。

Hashtable: 1142.45
SynchronizedHashMap: 1273.89
ConcurrentHashMap: 230.2

複数のスレッドが共通のMapにアクセスすることが予想されるマルチスレッド環境では、ConcurrentHashMapが明らかに望ましいです。

ただし、Mapにアクセスできるのが単一のスレッドのみである場合は、その単純さと確かなパフォーマンスのために、HashMapの方が適しています。

3.5. 落とし穴

通常、取得操作はConcurrentHashMapでブロックされず、更新操作と重複する可能性があります。 したがって、パフォーマンスを向上させるために、official Javadocに示されているように、最後に完了した更新操作の結果のみが反映されます。

心に留めておくべき他のいくつかの事実があります:

  • sizeisEmpty、およびcontainsValueを含む集計ステータスメソッドの結果は、通常、マップが他のスレッドで同時に更新されていない場合にのみ役立ちます。

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

同時更新が厳密に制御されている場合、集計ステータスは依然として信頼できます。

これらのaggregate status methods do not guarantee the real-time accuracy, they may be adequate for monitoring or estimation purposesですが。

ConcurrentHashMapsize()の使用は、mappingCount()に置き換える必要があることに注意してください。後者の方法では、longカウントが返されますが、詳細は同じ推定に基づいています。

  • hashCode matters:まったく同じhashCode()を持つ多くのキーを使用することは、ハッシュテーブルのパフォーマンスを低下させる確実な方法であることに注意してください。

キーがComparableの場合の影響を改善するために、ConcurrentHashMapはキー間の比較順序を使用して関係を解消することができます。 それでも、同じhashCode()をできるだけ使用しないようにする必要があります。

  • イテレータは、高速失敗トラバーサルではなく弱い一貫性を提供し、ConcurrentModificationException.をスローしないため、単一スレッドでのみ使用するように設計されています。

  • デフォルトの初期テーブル容量は16であり、指定された同時実行レベルによって調整されます。

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

    //...
    if (initialCapacity < concurrencyLevel) {
        initialCapacity = concurrencyLevel;
    }
    //...
}
  • 再マッピング関数に関する注意:提供されたcomputeおよびmerge*メソッドを使用して再マッピング操作を実行できますが、それらを高速、短く、単純に保ち、予期しないブロッキングを回避するために現在のマッピングに焦点を当てる必要があります。

  • ConcurrentHashMapのキーはソートされた順序ではないため、順序付けが必要な場合は、ConcurrentSkipListMapが適切な選択です。

4. ConcurrentNavigableMap

キーの順序付けが必要な場合は、TreeMapの同時バージョンであるConcurrentSkipListMapを使用できます。

ConcurrentMapの補足として、ConcurrentNavigableMapは、キーの全順序(デフォルトでは昇順)をサポートし、同時にナビゲートできます。 マップのビューを返すメソッドは、同時実行性の互換性のためにオーバーライドされます。

  • subMap

  • headMap

  • tailMap

  • subMap

  • headMap

  • tailMap

  • 下降マップ

keySet()ビューのイテレーターとスプリッターはweak-memory-consistencyで拡張されています。

  • navigableKeySet

  • keySet

  • 下降キーセット

5. ConcurrentSkipListMap

以前、NavigableMapインターフェースとその実装TreeMapについて説明しました。 ConcurrentSkipListMapは、TreeMapのスケーラブルな同時バージョンであることがわかります。

実際には、Javaで赤黒木を同時に実装することはありません。 SkipListsの並行バリアントは、ConcurrentSkipListMapに実装され、containsKeygetput、およびputの予想平均log(n)時間コストを提供します。 t5)の操作とその変形。

TreeMapの機能に加えて、キーの挿入、削除、更新、およびアクセス操作がスレッドセーフで保証されています。 同時にナビゲートする場合のTreeMapとの比較は次のとおりです。

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

舞台裏でのパフォーマンスの懸念の完全な説明は、この記事の範囲外です。 詳細は、src.zipファイルのjava/util/concurrentの下にあるConcurrentSkipListMap’sJavadocにあります。

6. 結論

この記事では、主にConcurrentMapインターフェイスとConcurrentHashMapの機能を紹介し、キーの順序付けが必要なConcurrentNavigableMapについて説明しました。

この記事で使用されているすべての例の完全なソースコードは、in the GitHub projectにあります。