🔴 The Error You're Seeing
Confirm this matches your console output. If it does, you're in the right place.
// 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)
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();
}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));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(); }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
}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
Same bare exception; tryRelease/release frames sit at lower line numbers.
No semantic change; AQS refactor kept the owner comparison identical.
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.