Collections.synchronizedList 等(同期ラッパー) — 古い同期方法
読み書きが同時に起きる環境で、既存の可変コレクションを「ひとまず安全に」使いたいときに使うのが同期ラッパー。Collections.synchronizedList / synchronizedSet / synchronizedMap は、全操作を1つのロックで守る古典的な手法です。仕組み、使い方、落とし穴、現代的な代替まで初心者向けに整理します。
同期ラッパーの基本
- 目的: 既存の非スレッドセーフなコレクションを「排他制御付きビュー」に包み、同時アクセスでも壊れないようにする。
- 対象:
List,Set,Mapなどほぼ全てのコレクションに用意あり。 - 方式: 1つのロック(モニタ)で「全操作」を直列化するため、読み書き競合が多いと性能が落ちる。
- イテレーションの注意: 反復処理(for-each, iterator)は「外側からロック」を取ってから行う必要がある。
使い方とコード例
List を同期化する
import java.util.*;
public class SyncListDemo {
public static void main(String[] args) {
List<String> raw = new ArrayList<>();
raw.add("A"); raw.add("B"); raw.add("C");
List<String> syncList = Collections.synchronizedList(raw);
// 単発操作(add/get/remove)は安全
syncList.add("D");
System.out.println(syncList.get(0)); // A
// 反復処理は外側で同期化(必須)
synchronized (syncList) {
for (String s : syncList) {
System.out.println(s);
}
}
}
}
JavaSet / Map も同様
Set<Integer> syncSet = Collections.synchronizedSet(new HashSet<>());
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
// 単発操作は安全
syncSet.add(1);
syncMap.put("A", 10);
// 反復処理(例:Mapのエントリ走査)は外側同期
synchronized (syncMap) {
for (var e : syncMap.entrySet()) {
System.out.println(e.getKey() + "=" + e.getValue());
}
}
Java反復処理の正しい書き方
- 理由: 同期ラッパーの
iterator()取得後に他スレッドが構造を変更すると、走査中に不整合や例外が起き得るため。 - パターン:
synchronized (syncList) {
Iterator<String> it = syncList.iterator();
while (it.hasNext()) {
String v = it.next();
// …処理…
}
}
Javaよくある落とし穴と回避策
- 落とし穴: 反復処理で同期しない
- 症状: 走査中の変更で例外や取りこぼし。
- 回避: 走査は常に
synchronized(コレクション)で囲む。
- 落とし穴: ロックの粒度が粗くて遅い
- 症状: 読み取り多数でも直列化され、スループット低下。
- 回避: 読み取り中心ならスナップショット(
new ArrayList<>(list))を配布、更新中心なら並行コレクションへ移行。
- 落とし穴: デッドロックやロック順序の問題
- 症状: 複数コレクションを入れ替えでロック → 順序逆転で停止。
- 回避: ロック順序を統一、可能なら1コレクションに集約。
- 落とし穴: マップの複合操作を分割
- 症状:
if (!map.containsKey(k)) map.put(k,v)のような分割操作で競合。 - 回避: 同期ブロックでまとめる、または
ConcurrentHashMapの原子メソッドを使う(putIfAbsent,computeIfAbsent)。
- 症状:
いつ使うか(適用シナリオ)
- 短期的な安全化: 既存コードをそのまま使いたい、急ぎで「壊れない」ようにしたい。
- 小規模・更新頻度が低い: 走査も少なく、ロック競合が軽い場面。
- 単純な排他: 複雑な同時更新ロジックが不要な場面。
現代的な代替(推奨)
- ConcurrentHashMap: マップ共有は原子操作が豊富で、並行性が高い。
- CopyOnWriteArrayList/Set: 読み取りが圧倒的に多く、更新が稀な場面に強い(走査はロック不要、更新はコピーコスト)。
- ConcurrentLinkedQueue/Deque: キューや双方向キューの並行性が必要な場面。
- 不変コレクションの配布: 公開用は
List.copyOfやList.ofで不変にして、更新側は内部の可変構造で管理。
テンプレート集
- 同期ラッパーの生成
List<T> syncList = Collections.synchronizedList(new ArrayList<>());
Set<T> syncSet = Collections.synchronizedSet(new HashSet<>());
Map<K,V> syncMap = Collections.synchronizedMap(new HashMap<>());
Java- 反復処理(必ず外側同期)
synchronized (syncList) {
for (T t : syncList) { /* … */ }
}
Java- スナップショットで読み取り配布
List<T> snapshot;
synchronized (syncList) {
snapshot = new ArrayList<>(syncList);
}
// スナップショットはロック不要で安全に走査できる
for (T t : snapshot) { /* … */ }
Java- 複合操作は同期ブロックで一括
synchronized (syncMap) {
if (!syncMap.containsKey(k)) {
syncMap.put(k, v);
}
}
Java例題で理解する
例題1: ログの安全な集約と配布
class SafeLogs {
private final List<String> logs = Collections.synchronizedList(new ArrayList<>());
public void record(String line) {
logs.add(line); // 単発操作は安全
}
public List<String> snapshot() {
synchronized (logs) {
return new ArrayList<>(logs); // スナップショットを返す
}
}
}
Java- 走査はスナップショットでロック不要。呼び出し側は安全に閲覧できる。
例題2: 同期 Map の反復と更新
Map<String, Integer> counts = Collections.synchronizedMap(new HashMap<>());
// 更新(単発)
counts.merge("apple", 1, Integer::sum);
// 走査(必ず同期)
synchronized (counts) {
for (var e : counts.entrySet()) {
System.out.println(e.getKey() + "=" + e.getValue());
}
}
Java- 複合更新は同期ブロックでまとめる。高頻度の集計なら ConcurrentHashMap がより良い。
まとめ
Collections.synchronizedXxxは「古典的な全体ロック」による安全化。反復処理は外側同期が必須で、読み取り中心でも直列化されるため性能は伸びにくい。- 速さや細かな原子性が必要なら、用途に応じて並行コレクション(ConcurrentHashMap など)や不変コレクションを選ぶ。短期的な安全化や小規模用途では、同期ラッパーは簡単で有効。
