Concurrency

java.lang.IllegalMonitorStateException: current thread is not owner

Written and reviewed by Sahil Srivastav

ConcurrencyMonitorsJava
java.lang.IllegalMonitorStateException: current thread is not owner
	at java.base/java.lang.Object.wait(Native Method)
	at java.base/java.lang.Object.wait(Object.java:366)
	at com.example.queue.TaskQueue.awaitItem(TaskQueue.java:52)
	at com.example.queue.Consumer.run(Consumer.java:19)
	at java.base/java.lang.Thread.run(Thread.java:1583)

What this error actually means

The runtime refused a monitor operation that is only legal for the monitor’s owner. `Object.wait()`, `notify()`, and `notifyAll()` all require that the calling thread currently holds the monitor of the object they are called on; `ReentrantLock.unlock()` requires that the calling thread is the recorded owner. You called one without that ownership, and the check is unconditional and immediate.

This is not an arbitrary restriction. `wait()` must *release* the monitor and re-acquire it before returning, and you cannot release something you do not hold — so the operation is undefined without ownership. `notify()` is worse: it is meaningful only as a statement about state that the monitor protects, and signalling without the lock would mean announcing a condition change that no waiter can trust, since the change and the signal would not be atomic. The ownership requirement is what makes the wait/notify protocol sound.

Because the check is precise, the exception is unusually informative: it proves the calling thread holds no claim on that specific object at that moment. There are only a few ways to arrive there, and they are all structural rather than timing-dependent — which makes this one of the few concurrency exceptions that reproduces on the first run.

The most consequential variant is the one where the object identity is wrong rather than the lock discipline. `synchronized (this)` followed by `queue.wait()` acquires one monitor and waits on another. The code compiles, reads as correct, and fails every time, because the monitor of `this` and the monitor of `queue` are different objects.

Causes, most common first

  1. 1Waiting or signalling on a different object than the one locked. The highest-value cause to check first because it is invisible on a read. `synchronized (lock) { … items.wait(); }` holds `lock`’s monitor and calls `wait()` on `items`’s. Every object has its own monitor; there is no relationship between them. A `synchronized` *method* locks `this`, so calling `field.wait()` inside it is the same mistake in a form that looks even more innocent.
  2. 2`wait()` or `notify()` called with no synchronisation at all. Usually the result of extracting the wait into a helper method that is not itself `synchronized`, or of a refactor that moved the call out of the block. Monitor ownership is not inherited across method boundaries unless every frame in between holds it.
  3. 3`unlock()` on a lock this thread never acquired. A `finally` that releases unconditionally after a `tryLock()` that returned `false`. The acquire failed, the body was skipped or ran anyway, and the release throws — and because it throws from `finally`, it replaces whatever exception was in flight, destroying the diagnosis of the original failure.
  4. 4Unbalanced reentrant release. The reverse of the leak: a method acquires once and releases twice, directly or through a helper that also unlocks. The first release drops the hold count to zero and clears the owner; the second finds no owner and throws.
  5. 5Acquire and release on different threads. One thread takes the lock and hands the release to an executor, a callback, or a completion stage. `ReentrantLock` ownership is per-thread by design — locks are not transferable — so the releasing thread is not the owner. If you need a permit that any thread can release, you want a `Semaphore`, not a lock.
  6. 6The lock reference was reassigned. A non-final field holding the lock or the monitored object is replaced between acquire and release. The thread now owns the old object’s monitor and is operating on the new one. Same root cause as the identity mismatch, harder to see because the code reads as a single variable.

When you see it

  • Fails on the very first invocation of the path, deterministically, with no concurrency required
  • A consumer thread dies at start-up and the pool silently continues with one fewer worker
  • The stack trace ends in `Object.wait` or `ReentrantLock.unlock` with your method one frame below
  • With `ReentrantLock`, the exception often carries no message at all — just the type and the `unlock` frame
  • A `finally` block throws this and masks the original exception that was propagating through it
  • On a `Condition`, the same shape appears as `await()` or `signal()` failing rather than `wait()`

How to diagnose it

Step 1

Read the two identities in the failing frames

The exception names the operation; your next frame names the receiver. Confirm that the object you called `wait()`/`notify()` on is the same object named in the enclosing `synchronized`. Nine times in ten, the bug is visible in those two lines alone.

Step 2

Make ownership explicit while debugging

Ownership is queryable. Asserting it immediately before the call turns a mystery into a precise statement about which object is unheld.

assert Thread.holdsLock(items) : "not holding items monitor";
// for explicit locks:
log.info("heldByMe={} holdCount={}", lock.isHeldByCurrentThread(), lock.getHoldCount());

Step 3

Check the `tryLock` branches

Search for every `tryLock` in the file and verify each has its release inside the branch that acquired. An `unlock()` in a `finally` that is not guarded by the acquire result is the bug, and it is a one-line grep to find.

grep -n -A 8 "tryLock" src/main/java/**/*.java

Step 4

Count acquires against releases on the hot path

For reentrant locks, instrument both sides with the hold count. A release that observes a count of one is the last one; a release observing zero means you have already dropped it. This finds unbalanced helpers quickly.

