ロック機構における課題
マルチスレッド環境では、ロックの扱いを誤ると深刻な問題が発生します。まず、可重入性についてです。同一スレッドが同一オブジェクトに対して再びロックを取得できる特性が必要であり、これを実現するにはロック保有スレッドの識別と参照カウントの管理が不可欠です。
次にデッドロックです。これは複数のスレッドが互いに必要なロックを待ち続け、処理が停滞する状態を指します。主な発生パターンは以下の通りです。
- 単一スレッド・単一ロック:再入不可能な鎖で同一スレッドが連続してロックを取得しようとする。
- 複数スレッド・複数ロック:スレッド A がロック X を保持しロック Y を待ち、スレッド B がロック Y を保持しロック X を待つ状態。
- 多数のスレッドとロック:資源の競合により循環待_occurs する状態(例:哲学者の食事問題)。
デッドロックが発生するには、「相互排他」「横取り不可」「保持と待機」「循環待機」の 4 条件が同時に揃う必要があります。これを防ぐには、ロックのネストを避けるか、ロック獲得順序を統一するなどの対策が必要です。
メモリ可視性の問題
現象の概要
スレッド間で共有変数を扱う際、一方のスレッドが変数を変更しても、他方のスレッドがその変更を検知できない現象をメモリ可視性問題と呼びます。以下のコード例では、スレッド A がフラグを監視し、スレッド B がユーザー入力に応じてフラグを変更する想定です。
package concurrency;
import java.util.Scanner;
public class VisibilityScenario {
private static int shutdownFlag = 0;
public static void main(String[] args) {
Thread monitorThread = new Thread(() -> {
while (shutdownFlag == 0) {
// 空ループ
}
System.out.println("monitorThread: 処理を終了しました");
});
Thread controlThread = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("終了するには 0 以外の整数を入力してください:");
shutdownFlag = scanner.nextInt();
});
monitorThread.start();
controlThread.start();
}
}
期待される動作は、ユーザーが数値を入力すると monitorThread がループを抜けることですが、実際には終了しないことが多いです。これは CPU や JVM の最適化が原因です。
最適化による影響
コンパイラや JVM は、単一スレッドの前提でコードを最適化します。上記の空ループにおいて、変数 `shutdownFlag` の値が変わらないと判断されると、メモリからの読み込み(load 命令)を省略し、レジスタの値を使い続けるよう最適化されることがあります。結果として、他スレッドによる主メモリ上の変更が反映されなくなります。
なお、ループ内に I/O 処理や `sleep` などを挿入すると、最適化が抑制されるため、この問題は発生しにくくなります。
volatile による解決
この問題に対処するには、変数に volatile 修飾子を追加します。これにより、変数の読み書きごとに主メモリとの同期が強制され、JVM による最適化(キャッシュの利用)が抑制されます。
package concurrency;
import java.util.Scanner;
public class VisibilityScenario {
private volatile static int shutdownFlag = 0;
public static void main(String[] args) {
Thread monitorThread = new Thread(() -> {
while (shutdownFlag == 0) {
// volatile により毎回主メモリから読み込まれる
}
System.out.println("monitorThread: 処理を終了しました");
});
Thread controlThread = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("終了するには 0 以外の整数を入力してください:");
shutdownFlag = scanner.nextInt();
});
monitorThread.start();
controlThread.start();
}
}
volatile はメモリ可視性を保証しますが、原子性を保証するものではありません。また、synchronized もロック獲得・解放時にメモリバリアを挿入するため可視性を保証しますが、オーバーヘッドが大きいため、フラグ管理などには volatile が適しています。
Java メモリモデル(JMM)では、スレッドは主メモリではなく作業メモリ(キャッシュやレジスタ)で変数を扱います。volatile はこの作業メモリと主メモリの同期を強制する役割を果たします。
スレッドの待機と通知機構
スレッドの実行順序を制御する手段として、待機通知機制があります。単純なループ待機は CPU リソースを浪費し、特定のスレッドが処理を独占する「スレッド飢餓」を引き起こす可能性があります。条件が整うまでスレッドをブロック状態にし、他スレッドから通知されて再開する仕組みが必要です。
wait メソッドの動作
Object クラスの wait() メソッドは、呼び出したスレッドをブロック状態に遷移させます。特筆すべき点は、このメソッドがロックを解放した上で待機することです。そのため、synchronized ブロック内でのみ呼び出す必要があります。
package concurrency;
public class WaitExample {
public static void main(String[] args) throws InterruptedException {
Object mutex = new Object();
System.out.println("待機開始前");
synchronized (mutex) {
mutex.wait();
}
System.out.println("待機解除後");
}
}
wait() は割り込みにより解除される可能性があり、その際は InterruptedException がスローされます。
notify メソッドによる唤醒
別スレッドから notify() を呼び出すことで、待機中のスレッドを再開させます。
package concurrency;
import java.util.Scanner;
public class NotifyExample {
public static void main(String[] args) {
Object mutex = new Object();
Thread waiter = new Thread(() -> {
synchronized (mutex) {
System.out.println("waiter: 待機に入ります");
try {
mutex.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("waiter: 再開しました");
}
});
Thread notifier = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
synchronized (mutex) {
System.out.println("notifier: 入力待ちです...");
scanner.next(); // ユーザー入力でブロック
mutex.notify();
System.out.println("notifier: 通知を送信しました");
}
});
waiter.start();
notifier.start();
}
}
注意点として、notify() が呼び出された時点で待機中のスレッドが存在しない場合、その通知は失われます。また、複数のスレッドが待機している場合、notify() はそのうちの一つをランダムに選択して唤醒します。
すべての待機スレッドを唤醒するには notifyAll() を使用します。また、wait(long timeout) を使用することで、指定時間経過しても通知がなければ自動的に待機を解除させることも可能です。