Java Tips | コレクション:スレッドセーフMap

Java Java
スポンサーリンク

「スレッドセーフMap」は“同時に触られても壊れない辞書”

業務システムでは、
「ユーザーID→セッション情報」
「商品コード→商品情報」
「設定キー→設定値」
のように、Map を共有して使う場面が本当に多いです。

問題は、これを複数スレッドから同時に触るときです。
素の HashMap はスレッドセーフではないので、同時に putremove をすると、
最悪「内部構造が壊れて無限ループ」なんてことも起こり得ます。

そこで必要になるのが「スレッドセーフMap」です。
今日は、実務でよく使う代表パターンを、初心者向けにかみ砕いて整理します。


まず押さえるべき主役:ConcurrentHashMap

「とりあえずこれを第一候補にする」でいい

スレッドセーフMapの主役は、なんといっても ConcurrentHashMap です。
業務で「スレッドセーフな Map が欲しい」と思ったら、まずこれを思い出してほしいです。

基本的な使い方は HashMap とほぼ同じです。

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentMapBasic {

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

        map.put("A", "Apple");
        map.put("B", "Banana");

        String v = map.get("A");
        System.out.println(v); // Apple
    }
}
Java

ここでの重要ポイントは、「複数スレッドから同時に get / put / remove しても壊れない」ということです。
内部で細かくロックや分割をしてくれているので、
Collections.synchronizedMap(new HashMap<>()) よりもスケールしやすく、実務ではほぼこちらが使われます。


ConcurrentHashMap を使うときに絶対覚えてほしい「原子操作」

「存在しなければ入れる」を安全に書く

スレッドセーフMapで一番やりがちなバグが、
「チェックと更新を別々に書いてしまう」ことです。

例えば、こう書くと危険です。

if (!map.containsKey(key)) {
    map.put(key, createValue());
}
Java

二つのスレッドが同時にこのコードを通ると、
両方とも containsKey で「ない」と判断し、
両方とも put してしまう可能性があります。

これを避けるために、ConcurrentHashMap には「原子操作」が用意されています。

代表的なのが putIfAbsent です。

map.putIfAbsent(key, createValue());
Java

これ一行で、
「そのキーがまだなければ createValue() の結果を入れる。すでにあれば何もしない」
という処理を、スレッドセーフに実現できます。

ここでの重要ポイントは、
containsKeyput を分けて書かずに、“一発でやるメソッド”を使う」
という発想です。


「なければ作る」を遅延評価で書く computeIfAbsent

putIfAbsent は便利ですが、createValue() が重い処理だと、
「結局使われなかったのに無駄に作ってしまう」ことがあります。

そこでよく使われるのが computeIfAbsent です。

Value v = map.computeIfAbsent(key, k -> createValue(k));
Java

このメソッドは、
「キーが存在しなければ、ラムダを呼んで値を作り、それを入れて返す」
「キーが存在すれば、その値をそのまま返す」
という動きを、スレッドセーフにやってくれます。

ここで深掘りしたいポイントは二つです。

一つ目は、「値の生成が“必要になったときだけ”行われる」ということです。
computeIfAbsent は、キーがすでに存在する場合、ラムダを呼びません。

二つ目は、「複数スレッドが同時に同じキーで computeIfAbsent を呼んでも、値の生成は一度だけになる」ということです。
内部でちゃんと同期してくれているので、「同じものを二重に作る」事故を防げます。

キャッシュや「ID→オブジェクト」のマップを作るときに、
computeIfAbsent は本当に頻出します。


Collections.synchronizedMap との違いと使いどころ

「全部に大きなロック」か「細かく制御」か

Collections.synchronizedMap もスレッドセーフMapを作る手段の一つです。

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

Map<String, String> map =
        Collections.synchronizedMap(new HashMap<>());
Java

これは「すべての操作に対して一つのロックを使う」イメージです。
複数スレッドが同時に get しても、順番待ちになります。

一方 ConcurrentHashMap は、内部を分割していたり、読み取りをロックなしで行えたりと、
より細かく制御しているので、読み取りが多い場面で特に強いです。

初心者向けの目安としては、
「新しく書くコードなら、基本は ConcurrentHashMap を選ぶ」でほぼ問題ありません。
synchronizedMap は、「既存の HashMap をとりあえず安全にしたい」ときの選択肢、くらいの位置づけで考えておくとよいです。


