0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

オブジェクト指向って何?RPGで理解する超入門 番外編(スレッドセーフなコレクション)

0
Posted at

titlej.png

📚 この回で学ぶこと

学習時間: 約45分
難易度: ★★★★☆(4/5)
前提知識: HashMap/ArrayList の理解

学習目標

  1. マルチスレッドの基礎を理解する
  2. Race Condition(競合状態) の危険性を知る
  3. スレッドセーフなコレクション の使い方をマスターする
  4. 実務での使い分けができるようになる

マルチスレッド、スレッドセーフは聞きなれない用語かもしれませんが
実務では考慮しないとシステムが壊れる場合があります
検証結果を視覚で得るため、絵文字多めでまとめてあります

なぜスレッドセーフが必要なのか?

問題のシナリオ:オンラインRPGのアイテム管理

// 複数のプレイヤーが同時にアイテムを取得するシステム
public class ItemManager {
    private Map<String, Integer> inventory = new HashMap<>();
    
    public void addItem(String itemName, int count) {
        int current = inventory.getOrDefault(itemName, 0);
        // ⚠️ ここで他のスレッドが同時にアクセスすると...
        inventory.put(itemName, current + count);
    }
}

問題点:

  • プレイヤーA と プレイヤーB が同時に「ポーション」を取得
  • どちらも現在数: 10 を読み取る
  • A が 1個追加 → 11個
  • B が 1個追加 → 11個(本来は12個のはず!)
  • データの不整合が発生

目次

1. マルチスレッドの基礎

1.1 スレッドとは?

// スレッドの基本
public class SimpleThreadExample {
    public static void main(String[] args) {
        // メインスレッド
        System.out.println("メインスレッド開始");
        
        // 新しいスレッドを作成
        Thread worker = new Thread(() -> {
            System.out.println("ワーカースレッド実行中");
        });
        worker.start();
        
        System.out.println("メインスレッド終了");
    }
}

実行結果:

メインスレッド開始
メインスレッド終了
ワーカースレッド実行中  ← 順序が保証されない!

1.2 Race Condition(競合状態)とは?

race_condition_diagram.png

問題のシナリオ: 2つのスレッドが同時に同じデータにアクセスすると、データの不整合が発生します。上の図は、タイムライン形式で2人のプレイヤーが同時にアイテムを取得した際に、1個が失われる様子を視覚化しています。

RaceConditionDemo.java
public class RaceConditionDemo {
    private int counter = 0;
    
    public void increment() {
        // この3つの操作は「アトミック」ではない
        int temp = counter;      // 1. 読み取り
        temp = temp + 1;         // 2. 計算
        counter = temp;          // 3. 書き込み
    }
    
    public static void main(String[] args) throws InterruptedException {
        RaceConditionDemo demo = new RaceConditionDemo();
        
        // 2つのスレッドが同時に10000回ずつインクリメント
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                demo.increment();
            }
        });
        
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                demo.increment();
            }
        });
        
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        
        System.out.println("期待値: 20000");
        System.out.println("実際の値: " + demo.counter); // 15000くらい?
    }
}

実行結果

期待値: 20000
実際の値: 15133

なぜ20000にならない?

  • スレッドAとBが同時に counter を読み取る
  • 両方が同じ値(例: 100)を読む
  • 両方が 101 を書き込む
  • 1回分のインクリメントが失われる

2. スレッドセーフなコレクションの種類

2.1 ConcurrentHashMap

特徴:

  • HashMap のスレッドセーフ版
  • 複数スレッドが同時に読み書き可能
  • ロックを細かく分割(セグメント)して高速化

concurrent_vs_regular_comparison.png

上図は、HashMap(左・赤背景・混乱)と ConcurrentHashMap(右・緑背景・整然)の比較です。下部の性能比較グラフと推奨表で、実務での選択基準が一目でわかります。

実践例:オンラインRPGのアイテム管理

デモ実行

ThreadSafeDemo.java
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;