Step 5

Look for the exception thrown from a `finally`

If this appears in logs with no preceding error, check whether it is being thrown out of cleanup and swallowing the real exception. Wrapping the release in its own guard restores the original stack trace, which is usually the more important bug.

grep -n -B 3 "IllegalMonitorStateException" app.log | head -30

The fix

Lock and wait on the same object, and make that pairing structural rather than remembered. The safest shape is a private `final Object lock = new Object();` used for both the `synchronized` block and the `wait`/`notify` calls, with the guarded state in the same class. If the two operations name the same final field, the mismatch cannot occur.

Keep the wait loop inside the `synchronized` block rather than factoring it into a helper. If you must extract it, make the helper `synchronized` on the same object, or pass the monitor explicitly and document the precondition — but inlining is better, because monitor ownership is a property of the whole call chain and helpers hide it.

For `tryLock`, tie the release to the acquire result: capture the boolean, and unlock only inside `if (acquired)`. Never release in an unconditional `finally` after a conditional acquire. The same rule prevents both this exception and the leaked-lock bug at the other extreme.

Ensure exactly one release per acquire on the same thread, including in reentrant paths. If a helper method may or may not lock, make it never lock and require the caller to hold the lock — an asymmetry between callers is how hold counts drift.

If the design genuinely needs a permit acquired on one thread and released on another, use `Semaphore`. Its permits are not owned by a thread, so cross-thread release is legal and intended, whereas with a lock it is a bug by construction.

Fix `finally` blocks so cleanup cannot replace the exception being propagated. Guard the release with an ownership check, or restructure so cleanup is only reached when it is valid — otherwise this exception will keep hiding the failure you actually need to see.

// Throws every time: holds this's monitor, waits on items's
synchronized void awaitItem() throws InterruptedException {
    while (items.isEmpty()) items.wait();   // IllegalMonitorStateException
}

// Throws from finally, masking the real exception
if (!lock.tryLock()) { /* fall through anyway */ }
try {
    doWork();
} finally {
    lock.unlock();                          // not the owner
}

// Correct: one final monitor, locked and waited on consistently
private final Object lock = new Object();
private final Deque<Task> items = new ArrayDeque<>();

Task take() throws InterruptedException {
    synchronized (lock) {
        while (items.isEmpty()) lock.wait();
        Task t = items.removeFirst();
        lock.notifyAll();
        return t;
    }
}

// Correct: release governed by the acquire result
boolean acquired = lock.tryLock(200, TimeUnit.MILLISECONDS);
if (!acquired) throw new BusyException();
try {
    doWork();
} finally {
    lock.unlock();
}

How to stop it coming back

  • Declare the monitor as a `private final` field and never synchronise on `this` or on a public object — it makes the lock/wait pairing checkable and stops outside code from interfering
  • Keep guarded state, its monitor, and its wait loop in one small class; ownership bugs are almost always the product of spreading them across layers
  • Ban unconditional `unlock()` in `finally` after a conditional acquire; make the acquire result the only thing that authorises a release
  • Use `Thread.holdsLock()` assertions on methods documented to require the lock, and run tests with assertions enabled
  • Prefer `BlockingQueue` and the other `java.util.concurrent` synchronisers over hand-written wait/notify, which removes the ownership obligation entirely
  • Treat any exception thrown from a `finally` as a defect, because it destroys the stack trace of the failure that was already propagating

Practise production debugging in a real repository

Reading about a failure and reproducing one are different skills. Gronex ships broken backend repositories with failing test suites that encode the real invariant, so you debug from evidence instead of memorising symptoms.

FAQ

Why does `wait()` require holding the monitor when it is going to release it anyway?

Because releasing and enqueueing must be a single atomic step relative to other threads. If you could wait without holding the lock, a producer could change the state and signal between your condition check and your enqueue, and the signal would be lost. Requiring ownership is what makes check-then-wait race-free.

Does a `synchronized` method let me call `wait()` on any field?

No. A `synchronized` instance method locks `this` and nothing else. Calling `someField.wait()` inside it throws, because you hold `this`'s monitor and not the field's. This is the single most common form of the bug and it is completely deterministic.

Why does my `ReentrantLock` version have no message?

The lock implementation throws a bare `IllegalMonitorStateException` from `unlock()` when the current thread is not the owner, without constructing a message. Use the `unlock` frame plus `isHeldByCurrentThread()` and `getHoldCount()` at the call site to establish what state the lock was actually in.

Can I hand a lock to another thread to release?

Not with `ReentrantLock` or `synchronized` — both are owned by the acquiring thread and releasing from elsewhere is defined to throw. When the requirement is a count of outstanding permits rather than mutual exclusion by an owner, use `Semaphore`, whose `release()` is deliberately callable by any thread.

Is this ever a race condition?

Rarely. Two variants are timing-dependent: a reassigned non-final lock reference, and an unbalanced reentrant release where the drift depends on which callers ran. Everything else — waiting on the wrong object, waiting with no lock, unconditional release after `tryLock` — is structural and fails on the first execution of the path.

Related

Other errors engineers hit next to this one

Full error and symptom index →