ConcurrentHashMapはHashMapとCollections.synchronizedMapとどう違うのか

1. 概要

複数のthreadが同じMapを読み書きする場合、おなじみの3つの選択肢があります: HashMap、Collections.synchronizedMap、ConcurrentHashMapです。これら3つは、マルチスレッド使用時の安全性、ロック方式、反復時の動作が大きく異なります。

この記事では、実行可能な例を通して各タイプを説明します。読み終わったら、どのタイプを選ぶべきか、複雑な操作をatomicに書く方法、そして一般的なミスを回避する方法がわかるようになります。

2. 準備

例ではJava 21を使用します。テスト部分ではJUnit 5と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>

プロジェクトがSpring Boot 3.xの場合、spring-boot-starter-testに両方のライブラリが含まれています。

3. HashMap: 高速だがthread-safeではない

HashMapには同期機構がありません。単一threadの場合、これは最速の選択肢です。複数のthreadが同時に書き込むと、データが失われる可能性があります。

3.1. 同時カウント時の更新喪失

以下の例は、8つのthreadを使って顧客の注文数をカウントします:

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()はすべてのtaskが完了するまで待機

        System.out.println("注文数: " + orderCount.get("customer-1"));
    }
}

10,000個のtaskを送信し、各taskがmergeで1を加算します。Java 19以降、ExecutorServiceはAutoCloseableなので、try-with-resourcesはすべてのtaskが完了するまで待機します。結果は実行するたびに変わります:

注文数: 9817

理由は、HashMapのmergeが複数のステップで構成されているからです: 古い値を読む、新しい値を計算して、書き直す。2つのthreadが100を読んで、両方とも101を書き込む可能性があり、1つの加算が失われます。さらに悪いことに、2つのthreadが同時にresizeをトリガーすると、内部構造が破損する可能性があります。

3.2. イテレーション時のConcurrentModificationException

HashMapのiteratorはfail-fastです。iteratorを通さずにmap内の反復中にmapが修正された場合、次のnext()呼び出しがConcurrentModificationExceptionをスローする可能性があります。このエラーは単一thread内でも発生します。例えば、for-eachループ内でmap.remove()を呼び出すと発生します。

注記: fail-fast機構は「最善努力」(best-effort)です。exceptionが出ないからといってあなたのコードが正しいわけではありません。

4. Collections.synchronizedMap: すべてに1つのロック

Collections.synchronizedMapは任意のMapをラップします。wrapperのすべてのメソッドはsynchronized (mutex)内で実行され、デフォルトではwrapperオブジェクト自体がmutexです。

Map<String, Integer> orderCount = Collections.synchronizedMap(new HashMap<>());

セクション3.1の例の初期化行を上記に置き換えた場合、結果は常に正しいです:

注文数: 10000

Java 8以降、このwrapperはmerge、compute、putIfAbsentなどのdefault methodもoverrideしているため、個々の呼び出しはatomicです。

4.1. 複合操作はまだatomicではない

各メソッドは個別に同期されます。2つの呼び出しを組み合わせると、そのシーケンスはもはやatomicではありません:

Map<String, Customer> customers = Collections.synchronizedMap(new HashMap<>());

// 誤り: 2つのthreadが「存在しない」を同時に見て、同時にputできる
if (!customers.containsKey(id)) {
    customers.put(id, new Customer(id));
}

// 正しい: check-then-act全体でロックを保持する
synchronized (customers) {
    if (!customers.containsKey(id)) {
        customers.put(id, new Customer(id));
    }
}

より簡単な方法はcustomers.putIfAbsent(id, new Customer(id))を使うことです。これは単一の呼び出しだからです。

4.2. イテレーション時に自分でロックする必要がある

wrapperのiteratorは同期されません。Javadocではmap自体に対して自分でロックするよう要求しています:

synchronized (customers) {
    for (Customer c : customers.values()) {
        System.out.println(c);
    }
}

synchronizedブロックを忘れると、まだConcurrentModificationExceptionが発生する可能性があります。ロック後、読み取りまたは書き込みを希望する他のthreadはすべて、ループが完了するまで待機する必要があります。

4.3. パフォーマンスのボトルネック

map全体に対して1つのロックのみがあります。異なる2つのkeyを読む2つのthreadも待機する必要があります。threadの数が増えるにつれて、ロックの競合(contention)が増加し、throughputが大きく低下します。

5. ConcurrentHashMap: マルチスレッド設計

ConcurrentHashMap(java.util.concurrentパッケージ)は同時アクセス用に設計されています。Java 8以降、実装は以下の通りです:

  • ロックなしで読み取り. getはvolatile読み取りを使用するため、ほぼブロックされません。
  • bucket単位でのロック書き込み. bucketが空の場合、CAS(compare-and-swap)を使用して挿入します。bucketに要素が既に存在する場合、そのbucketの先頭nodeに対してのみsynchronizedします。異なるbucketに書き込むthreadは並列に実行されます。
  • 協調的なresize. 複数のthreadが一緒に新しいテーブルへのデータ移行に参加し、map全体をロックする代わりに。

5.1. 複合操作のatomicity

メソッドputIfAbsent、computeIfAbsent、computeIfPresent、compute、mergeはすべてkey単位でatomicです:

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はmergeを使用して数量を累積します。keyが存在しない場合、mergeはquantityを初期値として使用します。reserveはcomputeIfPresentで在庫を1減らします。読み取り、チェック、書き込みのすべての操作は、そのkey対象に対して1つのatomicステップ内で発生するため、2つのthreadが同じ最後のユニットを取得することはありません。

非常に高い頻度でカウントするために、LongAdderと組み合わせると競合がさらに減ります:

