java-recipes

ホーム マルチスレッド › Th-02

Th-02: synchronized キーワード

複数のスレッドが同じデータを同時に読み書きすると、意図しない値になる「レースコンディション」が発生します。synchronized を使ってロックをかけることで、一度に1つのスレッドだけがアクセスできるようにする方法を解説します。

レースコンディションとは

count++ は一見1行の命令ですが、実際にはCPUレベルで「読み取り → 加算 → 書き戻し」の3ステップで実行されます。 スレッド A が読み取った直後にスレッド B も同じ値を読み取ると、2回 ++ したはずが1しか増えません。 これが「レースコンディション(競合状態)」です。

// count = 5 の状態で2スレッドが同時実行する場合
スレッドA: count を読み取る (5)
スレッドB: count を読み取る (5)  ← A がまだ書き戻す前に読んでしまう
スレッドA: 5 + 1 = 6 を書き戻す
スレッドB: 5 + 1 = 6 を書き戻す  ← 期待は 7 だが 6 になってしまう

synchronized の2つの使い方

方法書き方ロック対象推奨度
synchronized メソッドpublic synchronized void increment()this(インスタンス全体)○ シンプル
synchronized ブロックsynchronized(lock) { ... }専用ロックオブジェクト◎ ロック範囲を最小化できる

synchronized ブロックを使うと、ロックする範囲(クリティカルセクション)を最小限に絞れます。 ロック範囲が大きいほど並行処理の恩恵が減るため、必要な箇所だけロックする書き方が推奨されます。

サンプルコード

Sample.java
public class SynchronizedSample {

    // スレッドアンセーフなカウンター(競合が発生する)
    static class UnsafeCounter {
        private int count = 0;

        public void increment() {
            count++; // 非アトミック: 読み取り・加算・書き戻しの3ステップ
        }

        public int getCount() {
            return count;
        }
    }

    // synchronized メソッドによるスレッドセーフなカウンター
    static class SafeCounterMethod {
        private int count = 0;

        public synchronized void increment() { // メソッド全体をロック
            count++;
        }

        public synchronized int getCount() {
            return count;
        }
    }

    // synchronized ブロック(スコープ最小化・推奨)
    static class SafeCounterBlock {
        private int count = 0;
        private final Object lock = new Object(); // 専用ロックオブジェクト

        public void increment() {
            synchronized (lock) { // 必要な箇所だけロック
                count++;
            }
        }

        public int getCount() {
            synchronized (lock) {
                return count;
            }
        }
    }

    // 競合を発生させて違いを示す
    static void runConcurrent(Object counter, boolean isSafe) throws InterruptedException {
        int threadCount = 10;
        int incrementsPerThread = 1000;

        Thread[] threads = new Thread[threadCount];
        for (int i = 0; i < threadCount; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < incrementsPerThread; j++) {
                    if (counter instanceof UnsafeCounter) {
                        ((UnsafeCounter) counter).increment();
                    } else if (counter instanceof SafeCounterMethod) {
                        ((SafeCounterMethod) counter).increment();
                    } else if (counter instanceof SafeCounterBlock) {
                        ((SafeCounterBlock) counter).increment();
                    }
                }
            });
            threads[i].start();
        }
        for (Thread t : threads) {
            t.join();
        }

        int expected = threadCount * incrementsPerThread;
        int actual;
        if (counter instanceof UnsafeCounter) {
            actual = ((UnsafeCounter) counter).getCount();
        } else if (counter instanceof SafeCounterMethod) {
            actual = ((SafeCounterMethod) counter).getCount();
        } else {
            actual = ((SafeCounterBlock) counter).getCount();
        }
        System.out.printf("%s: 期待値=%d, 実際=%d, 一致=%b%n",
            isSafe ? "安全" : "危険", expected, actual, expected == actual);
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println("=== スレッドセーフ比較 ===");
        runConcurrent(new UnsafeCounter(), false);
        runConcurrent(new SafeCounterMethod(), true);
        runConcurrent(new SafeCounterBlock(), true);
    }
}

よくあるミス・注意点

⚠️ synchronized をつけ忘れてレースコンディションが起きる

increment()synchronized をつけても getCount() につけ忘れると、 不完全な値が返ることがあります。読み取り側も同じロックで保護する必要があります。

⚠️ 複数のロックオブジェクトを混在させて意図しない競合が起きる

synchronized(this)synchronized(lock) が同じクラスの別メソッドで使われていると、 それぞれ別のロックで保護されます。同じフィールドを守るロックは統一してください。

⚠️ ロックの粒度が大きすぎてパフォーマンスが低下する

メソッド全体に synchronized をつけると、時間のかかる処理(ファイルI/O や HTTP 通信など)もロックされてしまいます。 ロックが必要なのはデータを変更する最小限の箇所だけです。synchronized ブロックで範囲を絞ることを検討してください。

テストする観点

  • SafeCounterMethod で 10スレッド × 1000回インクリメントした結果が 10000 になること
  • SafeCounterBlock で同様に 10000 になること
  • UnsafeCounter では複数回実行すると結果が 10000 にならない(確率的にずれる)こと
  • increment()synchronized をつけても getCount() に付け忘れた場合、読み取り値が不正になる可能性があること
  • ✅ ロックオブジェクトが異なる2つの synchronized ブロックは互いに干渉しないこと

GitHub でソースコードを見る →