具体例1:ユーザーID→セッション情報のスレッドセーフMap

複数スレッドからログイン・アクセスされるケース

典型的な例として、「ユーザーIDからセッション情報を引く Map」を考えます。

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class SessionStore {

    private final Map<String, Session> sessions = new ConcurrentHashMap<>();

    public Session getOrCreate(String userId) {
        return sessions.computeIfAbsent(userId, id -> createNewSession(id));
    }

    public Session get(String userId) {
        return sessions.get(userId);
    }

    public void remove(String userId) {
        sessions.remove(userId);
    }

    private Session createNewSession(String userId) {
        // 新しいセッションを作る処理
        return new Session(userId);
    }
}
Java

ここでの重要ポイントは三つです。

一つ目は、「getOrCreatecomputeIfAbsent を使い、“セッションの二重生成”を防いでいる」ことです。
複数スレッドが同時に同じユーザーでアクセスしても、セッションは一つだけ作られます。

二つ目は、「読み取り(get)はロックなしで高速に行われる」ということです。
アクセスが多いシステムでは、この差が効いてきます。

三つ目は、「Map のスレッドセーフ性を SessionStore の中に閉じ込めている」ことです。
外側のコードは「スレッドセーフかどうか」を意識せずに getOrCreate を呼べます。


具体例2:カウンタMapを安全にインクリメントする

「キーごとに回数を数える」処理

例えば、「URLごとのアクセス回数」を数える Map を考えます。

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

public class AccessCounter {

    private final Map<String, LongAdder> counters = new ConcurrentHashMap<>();

    public void increment(String url) {
        counters.computeIfAbsent(url, k -> new LongAdder())
                .increment();
    }

    public long getCount(String url) {
        LongAdder adder = counters.get(url);
        return adder == null ? 0L : adder.sum();
    }
}
Java

ここでの重要ポイントは二つです。

一つ目は、「値として LongAdder を使うことで、“高頻度なインクリメント”にも強くしている」ことです。
LongAdder は、複数スレッドからの加算に特化したクラスです。

二つ目は、「computeIfAbsentLongAdder の生成を一度だけにしている」ことです。
同じ URL に対して複数スレッドが同時に increment しても、LongAdder は一つだけ作られます。

このように、「Map のスレッドセーフ性」と「値オブジェクトのスレッドセーフ性」を組み合わせるのが、実務ではよくあるパターンです。


スレッドセーフMapでやってはいけない典型パターン

「get してから別の操作」を分けて書く

例えば、「値が存在する場合だけ更新したい」という処理を、こう書くのは危険です。

Value v = map.get(key);
if (v != null) {
    v.update(...);
}
Java

v 自体がスレッドセーフでない場合、
別スレッドも同じ v を触っていて壊れる可能性があります。

また、「存在しなければ入れる」パターンを、
containsKeyput に分けて書くのも、さきほど触れた通り危険です。

スレッドセーフMapを使うときは、
「Map の中で完結する原子操作(putIfAbsentcomputeIfAbsentcompute など)を優先して使う」
という意識を持ってください。


まとめ:スレッドセーフMapで身につけてほしい感覚

スレッドセーフMapは、
単に「例外を防ぐための安全装置」ではなく、
「複数スレッドからのアクセスを前提に、“状態をどこに持つか”を設計するための道具」です。

新しく書くコードでは、まず ConcurrentHashMap を第一候補にする。
putIfAbsentcomputeIfAbsent などの原子操作で、「チェック+更新」を一発で書く。
Collections.synchronizedMap は、「既存の HashMap をとりあえず安全にしたい」ときの選択肢として理解しておく。
Map の中に閉じ込めるべき責務(キャッシュ、セッション管理、カウンタなど)を、専用クラスにまとめて設計する。

あなたのコードのどこかに、
static HashMap を複数スレッドから触っていそうな箇所や、
containsKeyput を分けて書いている箇所があれば、
そこを一度「ConcurrentHashMap+原子操作」に置き換えられないか眺めてみてください。

その小さな見直しが、
「並行処理を前提に、状態とコレクションを設計できるエンジニア」への、
確かな一歩になります。

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