CodeGym /コース /JAVA 25 SELF /スレッドセーフなコレクション: ConcurrentHashMap など

スレッドセーフなコレクション: ConcurrentHashMap など

JAVA 25 SELF
レベル 53 , レッスン 2
使用可能

1. なぜ通常のコレクションはマルチスレッドに向かないのか

メインのアプリケーション(たとえばチャットルーム)でコレクションをどのように使っていたか思い出してみましょう:

List<String> messages = new ArrayList<>();
messages.add("こんにちは!");
messages.add("調子はどう?");

シングルスレッドのプログラムでは問題ありません。しかし、複数のスレッドが同時に同じコレクションへ追加・削除・読み取りを行うと、データ競合(race conditions)、不整合な状態、謎のバグの世界へようこそ、ということになります。

たとえば、あるスレッドが要素を追加し、別のスレッドが削除し、さらに別のスレッドが反復処理中——その結果、ConcurrentModificationException が発生したり、時には ArrayIndexOutOfBoundsException が出たり、単に「壊れた」コレクションになってしまうこともあります。

定番:

List<String> list = new ArrayList<>();
Runnable writer = () -> {
    for (int i = 0; i < 1000; i++) {
        list.add("msg-" + i);
    }
};
Runnable reader = () -> {
    for (String msg : list) {
        // ...
    }
};
// writer と reader を別スレッドで実行すると — バグの温床!

結論: 通常のコレクション(ArrayListHashMapHashSet など)は スレッドセーフではありません。追加の同期(synchronized、ロックなど)なしに複数スレッドから使用してはいけません。

2. Java にあるスレッドセーフなコレクション

Java はあなたを見捨てません。マルチスレッド用のタスクに向けて、パッケージ java.util.concurrent には複数スレッドから安全に利用できるコレクション群が用意されています。

主要なスレッドセーフコレクション:

コレクション 用途 特徴
ConcurrentHashMap
Map、キャッシュ、頻繁なアクセス 高性能、グローバルな lock なし
CopyOnWriteArrayList
List、更新まれ・読み取り頻繁 読み取りが高速、更新は低速
CopyOnWriteArraySet
Set、更新まれ・読み取り頻繁 Copy-On-Write 方式の List と同様
ConcurrentLinkedQueue
キュー、FIFO 高速・ノンブロッキング、タスクキュー向け
ConcurrentSkipListMap
ソート付き Map(NavigableMap) TreeMap のスレッドセーフな相当品
ConcurrentSkipListSet
ソート付き Set TreeSet のスレッドセーフな相当品
BlockingQueue
ブロッキングキュー(スレッドプール) インターフェース、実装が多数

重要! 昔ながらの Collections.synchronizedList(list) などは、java.util.concurrent の現代的なコレクションとまったく同じではありません。詳細は後述します。

3. ConcurrentHashMap: マルチスレッド世界の頼れる相棒

ConcurrentHashMap<K, V> は本質的に HashMap と同じですが、マルチスレッド向けに強化されています。複数スレッドが同時にデータを安全に読み書きでき、マップ全体をブロックすることはありません。

通常の HashMap でスレッドセーフなアクセスを実現しようとすると、構造全体にロックをかける必要があり、たちまち「ボトルネック」になります。1 つのスレッドが作業をしている間、他のスレッドは待たされます。

ConcurrentHashMap はこれをより賢く解決します。初期の実装ではマップがセグメントに分かれ、それぞれに個別のロックがありました。新しい実装では、個々のバケットレベルで軽量な原子操作(CAS)が使われます。これにより、同じデータに触れていない限り、スレッドは並行して問題なく処理できます。

ConcurrentHashMap の使用例

import java.util.concurrent.ConcurrentHashMap;

public class ChatStats {
    private final ConcurrentHashMap<String, Integer> userMessageCount = new ConcurrentHashMap<>();

    public void increment(String user) {
        // 値を原子的に増やす
        userMessageCount.merge(user, 1, Integer::sum);
    }

    public int getCount(String user) {
        return userMessageCount.getOrDefault(user, 0);
    }
}