public class ThreadSafeDemo {
	
	// テスト設定
	private static final int THREAD_COUNT = 10;      // スレッド数
	private static final int OPERATIONS_PER_THREAD = 1000;  // 各スレッドの操作回数
	private static final int EXPECTED_TOTAL = THREAD_COUNT * OPERATIONS_PER_THREAD;

	/**
	 * HashMap(非スレッドセーフ)でテスト
	 */
	public static void testHashMap() throws InterruptedException {
		Map<String, Integer> unsafeMap = new HashMap<>();
		unsafeMap.put("ポーション", 0);

		CountDownLatch startLatch = new CountDownLatch(1);
		CountDownLatch endLatch = new CountDownLatch(THREAD_COUNT);

		// 複数スレッドを同時に起動
		for (int i = 0; i < THREAD_COUNT; i++) {
			new Thread(() -> {
				try {
					startLatch.await();  // 全スレッドが準備完了するまで待機

					for (int j = 0; j < OPERATIONS_PER_THREAD; j++) {
						// ⚠️ この操作はアトミックではない
						Integer current = unsafeMap.get("ポーション"); // 1. 読み取り
						unsafeMap.put("ポーション", current + 1);      // 2. 書き込み
                        // ⚠️ 1と2の間に他スレッドが割り込む可能性
					}
				} catch (Exception e) {
					e.printStackTrace();
				} finally {
					endLatch.countDown();
				}
			}).start();
		}

		// 全スレッドを同時にスタート
		startLatch.countDown();

		// 全スレッドの完了を待つ
		endLatch.await();

		int result = unsafeMap.get("ポーション");
		System.out.println("❌ HashMap(非スレッドセーフ):");
		System.out.println("   期待値: " + EXPECTED_TOTAL);
		System.out.println("   実際の値: " + result);
		System.out.println("   差分: " + (EXPECTED_TOTAL - result) + " 個が失われた ");
	}

	/**
	 * ConcurrentHashMap(スレッドセーフ)でテスト
	 */
	public static void testConcurrentHashMap() throws InterruptedException {
		ConcurrentHashMap<String, Integer> safeMap = new ConcurrentHashMap<>();
		safeMap.put("ポーション", 0);

		CountDownLatch startLatch = new CountDownLatch(1);
		CountDownLatch endLatch = new CountDownLatch(THREAD_COUNT);

		// 複数スレッドを同時に起動
		for (int i = 0; i < THREAD_COUNT; i++) {
			new Thread(() -> {
				try {
					startLatch.await();  // 全スレッドが準備完了するまで待機

					for (int j = 0; j < OPERATIONS_PER_THREAD; j++) {
						// ✅ merge()はアトミックな操作
						safeMap.merge("ポーション", 1, Integer::sum);
                        // ✅ 読み取り→計算→書き込みが1つの操作として実行
					}
				} catch (Exception e) {
					e.printStackTrace();
				} finally {
					endLatch.countDown();
				}
			}).start();
		}

		// 全スレッドを同時にスタート
		startLatch.countDown();

		// 全スレッドの完了を待つ
		endLatch.await();

		int result = safeMap.get("ポーション");
		System.out.println("\n✅ ConcurrentHashMap(スレッドセーフ):");
		System.out.println("   期待値: " + EXPECTED_TOTAL);
		System.out.println("   実際の値: " + result);
		if (result == EXPECTED_TOTAL) {
			System.out.println("   結果: 完璧!データ破損なし");
		} else {
			System.out.println("   差分: " + (EXPECTED_TOTAL - result) + " 個が失われた");
		}
	}

