LLD
Machine Coding
Interview Course
Java โ€ข Interview Prep
๐Ÿ”„ Concurrency Deep Dive/

ReentrantLock & Condition: Advanced Wait/Notify

Lesson 5 of 8

ReentrantLock & Condition: Advanced Wait/Notify


Before We Start โ€” What You Need to Know

You've learned Semaphores for limiting concurrency. But what about scenarios where threads need to wait for a specific condition to become true before proceeding? Like: "Wait until the queue is not empty" or "Wait until there's space in the buffer."

Java's synchronized + wait()/notify() can do this, but it's clunky and limited. ReentrantLock + Condition is the modern, more powerful replacement. It gives you multiple wait queues on a single lock, explicit lock management, and features like timed waits and interruptibility.

You should know: what synchronized does, the idea of a thread "waiting" for something, and the producer-consumer pattern concept.


What is it? (The Analogy)

Imagine a restaurant kitchen with a chef and a waiter. There's a shelf between them (the buffer). The chef puts finished dishes on the shelf. The waiter picks them up and serves them.

Problem 1: The waiter walks up to the shelf โ€” it's empty. What does the waiter do? Stand there spinning in circles checking every millisecond? (That wastes energy!) No โ€” the waiter sits down and says "Wake me up when there's food." This is **condition.await()**.

Problem 2: The shelf is full (max 5 dishes). The chef has another dish ready but there's no room. The chef sits down and says "Wake me up when there's space." Another condition.await() โ€” but a *different* condition.

Problem 3: The waiter takes a dish. Now there's space! The waiter calls out "Hey chef, there's room now!" This is **condition.signal()**.

With plain synchronized/wait/notify, there's only ONE waiting room. Both the chef and the waiter wait in the same room, and when you call notify(), you might wake up the wrong person. With Condition, you get separate waiting rooms: one for "waiting for food" and one for "waiting for space." You can signal exactly the right group.


The Problem It Solves

Here's the classic bounded buffer with synchronized โ€” and its problems:

java
1// CLUNKY: Using synchronized + wait/notify
2class OldBoundedBuffer<T> {
3    private final Object[] items;
4    private int count, putIndex, takeIndex;
5
6    public OldBoundedBuffer(int capacity) {
7        items = new Object[capacity];
8    }
9
10    public synchronized void put(T item) throws InterruptedException {
11        while (count == items.length) {
12            wait();  // Wait for space... but WHO wakes us up?
13        }
14        items[putIndex] = item;
15        putIndex = (putIndex + 1) % items.length;
16        count++;
17        notifyAll();  // Wake up EVERYONE โ€” wasteful!
18        // We want to wake up consumers, but we also wake up
19        // other producers who can't do anything yet.
20    }
21
22    public synchronized T take() throws InterruptedException {
23        while (count == 0) {
24            wait();  // Wait for items... same waiting room as producers!
25        }
26        @SuppressWarnings("unchecked")
27        T item = (T) items[takeIndex];
28        takeIndex = (takeIndex + 1) % items.length;
29        count--;
30        notifyAll();  // Wake up EVERYONE again โ€” wasteful!
31        return item;
32    }
33}

Problems with this approach:

  1. Single wait set: Producers and consumers share one waiting room. notifyAll() wakes up ALL waiters, even those that can't proceed.
  2. **Can't use notify() safely**: notify() wakes only one thread โ€” but it might wake a producer when we need a consumer (or vice versa). That's why we're forced to use expensive notifyAll().
  3. No timed waits: wait() blocks forever. What if you want to give up after 5 seconds?
  4. No try-lock: With synchronized, you either get the lock or block โ€” no way to "try" and walk away.

How it works โ€” Step by Step

  1. **Create a ReentrantLock**: This replaces synchronized. You explicitly lock() and unlock() (always in a finally block).
  2. **Create Condition objects from the lock**: lock.newCondition() gives you a named waiting queue. Create as many as you need!
  3. Wait on a condition: Call condition.await() while holding the lock. The thread releases the lock and goes to sleep in that specific condition's queue.
  4. Signal a condition: Call condition.signal() (wake one) or condition.signalAll() (wake all) to notify threads waiting on that specific condition.
  5. **Always await in a while loop**: Spurious wakeups can happen (the JVM/OS might wake your thread for no reason). Always re-check the condition after waking up.

Aha! The key insight is separate condition queues. With one lock, you can have a "notEmpty" condition (consumers wait here) and a "notFull" condition (producers wait here). When a producer adds an item, it signals "notEmpty" โ€” waking up only consumers. When a consumer removes an item, it signals "notFull" โ€” waking up only producers. No wasted wakeups!


Let's Build It Together

Let's build a food order queue for a restaurant โ€” chefs produce orders, waiters pick them up:

