The Problem
You're building a producer-consumer pattern or a custom blocking queue โ something that involves threads waiting on each other. At runtime, one of them crashes with:
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)
The stack trace points to a line calling wait(), notify(), or notifyAll(). That thread is dead. Depending on your design, the remaining threads either deadlock or spin forever waiting for a signal that never comes.
Root Cause
Java has one hard rule about these three methods: you can only call wait(), notify(), or notifyAll() on an object if the current thread holds that object's monitor lock. This isn't a soft guideline โ if the lock isn't held, the JVM throws IllegalMonitorStateException immediately, no second chances.
Here's the broken pattern that triggers it:
// BROKEN: calling wait() and notify() without a synchronized block
public class SharedQueue {
private final List<String> queue = new ArrayList<>();
public void waitForItem() throws InterruptedException {
while (queue.isEmpty()) {
queue.wait(); // <-- throws IllegalMonitorStateException
}
}
public void addItem(String item) {
queue.add(item);
queue.notify(); // <-- throws IllegalMonitorStateException
}
}
The thread calling queue.wait() never acquired the lock on queue. No lock, no wait.
Debug: Find the Exact Mismatch
The stack trace gives you the exact line. Then ask two questions:
- Is the
wait()ornotify()call inside asynchronizedblock? - Is that
synchronizedblock locking the same object you're calling the method on?
Two mismatches that look fine at a glance but blow up at runtime:
// Mistake 1: synchronized on 'this' but calling wait() on a field
public synchronized void bad() throws InterruptedException {
queue.wait(); // lock on 'this', calling wait() on 'queue' โ MISMATCH
}
// Mistake 2: synchronized block on the wrong object
synchronized (this) {
queue.notify(); // holds lock on 'this', not 'queue' โ MISMATCH
}
Whatever object you synchronized on is the only object you can call wait() or notify() on. Same object, both roles โ no exceptions.
Fix It
Option 1: Synchronized method (lock on this)
Declare the method synchronized โ that makes this the monitor โ then call this.wait() and this.notify():
public class SharedQueue {
private final List<String> queue = new ArrayList<>();
public synchronized void waitForItem() throws InterruptedException {
while (queue.isEmpty()) {
this.wait(); // current thread holds 'this' monitor โ OK
}
}
public synchronized void addItem(String item) {
queue.add(item);
this.notify(); // current thread holds 'this' monitor โ OK
}
}
Option 2: Synchronized block on the target object
For finer-grained control, synchronize explicitly on the exact object you want to wait on:
public class SharedQueue {
private final List<String> queue = new ArrayList<>();
public void waitForItem() throws InterruptedException {
synchronized (queue) { // acquire lock on 'queue'
while (queue.isEmpty()) {
queue.wait(); // same object โ OK
}
}
}
public void addItem(String item) {
synchronized (queue) { // same lock
queue.add(item);
queue.notify(); // same object โ OK
}
}
}
Always use while, not if
One more thing โ separate from the lock mismatch, but just as important. Wrap wait() in a while loop, not an if. The JVM can wake a waiting thread for no real reason, a phenomenon called a spurious wakeup. With if, the thread continues without rechecking whether the condition is actually true:
// WRONG: spurious wakeup bypasses the condition check
synchronized (lock) {
if (!ready) lock.wait();
// 'ready' might still be false here
}
// CORRECT: recheck every time
synchronized (lock) {
while (!ready) lock.wait();
// 'ready' is guaranteed true here
}
Option 3: Use java.util.concurrent instead
Raw Object.wait() works, but it's easy to get wrong. Most production Java code reaches for ReentrantLock with Condition โ the API makes lock ownership explicit, so the kind of mismatch above is much harder to introduce by accident:
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(); // equivalent to wait()
}
} finally {
lock.unlock();
}
}
public void addItem(String item) {
lock.lock();
try {
queue.add(item);
notEmpty.signal(); // equivalent to notify()
} finally {
lock.unlock();
}
}
}
Or skip manual lock management entirely. LinkedBlockingQueue handles all the synchronization internally โ no wait(), no notify(), no mismatch possible:
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
// Producer thread
queue.put("item"); // blocks if full, no manual sync needed
// Consumer thread
String item = queue.take(); // blocks until item available, no manual sync needed
Verify the Fix
Rerun the code path that was crashing. The IllegalMonitorStateException should be gone. A minimal test to confirm:
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();
}
// Expected output (order may vary):
// Producer: sent item
// Consumer: got item
Both lines print, no exception โ you're done.
Lessons Learned
- wait(), notify(), and notifyAll() require a lock. Specifically, the lock on the exact object you're calling them on. The JVM checks this at runtime every single call โ there's no grace period.
- The lock object and the wait/notify target must be identical. Synchronizing on
thiswhile callingfield.wait()compiles without complaint and crashes at runtime. No warning, no compile error. - Loop on wait(), always. Spurious wakeups are a documented JVM behavior, not a theoretical edge case. An
ifcheck gives no protection; awhileloop does. - For new code, use java.util.concurrent.
BlockingQueue,ReentrantLock, andSemaphoremake the locking protocol visible in the code itself. The class of bug that causes this exception becomes much harder to write by accident.