	public static void main(String[] args) throws InterruptedException {
		System.out.println("=".repeat(60));
		System.out.println("ConcurrentHashMap スレッドセーフ性能テスト");
		System.out.println("=".repeat(60));
		System.out.println("設定:");
		System.out.println("  - スレッド数: " + THREAD_COUNT);
		System.out.println("  - 各スレッドの操作回数: " + OPERATIONS_PER_THREAD);
		System.out.println("  - 期待合計: " + EXPECTED_TOTAL);
		System.out.println("=".repeat(60));

		// テスト1: HashMap(失敗する)
		System.out.println("\n【テスト1】HashMap(非スレッドセーフ)");
		System.out.println("-".repeat(60));
		testHashMap();

		// テスト2: ConcurrentHashMap(成功する)
		System.out.println("\n【テスト2】ConcurrentHashMap(スレッドセーフ)");
		System.out.println("-".repeat(60));
		testConcurrentHashMap();


		System.out.println("\n" + "=".repeat(60));
		System.out.println("📊 結論:");
		System.out.println("  HashMap: Race Conditionによりデータ破損が発生");
		System.out.println("  ConcurrentHashMap: スレッドセーフで正確");
		System.out.println("=".repeat(60));
	}
}

実行結果

============================================================
ConcurrentHashMap スレッドセーフ性能テスト
============================================================
設定:
  - スレッド数: 10
  - 各スレッドの操作回数: 1000
  - 期待合計: 10000
============================================================

【テスト1】HashMap(非スレッドセーフ)
------------------------------------------------------------
❌ HashMap(非スレッドセーフ):
   期待値: 10000
   実際の値: 1633
   差分: 8367 個が失われた 

【テスト2】ConcurrentHashMap(スレッドセーフ)
------------------------------------------------------------

✅ ConcurrentHashMap(スレッドセーフ):
   期待値: 10000
   実際の値: 10000
   結果: 完璧!データ破損なし

============================================================
📊 結論:
  HashMap: Race Conditionによりデータ破損が発生
  ConcurrentHashMap: スレッドセーフで正確
============================================================

重要なポイント

  • HashMap の結果は実行ごとに変わる(不確定)
  • ConcurrentHashMap は常に正確(確定的)
  • これがスレッドセーフの価値!

2.2 CopyOnWriteArrayList

特徴:

  • ArrayList のスレッドセーフ版
  • 読み取りが多く、書き込みが少ない場合に最適
  • 書き込み時に内部配列をコピー(Copy-On-Write)
import java.util.concurrent.CopyOnWriteArrayList;

public class CopyOnWriteArrayListDemo {
    public static void main(String[] args) {
        // ❌ 非スレッドセーフ
        List<String> unsafeList = new ArrayList<>();
        
        // ✅ スレッドセーフ(読み取り多用向け)
        List<String> safeList = new CopyOnWriteArrayList<>();
        
        safeList.add("勇者");
        safeList.add("戦士");
        safeList.add("魔法使い");
        
        // イテレーション中に他のスレッドが変更しても安全
        for (String member : safeList) {
            System.out.println(member);
        }
    }
}

実践例:パーティメンバーのリアルタイム管理:

ThreadSafePartyManager.java
public class ThreadSafePartyManager {
    private final CopyOnWriteArrayList<String> members 
        = new CopyOnWriteArrayList<>();
    
    public void addMember(String name) {
        members.add(name);
        System.out.println("✅ " + name + " がパーティに参加");
    }
    
    public void removeMember(String name) {
        if (members.remove(name)) {
            System.out.println("✅ " + name + " がパーティから離脱");
        }
    }
    
    public void showMembers() {
        System.out.println("\n👥 現在のパーティメンバー:");
        members.forEach(member -> System.out.println("  - " + member));
    }
    
	public int getMemberCount() {
        return members.size();
    }
}

実行デモ

  • テスト1:並行アクセス(参加のみ)
  • テスト2:ArrayList との比較(失敗するケース)
PartyManagerDemo.java
import java.util.ArrayList;
import java.util.concurrent.CountDownLatch;

/**
 * CopyOnWriteArrayList のスレッドセーフ性を実証するデモ
 * 
 * シナリオ:オンラインRPGのパーティ管理システム
 * - 複数のプレイヤーが同時にパーティに参加/離脱
 * - パーティメンバーの表示(イテレーション)も同時実行
 */
