【Java】Javaのsynchronizedと排他制御:競合状態を防ぐ書き方

Java

こんにちは、かつコーチです。
今回は複数スレッドが同じデータを操作するときに欠かせないsynchronizedと排他制御を扱います。
ExecutorServiceでタスクを並行実行できるようになった次のステップとして、データ競合の防ぎ方を押さえておきましょう。

競合状態(race condition)が起きる仕組み

カウンタの値がずれる典型例

// ❌ Before:排他制御なしのカウンタ
public class Counter {
    private int count = 0;

    public void increment() {
        count++; // 実は「読み取り→加算→書き込み」の3ステップ
    }

    public int getCount() {
        return count;
    }
}
Counter counter = new Counter();
ExecutorService executor = Executors.newFixedThreadPool(10);

for (int i = 0; i < 1000; i++) {
    executor.submit(counter::increment);
}
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);

System.out.println(counter.getCount()); // 1000にならないことがある

このコードを実際に動かすと、1000にならず980や995など不安定な値になることを何度も確認しました。
原因はcount++が「読み取り」「加算」「書き込み」の3ステップに分かれており、これがアトミック(それ以上分割できない、割り込まれない一連の処理)でないためです。
2つのスレッドが同時に同じ値を読み取ってしまうと、片方の加算結果が失われます。

競合状態が起きる条件

複数スレッドが同じ可変データに対して、少なくとも1つが書き込みを行いながら同時アクセスすると競合状態が発生します。
逆に言えば、データを読み取り専用にできるなら競合状態は起きません。

synchronizedによる排他制御

✅ After:synchronizedメソッドで保護する

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

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

synchronizedを付けたメソッドは、同時に1つのスレッドしか実行できなくなります。
あるスレッドがメソッドの実行中は、他のスレッドはそのメソッドの終了を待つ状態(BLOCKED)になります。
これによりcount++の3ステップが割り込まれずに完了し、値のずれがなくなります。

synchronizedブロックで範囲を絞る

メソッド全体ではなく、必要な処理だけをsynchronizedブロックで囲むこともできます。

public class Cache {
    private final Object lock = new Object();
    private Map<String, String> data = new HashMap<>();

    public void put(String key, String value) {
        synchronized (lock) {
            data.put(key, value); // 競合が起きうる箇所だけをロック
        }
        System.out.println("保存処理: " + key); // ここはロック外でOK
    }
}

ロックの範囲を狭めることで、他のスレッドが待たされる時間を最小限にできます。
ロックの粒度(1回のロックで保護する処理範囲の大きさ)は広すぎるとスループットが落ち、狭すぎると保護漏れが起きるため、バランスが重要です。

デッドロックとその回避

2つのロックが互いを待つ状態

// ❌ Before:ロック取得順序がバラバラでデッドロックのリスクがある
public void transfer(Account from, Account to, int amount) {
    synchronized (from) {
        synchronized (to) {
            from.withdraw(amount);
            to.deposit(amount);
        }
    }
}

スレッドAがtransfer(x, y, ...)、スレッドBがtransfer(y, x, ...)を同時に呼ぶと、AはxをロックしてyのロックをBが持つのを待ち、BはyをロックしてxのロックをAが持つのを待つ状態、いわゆるデッドロック(複数スレッドが互いのロック解放を待ち続けて処理が止まる状態)に陥ります。

✅ After:ロック取得順序を固定する

public void transfer(Account from, Account to, int amount) {
    Account first = from.getId() < to.getId() ? from : to;
    Account second = from.getId() < to.getId() ? to : from;

    synchronized (first) {
        synchronized (second) {
            from.withdraw(amount);
            to.deposit(amount);
        }
    }
}

常にIDが小さい方から先にロックすることで、どのスレッドも同じ順序でロックを取得するようになり、循環待ちが起きなくなります。
デッドロック対策の基本は「ロックを取得する順序を全スレッドで統一する」ことです。

java.util.concurrentの活用という選択肢

synchronizedより高機能な代替手段

synchronizedは基本的な排他制御を提供しますが、java.util.concurrentパッケージにはより高機能なクラスが用意されています。

選択肢特徴向いている用途
synchronized言語組み込み。シンプルだが機能は最小限単純な排他制御
ReentrantLockタイムアウト付きロック取得、公平性制御が可能ロック取得の柔軟な制御が必要な場合
AtomicIntegerなどロック不要でアトミックな加算・更新ができるカウンタなど単一の値の操作
ConcurrentHashMapスレッドセーフなMap実装複数スレッドから読み書きするMap

先ほどのカウンタ例は、実務ではsynchronizedよりもAtomicIntegerを使う方がシンプルで高速です。

private final AtomicInteger count = new AtomicInteger(0);

public void increment() {
    count.incrementAndGet(); // ロック不要でアトミックに加算
}

判断軸としては、単純な数値操作ならAtomic系クラス、複数の操作をまとめて保護したいならsynchronizedReentrantLock、というのが基本方針です。

まとめ

この記事のポイント

  • 複数スレッドが同じ可変データに同時アクセスすると競合状態が起き、値がずれる
  • synchronizedはメソッド・ブロック単位で排他制御を行い、同時実行を1スレッドに制限する
  • ロック取得順序がバラバラだとデッドロックのリスクがあり、順序を固定して回避する
  • 単純な数値操作はAtomicIntegerなどjava.util.concurrentのクラスの方がシンプルで高速

次に読むべき記事

  • スレッドプールの使い方は「ExecutorServiceでスレッドプールを使う」の記事へ
  • 非同期処理の組み立て方は「CompletableFutureの基本」の記事へ

タグ: Java, 上級者向け, 並行処理

タイトルとURLをコピーしました