ConcurrentHashMap<String, LongAdder> hits = new ConcurrentHashMap<>();
hits.computeIfAbsent("/orders", k -> new LongAdder()).increment();

5.2. Weakly consistentなiterator

ConcurrentHashMapのiteratorは決してConcurrentModificationExceptionをスローしません。イテレーション中にmapを変更できます:

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}

iteratorはそれが作成された時点でのmapの状態を反映します。iteratorは、その後に発生した変更を見る場合も見ない場合もあります。各要素は最大1回返されます。これは意図的なトレードオフです: ブロックされませんし、exceptionもありませんが、正確なsnapshotもありません。

5.3. nullを受け入れない

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("apple", null); // NullPointerException
map.put(null, 1);       // NullPointerException

HashMapでは、get(key)がnullを返すことは2つの意味を持つことができます: keyが存在しない、またはkeyがnullにマッピングされている。単一thread内では、containsKeyを追加で呼び出して区別できます。並行環境では、その2つの呼び出しの間にmapが変わる可能性があります。したがって、ConcurrentHashMapはnullを禁止しているため、getがnullを返すことは1つの意味だけになります: keyが存在しません。

5.4. size()は推定値のみ

他のthreadが書き込んでいる場合、size()とisEmpty()は近似状態のみを反映します。mapがInteger.MAX_VALUE要素を超える可能性がある場合は、mappingCount()を使用します。このメソッドはlongを返します。size()がある時点で絶対に正確でなければならないというビジネスロジックを書かないでください。

6. 並行動作のテスト

以下のテストは、ConcurrentHashMapが並行負荷下で正しくカウントし、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);
    }
}

最初のテストは常にpassします。new HashMap<>()に変更した場合、テストはほとんどの実行でfailし、実際の値は10,000未満になります。

注記: マルチthread testがpassしてもコードが正しいことは証明されません。Race conditionは実際の負荷でのみ表示される場合があります。testがgreen passingするのではなく、mergeのatomicityのようなAPIの保証に依存してください。

7. ConcurrentHashMapで発生しやすいエラー

getとputを組み合わせる. ConcurrentHashMapを使用しても、自動的にコードが安全になるわけではありません:

// 誤り: getとput間のrace condition
Integer current = stock.get("apple");
stock.put("apple", current - 1);

// 正しい
stock.computeIfPresent("apple", (k, v) -> v - 1);

compute*内の関数が長時間実行またはside effectを持つ. mapping関数はbucketがロックされている間に実行されます。computeIfAbsent内でHTTPを呼び出すか、データベースにクエリした場合、そのbucketに書き込もうとする他のthreadはすべてブロックされます。関数は簡潔に保ち、side effectがないようにしてください。

compute*内でmap自体を変更する. 同じmapのmapping関数内にmap.putまたはmap.computeIfAbsentを呼び出すと、IllegalStateException: Recursive updateをスローするか、deadlockを引き起こす可能性があります。典型的な例は、computeIfAbsentを使用したrecursive Fibonacci memoizeキャッシュです。

複数keyに対するatomicityが必要. ConcurrentHashMapはkey単位のatomicityのみを保証します。例えば、倉庫Aから倉庫Bへの在庫転送は、2つのkey上の操作です。専用のロックが必要か、複数のkeyを1つのkeyに統合するようにデータ設計を変更する必要があります。

順序が必要. ConcurrentHashMapはinsert順序を保持しません。keyをソート済みにする必要がある場合は、ConcurrentSkipListMapを使用します。

8. 総合比較

基準                | HashMap          | synchronizedMap          | ConcurrentHashMap
--------------------|------------------|--------------------------|--------------------------
thread-safe         | いいえ           | はい                     | はい
ロック機構         | なし             | 全map対して1つのロック   | CAS + bucketごとのロック
同時読み取り       | 安全でない       | 順序付け(ブロック中)   | ロックなし
複合操作          | atomicではない   | 外部synchronizedが必要   | compute/merge atomic
iterator            | fail-fast        | fail-fast、自分で lock   | weakly consistent
key/value null     | 許可             | ラップされたmapに従う    | 許可されない
マルチthreadパフォーマンス | —          | 高い競合時に低下         | 優良

では、どれを選ぶべきでしょうか?

  • HashMap: mapが単一threadからのみアクセスされるか、作成後に安全に公開され、読み取り専用です。読み取り専用の場合は、Map.copyOfがさらに優れています。
  • Collections.synchronizedMap: 順序または特別な動作を保持するためにLinkedHashMapまたはTreeMapのような既存のMapをラップする必要がある場合、並行負荷が低い場合、または長い操作シーケンスの全map をロックする必要がある場合。
  • ConcurrentHashMap: キャッシュ、カウンター、レジストリ、複数のthread間で共有されるすべてのmapのデフォルト選択肢。

9. 結論

HashMapは高速ですが、複数のthreadが同時に書き込むとデータが失われます。Collections.synchronizedMapは単一のロックのおかげで安全です。その見返りに、ボトルネックを作成し、イテレーション時または複数の操作を組み合わせるときに自分でロックする必要があります。ConcurrentHashMapはロックなしで読み取りを許可し、bucket単位で書き込みを並列化します。mergeやcomputeIfAbsentなどのatomic操作を提供し、mapが変更されてもexceptionをスローしません。

2つの原則を忘れないでください。第1に、thread-safeなcollectionでも操作シーケンスが自動的にatomicになるわけではないため、getとputの代わりにcompute*とmergeを使用してください。第2に、mapping関数は簡潔に保ち、map内で変更しないようにしてください。

javaconcurrencycollectionsconcurrenthashmapmultithreading