public class PartyManagerDemo {
    
    private static final int PLAYER_COUNT = 10;  // 同時接続プレイヤー数
   
    /**
     * テスト1:並行アクセス(参加のみ)
     */
    public static void testConcurrentAdd() throws InterruptedException {
        System.out.println("【テスト1】複数プレイヤーが同時に参加");
        System.out.println("-".repeat(60));
        
        ThreadSafePartyManager manager = new ThreadSafePartyManager();
        
        CountDownLatch startLatch = new CountDownLatch(1);
        CountDownLatch endLatch = new CountDownLatch(PLAYER_COUNT);
        
        // 10人のプレイヤーが同時に参加を試みる
        for (int i = 1; i <= PLAYER_COUNT; i++) {
            final int playerNum = i;
            new Thread(() -> {
                try {
                    startLatch.await();  // 全員が準備完了するまで待機
                    
                    String role = (playerNum % 3 == 0) ? "魔法使い" : 
                                  (playerNum % 3 == 1) ? "戦士" : "僧侶";
                    manager.addMember(role + playerNum);
                    
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    endLatch.countDown();
                }
            }, "Player-" + i).start();
        }
        
        // 全プレイヤーを同時にスタート
        startLatch.countDown();
        
        // 全プレイヤーの完了を待つ
        endLatch.await();
        
        manager.showMembers();
        System.out.println("✅ 期待: 10人、実際: " + manager.getMemberCount() + "人");
        System.out.println();
    }
        
    /**
     * テスト2:ArrayList との比較(失敗するケース)
     */
    public static void testArrayListFailure() throws InterruptedException {
        System.out.println("【テスト2】ArrayList(非スレッドセーフ)との比較");
        System.out.println("-".repeat(60));
        
        ArrayList<String> unsafeList = new ArrayList<>();
        
        // 初期データ
        for (int i = 1; i <= 5; i++) {
            unsafeList.add("メンバー" + i);
        }
        
        CountDownLatch startLatch = new CountDownLatch(1);
        CountDownLatch endLatch = new CountDownLatch(10);
        
        // 5スレッドが追加
        for (int i = 1; i <= 5; i++) {
            final int num = i;
            new Thread(() -> {
                try {
                    startLatch.await();
                    unsafeList.add("新規" + num);
                } catch (Exception e) {
                    System.out.println("❌ 例外発生: " + e.getClass().getSimpleName());
                } finally {
                    endLatch.countDown();
                }
            }).start();
        }
        
        // 5スレッドがイテレーション
        for (int i = 1; i <= 5; i++) {
            new Thread(() -> {
                try {
                    startLatch.await();
                    // ⚠️ イテレーション中に他スレッドが変更すると...
                    for (String member : unsafeList) {
                        System.out.print(member + " ");
                    }
                    System.out.println();
                } catch (Exception e) {
                    System.out.println("❌ 例外発生: " + e.getClass().getSimpleName());
                } finally {
                    endLatch.countDown();
                }
            }).start();
        }
        
        startLatch.countDown();
        endLatch.await();
        
        System.out.println("\n⚠️ ArrayList は ConcurrentModificationException が発生する可能性");
        System.out.println("✅ CopyOnWriteArrayList なら安全!");
        System.out.println();
    }
    

    public static void main(String[] args) throws InterruptedException {
        System.out.println("=".repeat(60));
        System.out.println("CopyOnWriteArrayList パーティ管理デモ");
        System.out.println("=".repeat(60));
        System.out.println();
                
        // テスト1: 並行追加
        testConcurrentAdd();
                
        // テスト2: ArrayList との比較
        testArrayListFailure();
                
        System.out.println("=".repeat(60));
        System.out.println("結論:");
        System.out.println("  ✅ CopyOnWriteArrayList は完全にスレッドセーフ");
        System.out.println("  ✅ 読み取り(イテレーション)が高速");
        System.out.println("  ✅ ConcurrentModificationException が発生しない");
        System.out.println("  ⚠️ 書き込みが多い場合はコストが高い");
        System.out.println("=".repeat(60));
    }
}