重要なポイント:

  • 複数スレッドからメソッドを呼んでも正しく動作します。
  • merge は原子的です。複数スレッドが同時にカウンターを増やしても、結果は正しくなります。
  • 読み取りに追加の同期は不要です。

ConcurrentHashMapsynchronizedMap より何が良いのか?

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

synchronizedMap を使うと、読み取り・書き込み・削除のいずれの操作でもマップ全体がブロックされます。1 つのスレッドがデータを扱っている間、他のスレッドは順番待ちです。

ConcurrentHashMap はより洗練されており、同じ領域(バケット)にアクセスしない限り、複数スレッドが同時に読み取り、さらには更新も可能です。その結果、実際のマルチスレッドシステムでははるかに高いパフォーマンスを示し、場合によっては数十倍の差になることもあります。

4. CopyOnWriteArrayList と CopyOnWriteArraySet

CopyOnWriteArrayListCopyOnWriteArraySet は特殊なコレクションで、変更があるたび(add() や remove() の呼び出し時など)に配列全体の新しいコピーを作成します。その代わり、読み取りは同期なしで行え、スレッドに対して完全に安全です。

たとえば、パーティーの参加者リストを想像してください。誰かが来たり帰ったりするたびにリストを一から書き直して、全員に最新のコピーを配布します。少し無駄に見えますが、今誰がいるのかで混乱することはありません。

これが本当に便利な場面

  • 読み取りは頻繁だが、変更はまれ。
  • 典型的なケースはイベントリスナーのリストです。ハンドラーの追加はまれですが、イベントは絶えず届きます。

例: チャットのリスナー

import java.util.concurrent.CopyOnWriteArrayList;

public class ChatRoom {
    private final CopyOnWriteArrayList<ChatListener> listeners = new CopyOnWriteArrayList<>();

    public void addListener(ChatListener listener) {
        listeners.add(listener);
    }

    public void removeListener(ChatListener listener) {
        listeners.remove(listener);
    }

    public void sendMessage(String message) {
        // マルチスレッドでも安全。今まさに購読/購読解除が行われていても問題なし
        for (ChatListener listener : listeners) {
            listener.onMessage(message);
        }
    }
}

重要な特性:

  • CopyOnWriteArrayList の反復で ConcurrentModificationException が投げられることはありません。
  • 変更(add/remove)は時間・メモリのコストが高い(配列全体をコピー)。
  • 大規模かつ頻繁に更新されるコレクションには不向きです。

5. その他のスレッドセーフなコレクション

ConcurrentLinkedQueue

ConcurrentLinkedQueue は FIFO のノンブロッキングキューです。明示的なロックを使わずに、複数スレッドが同時に要素を安全に追加・取得できます。スレッド間でタスクを受け渡す用途によく使われ、高速で「詰まり」がありません。

import java.util.concurrent.ConcurrentLinkedQueue;

ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.add("task1");
String task = queue.poll(); // キューが空なら null を返す

ConcurrentSkipListMap と ConcurrentSkipListSet

  • TreeMapTreeSet のスレッドセーフな相当品です。
  • 要素は常にソートされています。
  • キーの順序を維持することが重要な場合に使用します。
import java.util.concurrent.ConcurrentSkipListMap;

ConcurrentSkipListMap<Integer, String> sortedMap = new ConcurrentSkipListMap<>();
sortedMap.put(10, "a");
sortedMap.put(2, "b");
System.out.println(sortedMap.firstEntry()); // 2=b

BlockingQueue とその実装

  • 要素が現れる/空きができるまで待機するブロッキング操作をサポートするキューのインターフェース。
  • 実装: ArrayBlockingQueueLinkedBlockingQueuePriorityBlockingQueue など。
  • スレッドプールや「プロデューサ-コンシューマ」パターンで使用されます。
import java.util.concurrent.ArrayBlockingQueue;

ArrayBlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(10);
blockingQueue.put("task"); // キューが満杯ならブロック
String t = blockingQueue.take(); // キューが空ならブロック