java
1import java.util.LinkedList;
2import java.util.Queue;
3import java.util.concurrent.locks.Condition;
4import java.util.concurrent.locks.ReentrantLock;
5
6public class OrderQueue {
7
8    private final Queue<String> orders = new LinkedList<>();
9    private final int maxCapacity;
10
11    // The lock that protects our shared state
12    private final ReentrantLock lock = new ReentrantLock(true); // fair
13
14    // TWO separate conditions โ€” this is the key improvement!
15    private final Condition notEmpty = lock.newCondition(); // waiters wait here
16    private final Condition notFull = lock.newCondition();  // chefs wait here
17
18    public OrderQueue(int maxCapacity) {
19        this.maxCapacity = maxCapacity;
20    }
21
22    /**
23     * Chef adds an order. Blocks if the queue is full.
24     */
25    public void addOrder(String order) throws InterruptedException {
26        lock.lock();  // Acquire the lock explicitly
27        try {
28            // MUST be a while loop โ€” not if โ€” because of spurious wakeups!
29            while (orders.size() == maxCapacity) {
30                System.out.println(Thread.currentThread().getName()
31                    + ": Kitchen shelf is full! Waiting for space...");
32                notFull.await();  // Release lock and sleep on "notFull" queue
33                // When we wake up, we automatically re-acquire the lock
34            }
35
36            orders.add(order);
37            System.out.println(Thread.currentThread().getName()
38                + ": Prepared [" + order + "] โ€” "
39                + orders.size() + "/" + maxCapacity + " on shelf");
40
41            // Signal ONE waiter: "Hey, there's food now!"
42            notEmpty.signal();  // Only wakes a thread in the notEmpty queue!
43        } finally {
44            lock.unlock();  // ALWAYS unlock in finally
45        }
46    }
47
48    /**
49     * Waiter picks up an order. Blocks if the queue is empty.
50     */
51    public String takeOrder() throws InterruptedException {
52        lock.lock();
53        try {
54            while (orders.isEmpty()) {
55                System.out.println(Thread.currentThread().getName()
56                    + ": No orders ready! Waiting...");
57                notEmpty.await();  // Release lock and sleep on "notEmpty" queue
58            }
59
60            String order = orders.poll();
61            System.out.println(Thread.currentThread().getName()
62                + ": Picked up [" + order + "] โ€” "
63                + orders.size() + "/" + maxCapacity + " on shelf");
64
65            // Signal ONE chef: "Hey, there's space now!"
66            notFull.signal();  // Only wakes a thread in the notFull queue!
67            return order;
68        } finally {
69            lock.unlock();
70        }
71    }
72
73    /**
74     * Try to take an order with a timeout โ€” don't wait forever!
75     */
76    public String takeOrderWithTimeout(long timeoutMs)
77            throws InterruptedException {
78        lock.lock();
79        try {
80            long nanosLeft = java.util.concurrent.TimeUnit.MILLISECONDS
81                                .toNanos(timeoutMs);
82            while (orders.isEmpty()) {
83                if (nanosLeft <= 0) {
84                    System.out.println(Thread.currentThread().getName()
85                        + ": Timed out waiting for orders!");
86                    return null;  // Give up
87                }
88                // awaitNanos returns remaining time (or negative if expired)
89                nanosLeft = notEmpty.awaitNanos(nanosLeft);
90            }
91            String order = orders.poll();
92            notFull.signal();
93            return order;
94        } finally {
95            lock.unlock();
96        }
97    }
98
99    public static void main(String[] args) {
100        OrderQueue queue = new OrderQueue(3); // Shelf holds 3 orders
101
102        // 2 Chefs (producers)
103        for (int i = 1; i <= 2; i++) {
104            final int chefNum = i;
105            new Thread(() -> {
106                String[] menu = {"Pizza", "Burger", "Sushi", "Taco", "Pasta"};
107                try {
108                    for (String dish : menu) {
109                        queue.addOrder(dish + " by Chef-" + chefNum);
110                        Thread.sleep((long) (Math.random() * 500));
111                    }
112                } catch (InterruptedException e) {
113                    Thread.currentThread().interrupt();
114                }
115            }, "Chef-" + i).start();
116        }
117
118        // 3 Waiters (consumers)
119        for (int i = 1; i <= 3; i++) {
120            new Thread(() -> {
121                try {
122                    while (true) {
123                        String order = queue.takeOrderWithTimeout(3000);
124                        if (order == null) break; // No more orders
125                        // Simulate serving time
126                        Thread.sleep((long) (Math.random() * 800));
127                    }
128                } catch (InterruptedException e) {
129                    Thread.currentThread().interrupt();
130                }
131            }, "Waiter-" + i).start();
132        }
133    }
134}

What Happens Under the Hood

Let's trace a complete cycle of the producer-consumer interaction:

