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:
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:
- Single wait set: Producers and consumers share one waiting room.
notifyAll()wakes up ALL waiters, even those that can't proceed. - **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 expensivenotifyAll(). - No timed waits:
wait()blocks forever. What if you want to give up after 5 seconds? - 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
- **Create a
ReentrantLock**: This replacessynchronized. You explicitlylock()andunlock()(always in afinallyblock). - **Create
Conditionobjects from the lock**:lock.newCondition()gives you a named waiting queue. Create as many as you need! - 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. - Signal a condition: Call
condition.signal()(wake one) orcondition.signalAll()(wake all) to notify threads waiting on that specific condition. - **Always await in a
whileloop**: 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:
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().**
- Waiter-1 acquires the lock (
lock.lock()). - Checks
orders.isEmpty()โ yes, it's empty. - 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).
- The lock is now free for other threads.
**Chef-1 calls addOrder("Pizza").**
- Chef-1 acquires the lock.
- Checks
orders.size() == maxCapacityโ no, there's room. - Adds "Pizza" to the queue.
- 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.
- Chef-1 calls
lock.unlock():
- The lock is released.
- Waiter-1 is next in the entry queue โ it acquires the lock and wakes up.
- Waiter-1 resumes after
await(), re-checks the while condition (orders.isEmpty()is nowfalse), and takes the "Pizza" order. - Waiter-1 calls
notFull.signal()(in case any chefs are waiting for space) and unlocks.
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 When | Don'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" conditions | You can make the data immutable or thread-confined |
| Building custom synchronizers (blocking queues, barriers) |
Common Mistakes & Gotchas
- **Using
ifinstead ofwhilefor the condition check.** Spurious wakeups are real! Afterawait()returns, the condition might not actually be true. Always re-check in awhileloop. - **Forgetting to unlock in a
finallyblock.** If an exception occurs betweenlock()andunlock(), the lock is held forever, and every other thread deadlocks. Always use try/finally. - **Calling
signal()instead ofsignalAll()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. UsesignalAll()when in doubt, orsignal()only when you're sure exactly one waiter will be able to proceed. - **Confusing
Condition.await()withThread.sleep().**await()releases the lock and can be signaled.sleep()does NOT release any lock and cannot be signaled. - Not understanding reentrancy.
ReentrantLockcan 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
- What happens if you call
condition.signal()but no thread is currently waiting on that condition? Is anything lost?
- Why must
await()always be in awhileloop and not anifstatement? What's a "spurious wakeup"?
- Can you create two
Conditionobjects 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 forsynchronized+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
whileloop** (notif) to handle spurious wakeups, and **always unlock infinally** 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.