実行結果

============================================================
CopyOnWriteArrayList パーティ管理デモ
============================================================

【テスト1】複数プレイヤーが同時に参加
------------------------------------------------------------
✅ 僧侶2 がパーティに参加
✅ 魔法使い9 がパーティに参加
✅ 戦士7 がパーティに参加
✅ 戦士1 がパーティに参加
✅ 僧侶8 がパーティに参加
✅ 魔法使い6 がパーティに参加
✅ 魔法使い3 がパーティに参加
✅ 戦士4 がパーティに参加
✅ 僧侶5 がパーティに参加
✅ 戦士10 がパーティに参加

👥 現在のパーティメンバー:
  - 僧侶8
  - 魔法使い9
  - 戦士7
  - 戦士10
  - 僧侶2
  - 戦士1
  - 魔法使い6
  - 魔法使い3
  - 戦士4
  - 僧侶5
✅ 期待: 10人、実際: 10人

【テスト2】ArrayList(非スレッドセーフ)との比較
------------------------------------------------------------
❌ 例外発生: ConcurrentModificationException
❌ 例外発生: ConcurrentModificationException
メンバー1 メンバー1 メンバー2 メンバー1 メンバー2 メンバー3 メンバー4 メンバー5 新規2 新規3 新規5 
❌ 例外発生: ConcurrentModificationException
メンバー3 メンバー4 メンバー5 新規2 新規3 新規5 

⚠️ ArrayList は ConcurrentModificationException が発生する可能性
✅ CopyOnWriteArrayList なら安全!

============================================================
結論:
  ✅ CopyOnWriteArrayList は完全にスレッドセーフ
  ✅ 読み取り(イテレーション)が高速
  ✅ ConcurrentModificationException が発生しない
  ⚠️ 書き込みが多い場合はコストが高い
============================================================

2.3 Collections.synchronizedMap() / synchronizedList()

特徴:

  • 既存のコレクションをスレッドセーフにラップ
  • すべての操作をロック(遅い)
  • ConcurrentHashMap より性能が劣る
import java.util.*;

public class SynchronizedCollectionDemo {
    public static void main(String[] args) {
        // HashMap をスレッドセーフにラップ
        Map<String, Integer> syncMap = 
            Collections.synchronizedMap(new HashMap<>());
        
        // ArrayList をスレッドセーフにラップ
        List<String> syncList = 
            Collections.synchronizedList(new ArrayList<>());
        
        syncMap.put("HP", 100);
        syncList.add("ポーション");
        
        // ⚠️ イテレーション時は手動で同期が必要
        synchronized (syncList) {
            for (String item : syncList) {
                System.out.println(item);
            }
        }
    }
}

3. 使い分けガイド

3.1 フローチャート

collection_selection_flowchart.png

上図はゲームのスキルツリー風デザインで、実務でのコレクション選択をサポートします。左パネルに性能比較、右パネルに実務例、下部にクイックリファレンス表を配置しています。

テキスト版フローチャート(参考):

マルチスレッドで使う?
├─ NO → HashMap / ArrayList(通常版)
└─ YES
    ├─ Map系?
    │   ├─ 読み書きが混在 → ConcurrentHashMap ⭐推奨
    │   └─ 読み書きが少ない → Collections.synchronizedMap
    │
    └─ List系?
        ├─ 読み取りがほとんど → CopyOnWriteArrayList ⭐推奨
        ├─ 書き込みが多い → Collections.synchronizedList
        └─ キュー操作が必要 → BlockingQueue

3.2 使い分け早見表

コレクション スレッド
セーフ
性能 用途
HashMap ⚡⚡⚡ シングルスレッド
ConcurrentHashMap ⚡⚡ マルチスレッド(読み書き混在)
SynchronizedMap マルチスレッド(読み書き少)
ArrayList ⚡⚡⚡ シングルスレッド
CopyOnWriteArrayList ⚡⚡ マルチスレッド(読み取り多)
SynchronizedList マルチスレッド(書き込み多)