6. 例: コレクションでの安全な操作

例 1: メッセージ数を数えるスレッドセーフな Map

import java.util.concurrent.ConcurrentHashMap;

ConcurrentHashMap<String, Integer> messageCount = new ConcurrentHashMap<>();

// スレッド1
messageCount.put("Anna", 1);
// スレッド2
messageCount.put("Anna", messageCount.getOrDefault("Anna", 0) + 1); // 原子的ではない!

// 正解(原子的):
messageCount.merge("Anna", 1, Integer::sum);

例 2: CopyOnWriteArrayList を反復

import java.util.concurrent.CopyOnWriteArrayList;

CopyOnWriteArrayList<String> users = new CopyOnWriteArrayList<>();
users.add("Anton");
users.add("Maria");

for (String user : users) {
    System.out.println(user);
    users.remove(user); // ConcurrentModificationException は投げられない!
}
System.out.println(users); // []

例 3: スレッド間のタスクキュー

import java.util.concurrent.ConcurrentLinkedQueue;

ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();

// プロデューサースレッド
queue.add("task-1");

// コンシューマースレッド
String task = queue.poll(); // 空なら null

7. 便利な要点

いつ(なぜ)スレッドセーフなコレクションを使うか

以下のような場合、スレッドセーフなコレクションを使うのが妥当です:

  • 同じコレクションを複数のスレッドで共有する。
  • 各操作を手作業で同期したくない。
  • データ競合や一貫性エラーを避けたい。

よくあるシナリオ:

  • マルチスレッドシステムのキャッシュ(例: ユーザーセッションの保存に ConcurrentHashMap)。
  • スレッド間のタスクキュー(ConcurrentLinkedQueueBlockingQueue)。
  • イベントリスナーのリスト(CopyOnWriteArrayList)。
  • データのマルチスレッド処理(たとえば MapReduce スタイル)。

制限と落とし穴

  • 複数要素にまたがる操作は原子的ではありません。 if (!map.containsKey(k)) map.put(k, v) のような構成は原子的ではありません。putIfAbsentcomputeIfAbsentmerge を使いましょう。
  • CopyOnWriteArrayList は頻繁な更新には非効率。 大きなサイズで頻繁に add/remove があるとオーバーヘッドが雪だるま式に増えます。
  • ConcurrentHashMap の反復は「弱い」。 反復は弱一貫性のスナップショットであり、並行する変更の一部を見落とす可能性があります。
  • スレッドセーフなコレクションは同期のすべての問題を解決しません。 複数のコレクション/変数にまたがるロジックでは、外部同期(synchronized、ロック、アトミッククラス)が必要です。

8. スレッドセーフなコレクションでよくある誤り

誤り #1: スレッドセーフなコレクションに魔法を期待する。「コレクションがスレッドセーフなら何をしても同期を気にしなくてよい」。残念ながら、(確認+追加)のような複数操作から成るシーケンスは原子的ではありません。putIfAbsentcomputemerge といった専用メソッドを使いましょう。

誤り #2: 大きくて頻繁に更新されるコレクションに CopyOnWriteArrayList を使う。 リスナーリストには適していますが、10 000+ 要素で頻繁に変更がある場合、メモリと時間のコストが大きくなります。

誤り #3: 通常のコレクションでの ConcurrentModificationException。 ArrayListHashMap を反復している最中に、別スレッドがコレクションを変更すると ConcurrentModificationException を食らいます。専用のコレクションを使うか、手動でアクセスをブロックしましょう。

誤り #4: 複雑な操作の原子性を忘れる。 複数のコレクションを同時に変更したり、関連する一連の処理を行う必要がある場合、スレッドセーフなコレクションだけでは不十分です。外部同期やトランザクション的なロジックを適用してください。

誤り #5: ConcurrentHashMap の反復でのミス。 反復は弱一貫性であり、イテレータをマップ状態の「完全なスナップショット」として使うことはできません。一貫したスナップショットが必要な場合は、データを別の構造にコピーして用いましょう。

コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION