🔴 The Error You're Seeing

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

ERROR LOG// DOC-DERIVED — canonical trace shape; line numbers vary by JDK build Exception in thread "main" java.lang.IllegalMonitorStateException at java.base/java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:175) at java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1010) at java.base/java.util.concurrent.locks.ReentrantLock.unlock(ReentrantLock.java:466) at com.devinhyderabad.cache.CacheStore.evict(CacheStore.java:27)

⚡ Quick Fix Works 80% of the time

Ensure every unlock() is reached only after a successful lock() on the identical instance — lock immediately before try, unlock as the first statement of finally.

cacheLock.lock(); // acquire on THIS instance try { cache.remove(key); } finally { cacheLock.unlock(); // same instance, guaranteed path }

🧠 Why this Happens

Tap to expand the deep technical explanation

ReentrantLock tracks its owner as a single AQS state field plus an exclusiveOwnerThread reference. unlock() walks release → tryRelease, which compares exclusiveOwnerThread against the current thread and subtracts from the hold count; a zero match fails and throws the bare IllegalMonitorStateException. Because reentrancy stacks holds, a double unlock on the same thread also lands here — the second decrement finds a foreign owner (null).

The HITEC City Parking Spot Analogy:

Returning a borrowed library book to the wrong branch: the librarian checks their ledger, your card is not in it, and the transaction stops before anything is shelved.

🔁 How to Reproduce Confirm this is your error

Call reentrantLock.unlock() on a freshly created ReentrantLock from main. Bare IMSE from tryRelease/release/unlock. Double-unlock inside one held region reproduces it on the second call. (Canonical wording; not a lab capture.)

🛠️ Solutions (5 Ways to Fix)

Solution 1✓ Most common cause

Adopt the lock-before-try, unlock-first-in-finally idiom

👉 Use this when/if unlocks drifted out of their try blocks during refactors.

Placing lock() on the line before try and unlock() as the first finally statement creates a visual contract reviewers can verify at a glance. Any early return, exception, or continue still passes through exactly one matching unlock.

lock.lock(); try { mutateSharedState(); } finally { lock.unlock(); }
Solution 2

Extract an executeWithLock helper

👉 Use this when/if many call sites repeat manual pairing and drift creeps in.

Centralizing acquire/release in one higher-order method means application code can no longer forget pairing — the helper’s finally owns it. Supplier and Runnable overloads cover value-returning and void sections.

<T> T withLock(Supplier<T> body) { lock.lock(); try { return body.get(); } finally { lock.unlock(); } } // usage var value = withLock(() -> map.remove(key));
Solution 3

One lock instance per resource, injected not created ad hoc

👉 Use this when/if two modules each instantiate their own ReentrantLock for the same resource.

Two distinct ReentrantLock objects guarding one map provide zero mutual exclusion, and unlocking instance A while holding instance B throws this exception. Declaring the lock as a singleton bean or static final field ties all participants to the same owner ledger.

// shared module public final class StoreLock { public static final ReentrantLock INSTANCE = new ReentrantLock(); } // everywhere else StoreLock.INSTANCE.lock(); try { /* ... */ } finally { StoreLock.INSTANCE.unlock(); }
Solution 4

Match ReadWriteLock views exactly

👉 Use this when/if the code acquires readLock but releases writeLock (or vice versa).

ReentrantReadWriteLock hands out two sibling views; each maintains separate AQS accounting. Acquiring the read view and unlocking the write view fails the owner check instantly, and the fix is symmetric pairing of the same view reference.

rw.readLock().lock(); try { return snapshot; } finally { rw.readLock().unlock(); // same view that locked }
Solution 5

DEV ONLY: probe-and-swallow around unlock

👉 Use this only to demonstrate how the owner check behaves — never ship it.

Catching the exception around unlock leaves the lock permanently held by whichever thread succeeded, converting the next reader into a livelocked bystander. The exception is the only signal that pairing broke; suppressing it trades a loud bug for a silent wedge.

// ANTI-PATTERN — do not ship try { lock.unlock(); } catch (IllegalMonitorStateException ignored) { // lock now leaked; everyone else blocks forever }

📋 Version Notes

Java 8

Same bare exception; tryRelease/release frames sit at lower line numbers.

Java 11

No semantic change; AQS refactor kept the owner comparison identical.

Java 21

Unchanged; ReentrantLock remains unpinned under virtual threads, unlike synchronized.

🛡️ How to Prevent This Next Time

Enforce the pairing idiom with a lint rule (or ErrorProne custom check), inject shared locks as singletons, and add a stress test that hammers the guarded resource concurrently — pairing bugs surface as IMSE storms within seconds under load.