Javaにおけるスレッドセーフティ:同期化ブロックによる競合状態の防止

問題の背景

マルチスレッド処理は処理効率を向上させる一方で、共有データへのアクセス競合という重大な課題を伴います。具体的な事例として、100枚限定の特別チケットを3つの販売窓口(スレッド)で同時に販売するシステムを考えてみましょう。

初期実装とその問題点

まず、単純な実装を試してみます。各窓口を独立したスレッドとして表現し、チケットカウンターを保持させます。

public class TicketWindow extends Thread {
    // 各スレッドが独自に保持するカウンター(共有されていない)
    private int seatNumber = 0;
    
    @Override
    public void run() {
        while (seatNumber < 100) {
            try {
                Thread.sleep(10); // 処理の高速化を抑える
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            seatNumber++;
            System.out.println(getName() + "が座席番号" + seatNumber + "を販売");
        }
    }
}

// 実行コード
public static void main(String[] args) {
    TicketWindow counterA = new TicketWindow();
    TicketWindow counterB = new TicketWindow();
    TicketWindow counterC = new TicketWindow();
    
    counterA.setName("窓口A");
    counterB.setName("窓口B");
    counterC.setName("窓口C");
    
    counterA.start();
    counterB.start();
    counterC.start();
}

このコードを実行すると、各スレッドが独立したカウンターを持つため、合計300枚のチケットが販売されてしまいます。これは明らかに意図した動作ではありません。

共有データの導入と新たな課題

次に、static修飾子を使用してデータを共有します。

public class TicketWindow extends Thread {
    // 全スレッドで共有される静的カウンター
    private static int availableTickets = 0;
    
    @Override
    public void run() {
        while (availableTickets < 100) {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            availableTickets++;
            System.out.println(getName() + ":チケットNo." + availableTickets + "を発行");
        }
    }
}

しかし、この実装では以下の2つの重大な問題が発生します:

  • 重複番号の発行:複数スレッドが同じ番号を出力する
  • 上限超過:100を超える番号が発行される

なぜ重複が起きるのか

3つのスレッドが同時にavailableTicketsの値を0としてアクセスしたとします。スレッド1がCPU実行権を獲得し、条件判定をパスしてスリープ状態に入ると、スレッド2とスレッド3も同様に条件ブロック内に進入します。各スレッドが目覚めて増分処理を実行する際、同じ値に対して連続してインクリメントが行われ、最終的に全てのスレッドが同一番号を出力してしまいます。

なぜ上限を超過するのか

availableTicketsが99に達した状態で、3つのスレッドがほぼ同時に条件判定を行ったとします。全てのスレッドが99 < 100を満たすため、スリープ後に順次インクリメントが実行され、102や103といった超過値が出力されます。

同期化ブロックによる解決策

競合状態を解消するため、共有データへのアクセスを排他的に制御する必要があります。synchronizedキーワードを使用した同期化ブロックがその役割を果たします。

synchronized (ロックオブジェクト) {
    // 共有データへのアクセスコード
}

同期化ブロックの特性:

  • 初期状態ではロックは開放されており、いずれかのスレッドが入ると自動的にロックされる
  • ブロック内の全処理が完了しスレッドが退出すると、ロックが再び開放される

修正版コードの実装

唯一のロックオブジェクトを用いて、クリティカルセクションを保護します。

public class TicketWindow extends Thread {
    private static int ticketCount = 0;
    // 全スレッドで共有される唯一のロックオブジェクト
    private static final Object lock = new Object();
    
    @Override
    public void run() {
        while (true) {
            synchronized (lock) {
                if (ticketCount >= 100) {
                    break;
                }
                try {
                    Thread.sleep(12);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
                ticketCount++;
                System.out.println(getName() + "がチケット" + ticketCount + "枚目を販売");
            }
            // ロック解除後に他スレッドに譲渡
            Thread.yield();
        }
    }
}

同期化ブロックの重要な注意点

1. ループとの配置関係

synchronizedブロックをループ外に配置すると、デッドロックに近い状態が発生します。

// 誤った実装例
@Override
public void run() {
    synchronized (lock) {
        while (true) { // 1つのスレッドが全てのチケットを独占
            if (ticketCount >= 100) break;
            // ... 処理 ...
        }
    }
}

この構成では、最初にロックを獲得したスレッドが100枚全ての販売を完了するまで、他のスレッドは一切実行できません。

2. ロックオブジェクトの一意性

ロックオブジェクトがスレッドごとに異なる場合、同期化が完全に機能しません。

// 誤った実装例
@Override
public void run() {
    while (true) {
        synchronized (this) { // 各スレッドのインスタンスが異なるロック
            // ... 処理 ...
        }
    }
}

この場合、各スレッドは独立したロックを使用するため、まったく同じ競合問題が再発します。正しくは、static finalなオブジェクト、またはクラスオブジェクト(TicketWindow.class)を使用します。

// 推奨実装
synchronized (TicketWindow.class) {
    // ... クリティカルセクション ...
}

クラスオブジェクトはアプリケーション内で唯一無二であるため、最も安全なロックとして機能します。

タグ: Java synchronized thread-safety race-condition critical-section

7月16日 17:01 投稿