「スレッドセーフMap」は“同時に触られても壊れない辞書”
業務システムでは、
「ユーザーID→セッション情報」
「商品コード→商品情報」
「設定キー→設定値」
のように、Map を共有して使う場面が本当に多いです。
問題は、これを複数スレッドから同時に触るときです。
素の HashMap はスレッドセーフではないので、同時に put や remove をすると、
最悪「内部構造が壊れて無限ループ」なんてことも起こり得ます。
そこで必要になるのが「スレッドセーフ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() の結果を入れる。すでにあれば何もしない」
という処理を、スレッドセーフに実現できます。
ここでの重要ポイントは、
「containsKey と put を分けて書かずに、“一発でやるメソッド”を使う」
という発想です。
「なければ作る」を遅延評価で書く 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ここでの重要ポイントは三つです。
一つ目は、「getOrCreate で computeIfAbsent を使い、“セッションの二重生成”を防いでいる」ことです。
複数スレッドが同時に同じユーザーでアクセスしても、セッションは一つだけ作られます。
二つ目は、「読み取り(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 は、複数スレッドからの加算に特化したクラスです。
二つ目は、「computeIfAbsent で LongAdder の生成を一度だけにしている」ことです。
同じ URL に対して複数スレッドが同時に increment しても、LongAdder は一つだけ作られます。
このように、「Map のスレッドセーフ性」と「値オブジェクトのスレッドセーフ性」を組み合わせるのが、実務ではよくあるパターンです。
スレッドセーフMapでやってはいけない典型パターン
「get してから別の操作」を分けて書く
例えば、「値が存在する場合だけ更新したい」という処理を、こう書くのは危険です。
Value v = map.get(key);
if (v != null) {
v.update(...);
}
Javav 自体がスレッドセーフでない場合、
別スレッドも同じ v を触っていて壊れる可能性があります。
また、「存在しなければ入れる」パターンを、containsKey と put に分けて書くのも、さきほど触れた通り危険です。
スレッドセーフMapを使うときは、
「Map の中で完結する原子操作(putIfAbsent、computeIfAbsent、compute など)を優先して使う」
という意識を持ってください。
まとめ:スレッドセーフMapで身につけてほしい感覚
スレッドセーフMapは、
単に「例外を防ぐための安全装置」ではなく、
「複数スレッドからのアクセスを前提に、“状態をどこに持つか”を設計するための道具」です。
新しく書くコードでは、まず ConcurrentHashMap を第一候補にする。putIfAbsent や computeIfAbsent などの原子操作で、「チェック+更新」を一発で書く。Collections.synchronizedMap は、「既存の HashMap をとりあえず安全にしたい」ときの選択肢として理解しておく。
Map の中に閉じ込めるべき責務(キャッシュ、セッション管理、カウンタなど)を、専用クラスにまとめて設計する。
あなたのコードのどこかに、static HashMap を複数スレッドから触っていそうな箇所や、containsKey と put を分けて書いている箇所があれば、
そこを一度「ConcurrentHashMap+原子操作」に置き換えられないか眺めてみてください。
その小さな見直しが、
「並行処理を前提に、状態とコレクションを設計できるエンジニア」への、
確かな一歩になります。