3.3 Listの使い分けの指針

シナリオ 推奨
読み取り多、書き込み少 ✅ CopyOnWriteArrayList
書き込み多、読み取り少 ❌ Collections.synchronizedList
イテレーション中に変更 ✅ CopyOnWriteArrayList
リアルタイム性が必要 ❌ CopyOnWriteArrayList(スナップショット)

✅ 設定値のリスト(読み取り多)
✅ リスナーのリスト(読み取り多)
✅ パーティメンバー(読み取り多、表示頻度高)
❌ チャットログ(書き込み多)
❌ リアルタイムランキング(即座の反映が必要)

3.4 スレッドセーフの代償

  • 利点
    ✅ 完全にスレッドセーフ
    ✅ イテレーションが高速
    ✅ 例外が発生しない

  • 欠点
    ⚠️ 書き込みが遅い(配列全体をコピー)
    ⚠️ メモリ使用量が増加
    ⚠️ 最新の変更がすぐに反映されない(スナップショット)

4. よくある間違いと対策

❌ 間違い1: synchronizedブロックの外でイテレーション

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

// ❌ ダメな例
for (String key : map.keySet()) {  // ConcurrentModificationException
    System.out.println(key);
}

// ✅ 正しい例
synchronized (map) {
    for (String key : map.keySet()) {
        System.out.println(key);
    }
}

❌ 間違い2: putIfAbsent の誤用

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

// ❌ ダメな例(アトミックでない)
if (!map.containsKey("HP")) {
    map.put("HP", 100);
}

// ✅ 正しい例(アトミック)
map.putIfAbsent("HP", 100);

❌ 間違い3: CopyOnWriteArrayList の誤用

// ❌ 書き込みが多い場合は遅い
List<String> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 100000; i++) {
    list.add("item" + i);  // 毎回配列をコピー 💥
}

// ✅ 書き込みが多い場合は別の方法
List<String> list = Collections.synchronizedList(new ArrayList<>());

練習問題

問題1: どのコレクションを選ぶ?

以下のシナリオで最適なコレクションを選んでください:

1.オンラインゲームのチャットログ(読み取りがほとんど)

  • ArrayList
  • CopyOnWriteArrayList
  • Collections.synchronizedList

2.リアルタイムのアイテム取引システム(読み書き頻繁)

  • HashMap
  • ConcurrentHashMap
  • Collections.synchronizedMap
  1. タスクキュー(追加と取得を繰り返す)
    • ArrayList
    • LinkedList
解答を見る

1.CopyOnWriteArrayList ✅(読み取りが多い)
2.ConcurrentHashMap ✅(読み書き頻繁、高性能)

まとめ

重要ポイント

1.HashMap は非スレッドセーフ

  • マルチスレッドでは ConcurrentHashMap を使う

2.ConcurrentHashMap vs SynchronizedMap

  • 性能: ConcurrentHashMapSynchronizedMap
  • 基本的に ConcurrentHashMap を選ぶ

3.CopyOnWriteArrayList の使い所

  • 読み取りが多く、書き込みが少ない場合に最適
  • 逆は性能が悪い

4.アトミック操作を活用

  • merge(), computeIfPresent(), putIfAbsent() など

5.性能とトレードオフ

  • スレッドセーフ = 安全だが遅い
  • シングルスレッドなら通常版で十分

関連記事

JavaQuest Part2

📚 参考資料

公式ドキュメント

推奨書籍

  • 『Effective Java 第3版』(Joshua Bloch 著)

実務での応用

  • Webサーバーでのセッション管理
  • データベースのコネクションプール
  • リアルタイム通信システム

本記事は教育目的で作成されています。RPGの例を通じてプログラミング概念を楽しく学習できることを目指しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?