Java 逆引き集 | ConcurrentHashMap (スレッド安全な Map) — マルチスレッド環境

Java Java
スポンサーリンク

ConcurrentHashMap(スレッド安全な Map) — マルチスレッド環境

並行処理で「キー→値」を共有したいなら、まずは ConcurrentHashMap。ロックを最小限に抑えた設計で、高い並行性と安定性を両立します。初心者でも安全に使えるポイントとコード例をまとめます。


特性と注意点

  • スレッド安全: 複数スレッドから同時に put/get/remove しても内部構造が壊れない。
  • 高速性: 取得(get)は基本ノンブロッキング、更新(put/remove)は必要な箇所だけ局所ロック。
  • イテレータの性質: 走査は「弱一貫性」。並行更新中でも例外なく進むが、最新が全部見えるとは限らない。
  • null 非許容: キーも値も null を入れられない(HashMap と違う点)。
  • 順序非保証: 挿入順やソート順は持たない(必要なら LinkedHashMap/TreeMap を用途に応じて選ぶ)。
  • キーの不変性: equals/hashCode が安定する不変オブジェクトをキーにするのが鉄則。

基本コード例(入門)

import java.util.concurrent.ConcurrentHashMap;

public class CHMBasics {
    public static void main(String[] args) {
        ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();

        // 追加・取得
        map.put("A001", "Apple");
        System.out.println(map.get("A001")); // Apple

        // 存在確認
        System.out.println(map.containsKey("A001")); // true

        // 削除
        map.remove("A001");
        System.out.println(map.get("A001")); // null

        // null は不可(例外になる)
        // map.put("K", null); // NG
    }
}
Java
  • ポイント: 使い方は Map と同じ感覚。null は入れない、順序は期待しない。

原子操作(同時更新でも安全なビルディングブロック)

  • putIfAbsent: ないときだけ追加(重複作成を防ぐ)
map.putIfAbsent(key, value);
Java
  • computeIfAbsent: ないときだけ計算して入れる(オンデマンド生成)
map.computeIfAbsent(key, k -> heavyCreate(k));
Java
  • compute / computeIfPresent: 値を計算で更新(読み→書きの競合をひと塊に)
map.compute(key, (k, v) -> v == null ? initValue() : update(v));
Java
  • merge: 既存値とマージ(カウンタや集合マージに便利)
map.merge(key, 1, Integer::sum); // カウンタ加算
Java
  • replace / remove(key, value): 期待値一致時だけ更新・削除(CAS 的な安全更新)
map.replace(key, oldValue, newValue);
map.remove(key, expectedValue);
Java
  • forEach(並行走査): 位置づけは弱一貫性。大規模でも安全に回せる
map.forEach(1, (k, v) -> process(k, v)); // 並列度ヒント=1(実装依存)
Java

例題1: スレッド安全なメモ化キャッシュ(computeIfAbsent)

import java.util.concurrent.ConcurrentHashMap;

public class Memoizer<K, V> {
    private final ConcurrentHashMap<K, V> cache = new ConcurrentHashMap<>();

    public V getOrCompute(K key, java.util.function.Function<K, V> fn) {
        return cache.computeIfAbsent(key, fn); // ないときだけ一度だけ計算
    }

    public static void main(String[] args) {
        Memoizer<String, String> memo = new Memoizer<>();
        String v = memo.getOrCompute("user:42", k -> loadFromDb(k)); // 同時呼び出しでも重複計算なし
        System.out.println(v);
    }

    static String loadFromDb(String k) {
        // 重い処理の代わり
        return "DATA:" + k;
    }
}
Java
  • ポイント: マルチスレッドで同じキーを同時に要求しても「一度だけ」計算される。

例題2: カウンタ(merge と LongAdder)

シンプルなカウンタ(merge)

ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
void inc(String key) {
    counts.merge(key, 1, Integer::sum); // 原子に加算
}
Java

高頻度加算に強い設計(LongAdder 併用)

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;

ConcurrentHashMap<String, LongAdder> adderMap = new ConcurrentHashMap<>();
void incFast(String key) {
    adderMap.computeIfAbsent(key, k -> new LongAdder()).increment();
}
long sum(String key) {
    LongAdder la = adderMap.get(key);
    return la == null ? 0 : la.sum();
}
Java
  • ポイント: 激しい競合下では LongAdder の分散カウンタが有利。

走査と並行更新の扱い

  • 弱一貫性イテレータ: 走査中に追加・削除があっても例外にならず進む。ただし「すべての更新が必ず見える」わけではない。
  • サイズや完了保証: size()、isEmpty() は近似的。走査で「n件ちょうど」などの強い前提を置かない。
  • スナップショットが必要なら: 適切なタイミングでコピー(例: new HashMap<>(chm))してから扱う。

実務での選定指針

  • ConcurrentHashMap を選ぶ場面:
    • 複数スレッドから頻繁に読み書きする共有辞書。
    • キャッシュセッション状態の管理。
    • 集計・カウンタなどの高並行更新。
  • 他の選択肢との比較:
    • Collections.synchronizedMap: 全操作を大域ロック → 並行性が低く遅くなりがち。
    • Immutable(不変)構造+差し替え: 読み取り中心で更新まれなら有効(CopyOnWrite 的設計)。
    • ConcurrentSkipListMap: 順序・範囲検索が必要なとき(O(log n))。

テンプレート集

  • 基本宣言
ConcurrentHashMap<Key, Value> map = new ConcurrentHashMap<>();
Java
  • 存在しないときだけ作る
Value v = map.computeIfAbsent(key, k -> create(k));
Java
  • 期待値一致で更新/削除(楽観的制御)
boolean ok = map.replace(key, oldVal, newVal);
boolean removed = map.remove(key, expectedVal);
Java
  • 安全な加算(競合に強い)
map.merge(key, 1, Integer::sum);
Java
  • ビジネスロジックをひと塊で更新
map.compute(key, (k, v) -> v == null ? start() : advance(v));
Java

よくある落とし穴と回避策

  • 落とし穴: null を入れようとして例外。
    • 回避: null を使わず、欠損は「値なし(get が null)」で表現する。
  • 落とし穴: get→加工→put の別々操作で競合。
    • 回避: compute/merge を使って「読み・計算・書き」を原子にまとめる。
  • 落とし穴: キーの equals/hashCode が可変。
    • 回避: 不変キーを使用。レコードや不変クラスで設計。
  • 落とし穴: 走査の完全性を期待(「必ず全件見る」前提)。
    • 回避: 弱一貫性を理解。必要ならスナップショットコピーして処理。
  • 落とし穴: 順序や LRU を期待。
    • 回避: CHM は順序なし。LRU は LinkedHashMap を使う等、用途に応じて構成。

まとめ

  • ConcurrentHashMap はマルチスレッドで「速くて安全」な Map。取得は基本ノンブロッキング、更新は原子操作を活用して競合を最小化する。
  • null 非許容・弱一貫性イテレータなどの性質を理解し、compute/merge/putIfAbsent で「同時更新でも壊れない」ロジックにする。
  • キーは不変、完全性が必要ならスナップショット。用途に応じて他の Map と使い分ける。

タイトルとURLをコピーしました