🔴 The Error You're Seeing
Confirm this matches your console output. If it does, you're in the right place.
Exception in thread "waiter-thread" java.lang.InterruptedException
at java.base/java.lang.Object.wait0(Native Method)
at java.base/java.lang.Object.wait(Object.java:389)
at java.base/java.lang.Object.wait(Object.java:351)
at com.devinhyderabad.sync.TaskCoordinator.waitForSignal(TaskCoordinator.java:22)⚡ Quick Fix Works 80% of the time
Wrap the wait in a predicate loop, catch the interrupt, restore the flag, and let the loop decide whether to bail out.
synchronized (lock) {
while (!ready) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return; // shutdown path
}
}
}🧠 Why this Happens
Tap to expand the deep technical explanation
Object.wait() atomically releases the monitor and parks the thread on the object wait set. An interrupt marks the thread, moves it off the wait set, and — after it reacquires the monitor — throws InterruptedException with the flag cleared. Because the wake-up happened before any notify, nothing about the condition changed; only a re-checked predicate can tell the difference between a real signal and an interruption.
The HITEC City Parking Spot Analogy:
You nap in a waiting room until your name is called. A fire drill pulls you out instead — same chair, same room, but nobody called you, so before sitting back down you check whether the appointment still exists at all.
🔁 How to Reproduce Confirm this is your error
Spawn a thread that enters synchronized(lock) and calls lock.wait(); interrupt it from main after 150 ms. The trace rises through wait0(Native Method) exactly as shown. (Lab capture: OpenJDK Temurin 25.0.2.)
🛠️ Solutions (5 Ways to Fix)
Guarded-wait loop with restored interrupt flag
👉 Use this when/if you must stay on raw wait/notify and interruptions mean shutdown.
The while loop re-evaluates the condition after every wake-up — interrupt or spurious alike — so a signal can never be missed or double-applied. Restoring the flag preserves the cancellation request for enclosing frameworks.
synchronized (lock) {
while (!ready && !cancelled) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
cancelled = true;
}
}
}Replace the handshake with CountDownLatch
👉 Use this when/if the signal is one-shot and you do not need to pass data.
A latch encodes the whole wait/notify dance in one await() call with correct happens-before semantics built in, and its InterruptedException surfaces at a clearly owned boundary.
CountDownLatch ready = new CountDownLatch(1);
// waiter
ready.await();
// signer
ready.countDown();Move to ReentrantLock plus Condition.await
👉 Use this when/if multiple wait conditions share one lock.
Each Condition gets its own wait set, so price-drop waiters are never woken by stock alerts. The same interrupted-contract applies, but signals become precise and the lock is explicit rather than implicit in the monitor keyword.
ReentrantLock lock = new ReentrantLock();
Condition priced = lock.newCondition();
lock.lock();
try {
while (!priceOk) {
priced.await();
}
} finally {
lock.unlock();
}Use timed waits to bound every park
👉 Use this when/if a missed notify must not hang the feature forever.
wait(2_000) turns an indefinite sleep into a periodic re-check. Combined with the predicate loop it converts deadlocks into slow paths you can measure, alert on, and debug with ordinary logs.
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
synchronized (lock) {
while (!ready && System.nanoTime() < deadline) {
lock.wait(500);
}
}DEV ONLY: catch-and-ignore inside the synchronized block
👉 Use this only to demo the bug — the waiter keeps waiting and can never be cancelled.
Swallowing the interrupt leaves the thread parked again on the next loop iteration with the flag consumed. shutdownNow() then looks broken because the coordinator ignores every cancellation request forever.
// ANTI-PATTERN — do not ship
synchronized (lock) {
try {
lock.wait();
} catch (InterruptedException ignored) { }
// still waiting; unstoppable
}📋 Version Notes
Same contract; the throwing frame reads Object.wait(Native Method) without the intermediate wait0 line.
Trace gains the wait0 Native Method frame shown above; behavior unchanged.
Virtual threads parking in Object.wait pin the carrier until JDK 24 (JEP 491) — prefer j.u.c primitives on 21.
JEP 491 removes monitor pinning; Object.wait on a virtual thread unmounts the carrier like any j.u.c park.
🛡️ How to Prevent This Next Time
Prefer java.util.concurrent coordination tools (latches, queues, futures) over raw monitors; when wait/notify is unavoidable, enforce the predicate-loop-plus-flag-restore idiom via review checklist and a stress test that interrupts coordinators repeatedly.