問題の背景
マルチスレッド処理は処理効率を向上させる一方で、共有データへのアクセス競合という重大な課題を伴います。具体的な事例として、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) {
// ... クリティカルセクション ...
}
クラスオブジェクトはアプリケーション内で唯一無二であるため、最も安全なロックとして機能します。