問題の概要
プロデューサー・コンシューマーパターン、またはカスタムのブロッキングキューを実装している最中に、スレッドが互いに待機し合う処理を書いているとします。実行時に、その中の一つがクラッシュして次のエラーが発生します:
Exception in thread "Thread-0" java.lang.IllegalMonitorStateException: current thread is not owner
at java.base/java.lang.Object.wait(Native Method)
at com.example.SharedQueue.waitForItem(SharedQueue.java:14)
at com.example.ConsumerThread.run(ConsumerThread.java:22)
スタックトレースは wait()、notify()、または notifyAll() を呼び出している行を指しています。そのスレッドは停止しています。設計によっては、残りのスレッドがデッドロックに陥るか、永遠に来ないシグナルを待ち続けてスピンし続けることになります。
根本原因
Javaにはこれら3つのメソッドに関して厳格なルールが一つあります:wait()、notify()、または notifyAll() を呼び出せるのは、現在のスレッドがそのオブジェクトのモニターロックを保持している場合に限られます。これは柔軟なガイドラインではありません——ロックが保持されていなければ、JVMは即座に IllegalMonitorStateException をスローし、やり直しは効きません。
これを引き起こす壊れたパターンを以下に示します:
// 壊れた例: synchronizedブロックなしでwait()とnotify()を呼び出している
public class SharedQueue {
private final List<String> queue = new ArrayList<>();
public void waitForItem() throws InterruptedException {
while (queue.isEmpty()) {
queue.wait(); // <-- IllegalMonitorStateExceptionをスロー
}
}
public void addItem(String item) {
queue.add(item);
queue.notify(); // <-- IllegalMonitorStateExceptionをスロー
}
}
queue.wait() を呼び出しているスレッドは、queue のロックを取得していません。ロックなし、待機なし、というわけです。
デバッグ:ミスマッチの特定
スタックトレースから正確な行がわかります。次の2つの質問を自分に問いかけてください:
wait()またはnotify()の呼び出しはsynchronizedブロックの内側にあるか?- その
synchronizedブロックはメソッドを呼び出している同じオブジェクトをロックしているか?
一見問題なさそうに見えても実行時に爆発する、2つのミスマッチのパターンを示します:
// ミス1: 'this'でsynchronizedしているが、wait()はフィールドに対して呼び出している
public synchronized void bad() throws InterruptedException {
queue.wait(); // 'this'でロック、'queue'でwait()を呼び出し — ミスマッチ
}
// ミス2: 間違ったオブジェクトでsynchronizedブロックを使っている
synchronized (this) {
queue.notify(); // 'this'のロックを保持、'queue'のロックではない — ミスマッチ
}
synchronizedしたオブジェクトだけが、wait() または notify() を呼び出せる唯一のオブジェクトです。同じオブジェクトが両方の役割を持ちます——例外はありません。
修正方法
オプション1: synchronizedメソッド(thisでロック)
メソッドを synchronized として宣言します——これにより this がモニターになります——次に this.wait() と this.notify() を呼び出します:
public class SharedQueue {
private final List<String> queue = new ArrayList<>();
public synchronized void waitForItem() throws InterruptedException {
while (queue.isEmpty()) {
this.wait(); // 現在のスレッドは'this'のモニターを保持 — OK
}
}
public synchronized void addItem(String item) {
queue.add(item);
this.notify(); // 現在のスレッドは'this'のモニターを保持 — OK
}
}
オプション2: 対象オブジェクトでsynchronizedブロックを使う
より細かい制御のためには、待機したいオブジェクトに対して明示的にsynchronizeします:
public class SharedQueue {
private final List<String> queue = new ArrayList<>();
public void waitForItem() throws InterruptedException {
synchronized (queue) { // 'queue'のロックを取得
while (queue.isEmpty()) {
queue.wait(); // 同じオブジェクト — OK
}
}
}
public void addItem(String item) {
synchronized (queue) { // 同じロック
queue.add(item);
queue.notify(); // 同じオブジェクト — OK
}
}
}
常にifではなくwhileを使う
もう一つ重要なことがあります——ロックのミスマッチとは別の話ですが、同様に重要です。wait() は if ではなく while ループで囲んでください。JVMは特別な理由がなくても待機中のスレッドを起こすことがあります。これは「スプリアスウェイクアップ」と呼ばれる現象です。if を使うと、条件が実際に真かどうかを再確認せずにスレッドが処理を続けてしまいます:
// 誤り: スプリアスウェイクアップが条件チェックをバイパスする
synchronized (lock) {
if (!ready) lock.wait();
// ここで'ready'がまだfalseの可能性がある
}
// 正しい: 毎回再確認する
synchronized (lock) {
while (!ready) lock.wait();
// ここで'ready'は確実にtrueである
}
オプション3: java.util.concurrentを使う
生の Object.wait() は機能しますが、間違えやすいです。本番環境のJavaコードの多くは、Condition と組み合わせた ReentrantLock を使います——このAPIはロックの所有権を明示的にするため、上記のようなミスマッチを誤って導入することがはるかに難しくなります:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class SharedQueue {
private final List<String> queue = new ArrayList<>();
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
public void waitForItem() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // wait()と同等
}
} finally {
lock.unlock();
}
}
public void addItem(String item) {
lock.lock();
try {
queue.add(item);
notEmpty.signal(); // notify()と同等
} finally {
lock.unlock();
}
}
}
あるいは、手動のロック管理を完全に省略することもできます。LinkedBlockingQueue はすべての同期処理を内部で処理します——wait() も notify() も不要で、ミスマッチは起こりません:
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
// プロデューサースレッド
queue.put("item"); // 満杯の場合はブロック、手動同期は不要
// コンシューマースレッド
String item = queue.take(); // アイテムが利用可能になるまでブロック、手動同期は不要
修正の確認
クラッシュしていたコードパスを再実行してください。IllegalMonitorStateException は消えているはずです。確認のための最小限のテストを示します:
public static void main(String[] args) throws InterruptedException {
SharedQueue sq = new SharedQueue();
Thread consumer = new Thread(() -> {
try {
sq.waitForItem();
System.out.println("Consumer: got item");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread producer = new Thread(() -> {
try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
sq.addItem("hello");
System.out.println("Producer: sent item");
});
consumer.start();
producer.start();
consumer.join();
producer.join();
}
// 期待される出力(順序は異なる場合があります):
// Producer: sent item
// Consumer: got item
両方の行が出力され、例外も発生しない——これで完了です。
得られた教訓
- **wait()、notify()、notifyAll()にはロックが必要です。**具体的には、それらを呼び出す対象オブジェクトのロックです。JVMは呼び出しのたびに実行時にこれをチェックします——猶予期間はありません。
- ロックオブジェクトとwait/notifyの対象は同一でなければなりません。
thisでsynchronizeしながらfield.wait()を呼び出すコードはコンパイルが通りますが、実行時にクラッシュします。警告もコンパイルエラーも出ません。 - **wait()は常にループで囲みましょう。**スプリアスウェイクアップはJVMの仕様として文書化された動作であり、理論上のエッジケースではありません。
ifチェックでは保護になりません;whileループなら保護されます。 - 新しいコードにはjava.util.concurrentを使いましょう。
BlockingQueue、ReentrantLock、Semaphoreはロックのプロトコルをコード上で可視化します。この例外を引き起こすようなバグを誤って書くことが、はるかに難しくなります。