**State: Queue is EMPTY. Waiter-1 calls takeOrder().**

  1. Waiter-1 acquires the lock (lock.lock()).
  2. Checks orders.isEmpty() โ€” yes, it's empty.
  3. Calls notEmpty.await():

- Waiter-1 atomically releases the lock and is placed into the notEmpty condition queue.

- Waiter-1's thread is parked (sleeping, zero CPU usage).

  1. The lock is now free for other threads.

**Chef-1 calls addOrder("Pizza").**

  1. Chef-1 acquires the lock.
  2. Checks orders.size() == maxCapacity โ€” no, there's room.
  3. Adds "Pizza" to the queue.
  4. Calls notEmpty.signal():

- Waiter-1 is moved from the notEmpty condition queue to the lock's entry queue (it needs to re-acquire the lock before proceeding).

- Waiter-1 is NOT yet running โ€” it's waiting for the lock.

  1. Chef-1 calls lock.unlock():

- The lock is released.

- Waiter-1 is next in the entry queue โ€” it acquires the lock and wakes up.

  1. Waiter-1 resumes after await(), re-checks the while condition (orders.isEmpty() is now false), and takes the "Pizza" order.
  2. Waiter-1 calls notFull.signal() (in case any chefs are waiting for space) and unlocks.
java
1Lock Entry Queue:   (threads waiting to acquire the lock)
2notEmpty Queue:     (consumers waiting for items)
3notFull Queue:      (producers waiting for space)
4
5signal() moves a thread from a Condition queue -> Lock Entry Queue
6await()  moves a thread from running -> Condition queue (and releases lock)

**Danger zone: Never call await() or signal() without holding the lock!** It will throw IllegalMonitorStateException. The lock must be held because the condition check and the await must happen atomically โ€” otherwise another thread could add an item between your "is it empty?" check and your await() call, and you'd sleep forever even though there's data.


When to Use vs When NOT to Use

Use ReentrantLock + Condition WhenDon't Use When
You need multiple wait conditions on one lock (producer-consumer)Simple mutual exclusion (synchronized is simpler)
You need timed waits (awaitNanos, await(time, unit))You just need to limit concurrency (use Semaphore)
You need interruptible locking (lockInterruptibly())You need a one-shot latch (use CountDownLatch)
You need try-lock semantics (tryLock())The shared state is a simple counter (use atomics)
Complex state machines with multiple "wait for X" conditionsYou can make the data immutable or thread-confined
Building custom synchronizers (blocking queues, barriers)

Common Mistakes & Gotchas

  • **Using if instead of while for the condition check.** Spurious wakeups are real! After await() returns, the condition might not actually be true. Always re-check in a while loop.
  • **Forgetting to unlock in a finally block.** If an exception occurs between lock() and unlock(), the lock is held forever, and every other thread deadlocks. Always use try/finally.
  • **Calling signal() instead of signalAll() when multiple threads might be waiting.** signal() wakes only one thread. If you signal the "wrong" one (one that can't proceed because the condition doesn't apply to it), progress stalls. Use signalAll() when in doubt, or signal() only when you're sure exactly one waiter will be able to proceed.
  • **Confusing Condition.await() with Thread.sleep().** await() releases the lock and can be signaled. sleep() does NOT release any lock and cannot be signaled.
  • Not understanding reentrancy. ReentrantLock can be locked multiple times by the same thread (the lock keeps a count). You must unlock the same number of times. This is useful when a locked method calls another locked method.

Interview Tip

When asked about wait()/notify() vs Condition, highlight three advantages: (1) Multiple conditions per lock โ€” the killer feature for producer-consumer patterns, (2) Timed and interruptible waits โ€” await(5, SECONDS) returns false on timeout instead of blocking forever, (3) Explicit lock/unlock โ€” makes the scope of locking clear and allows try-lock patterns. Then code a bounded blocking queue as your go-to example โ€” it's the canonical use case and appears in almost every concurrency interview.


Quick Quiz

  1. What happens if you call condition.signal() but no thread is currently waiting on that condition? Is anything lost?
  1. Why must await() always be in a while loop and not an if statement? What's a "spurious wakeup"?
  1. Can you create two Condition objects from two *different* locks and use them together to coordinate producers and consumers? Why or why not?

Summary โ€” Key Takeaways

  • **ReentrantLock + Condition** is the modern replacement for synchronized + wait()/notify(), offering multiple condition queues, timed waits, and try-lock semantics.
  • Multiple conditions on one lock is the key advantage: producers wait on "notFull", consumers wait on "notEmpty", and signals go to the right group.
  • **Always await in a while loop** (not if) to handle spurious wakeups, and **always unlock in finally** to prevent deadlocks.
  • **signal() moves a thread from the condition queue to the lock's entry queue** โ€” the thread still needs to re-acquire the lock before it can run.