Java Concurrency LLD: Master ReentrantLock and Condition for Precise Thread Signaling
In senior Java LLD interviews, designing thread-safe components like a bounded buffer or rate limiter requires precise thread signaling. If you are still relying on synchronized blocks and Object.wait(), you are likely failing the concurrency bar at Big Tech.
I built javalld.com while prepping for senior roles — complete LLD problems with execution traces, not just theory.
The Mistake Most Candidates Make
- Using intrinsic locks with
Object.wait(): This forces all threads (producers and consumers) into a single wait-set, requiring a broadnotifyAll()that causes massive thread contention. - Ignoring spurious wakeups: Failing to re-evaluate the state condition in a loop when using time-bound wait methods.
- Lacking time precision: Relying on
Thread.sleep()or imprecise millisecond wait timeouts, which degrades system throughput under heavy load.
The Right Approach
- Core mental model: Decouple the mutual exclusion lock from conditional signaling by using dedicated wait-sets for distinct business conditions.
- Key entities/classes:
ReentrantLock,Condition,TimeUnit. - Why it beats the naive approach: It eliminates spurious wakeup bugs and allows you to target and wake up only the exact subset of threads that can actually make progress.
The Key Insight (Code)
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
public Object poll(long timeout, TimeUnit unit) throws InterruptedException {
lock.lock();
try {
long nanos = unit.toNanos(timeout);
while (queue.isEmpty()) {
if (nanos <= 0L) return null; // Timeout reached safely
nanos = notEmpty.awaitNanos(nanos); // Spurious wakeup safe
}
return queue.poll();
} finally {
lock.unlock();
}
}
Key Takeaways
- Multiple Conditions per Lock:
ReentrantLockallows creating separateConditioninstances (e.g.,notFullandnotEmpty) to signal specific thread groups. - Time-Remaining Tracking:
Condition.awaitNanos(nanos)returns the remaining time, letting you safely loop and resume waiting without losing track of the original timeout. - Targeted Signaling: Calling
condition.signal()wakes up exactly one thread waiting on that specific condition, eliminating the CPU overhead of globalnotifyAll().
Full working implementation with execution trace available at https://javalld.com/learn/condition-await
Top comments (1)
Clean writeup — the awaitNanos loop returning remaining time is the detail most people miss, and it's exactly why you don't lose the original timeout across spurious wakeups.
Two follow-ups interviewers tend to push on:
signal() vs signalAll() safety: with dedicated notFull/notEmpty conditions, signal() is correct because every waiter on that condition can actually make progress, so waking one is enough. The trap is a single shared condition for multiple predicates — then signal() can wake a thread that still can't proceed while the one that could stays asleep (a lost wakeup / effective deadlock). Rule of thumb: one condition per predicate → signal() is safe; shared condition → you're forced back to signalAll().
Interrupt semantics: awaitNanos throws InterruptedException but reacquires the lock before propagating, so your finally { lock.unlock() } still runs correctly under the lock — worth saying out loud, since "what happens if this thread is interrupted mid-wait?" is a common probe. (And signal()/await() must be called while holding the lock, or you get IllegalMonitorStateException.)