🔴 The Error You're Seeing

Confirm this matches your console output. If it does, you're in the right place.

ERROR LOGException in thread "main" java.lang.IllegalMonitorStateException at java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.enableWait(AbstractQueuedSynchronizer.java:1619) at java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:1740) at com.devinhyderabad.sync.BoundedBuffer.awaitSpace(BoundedBuffer.java:26)

⚡ Quick Fix Works 80% of the time

Acquire the Condition’s own Lock before awaiting and release it in a finally block.

lock.lock(); try { while (!spaceAvailable) { notFull.await(); } } finally { lock.unlock(); }

🧠 Why this Happens

Tap to expand the deep technical explanation

AQS ConditionObject.await() begins with enableWait, which asks the owning synchronizer isHeldExclusively(). await() must atomically enqueue the thread and release the lock; releasing a lock the thread does not hold would corrupt the owner bookkeeping, so the check throws a bare IllegalMonitorStateException before any queueing happens. The bare message is AQS convention — the frame line (enableWait) is the real diagnosis.

The HITEC City Parking Spot Analogy:

Trying to check a coat into a cloakroom using somebody else’s ticket stub — the attendant stops you at the counter because handing back property you never deposited would break the whole tracking system.

🔁 How to Reproduce Confirm this is your error

Create a ReentrantLock and one Condition, then call condition.await() with no lock.lock() first. Bare IMSE from enableWait/await, as captured. (Lab capture: OpenJDK Temurin 25.0.2.)

🛠️ Solutions (5 Ways to Fix)

Solution 1✓ Most common cause

lock, try, await, finally unlock — the canonical shape

👉 Use this when/if the await site simply forgot acquisition.

Awaiting requires the lock because await releases it internally. Wrapping acquisition and await in try/finally guarantees release even when the await is interrupted, keeping the owner bookkeeping consistent for every future waiter.

lock.lock(); try { while (!spaceAvailable) { notFull.await(); } // critical section } finally { lock.unlock(); }
Solution 2

Make the signalling side hold the lock too

👉 Use this when/if the producer calls signal() without acquiring the same lock.

signal() enforces the identical ownership rule and throws the same exception from a different frame. Beyond legality, holding the lock while signalling closes the missed-signal race where the waiter checks the predicate between the state change and the signal.

// producer lock.lock(); try { queue.add(item); notEmpty.signal(); // legal: we hold the lock } finally { lock.unlock(); }
Solution 3

Bind each Condition to the lock you actually hold

👉 Use this when/if two lock instances exist and the await uses the wrong one.

Conditions are children of one specific ReentrantLock. Holding lockA while awaiting a condition born from lockB fails the same isHeldExclusively check because lockB reports no owner. One lock per resource, all conditions derived from it, removes the mismatch class.

// WRONG: condB belongs to lockB, not the held lockA lockA.lock(); condB.await(); // throws // RIGHT lockB.lock(); try { condB.await(); } finally { lockB.unlock(); }
Solution 4

Drop down to BlockingQueue primitives

👉 Use this when/if the condition only expresses not-full/not-empty.

ArrayBlockingQueue ships with notFull and notEmpty conditions wired correctly inside AQS. Deleting bespoke condition plumbing eliminates both the exception and the subtle predicate bugs hand-rolled versions accumulate.

BlockingQueue<Slot> slots = new ArrayBlockingQueue<>(8); slots.put(job); // awaits space, correctly Slot done = slots.take(); // awaits availability
Solution 5

DEV ONLY: assert-and-ignore the ownership failure

👉 Use this only in scratch code to see the failure repeat deterministically.

Catching the exception around await() leaves the thread neither queued nor parked, so the loop retries await forever, burning CPU while appearing idle. Ownership is structural — no amount of retry satisfies it.

// ANTI-PATTERN — do not ship while (true) { try { notFull.await(); // lock never held break; } catch (IllegalMonitorStateException e) { // spins forever } }

📋 Version Notes

Java 8

Same bare exception from enableWait; AQS internal line numbers differ substantially.

Java 11

Behavior unchanged; frame names stable (enableWait, await).

Java 21

Virtual threads may await Conditions without pinning carriers — j.u.c locks remain the recommended primitive on Loom-based services.

🛡️ How to Prevent This Next Time

Standardize the lock/try/finally/unlock template in a team lint rule, keep Conditions private next to their Lock, and route inter-thread handoff through BlockingQueue/SynchronousQueue so bespoke await sites are rare and reviewed.