The bounded blocking queue is one of the most-asked concurrency problems in interviews, and for a sneaky reason: a correct single-threaded queue is trivial, but a correct concurrent, blocking one forces you to understand locks, waiting, and signalling down to the last line.
In this post we build it twice — first with the low-level synchronized + wait/notify primitives, then with the more expressive ReentrantLock + Condition — and cover the theory you need to explain every line in a round.
The problem
Build a queue with a fixed capacity N. If it's full, put blocks until space frees up. If it's empty, take blocks until an item arrives.
This is the producer–consumer problem in its purest form. Producers add items; consumers remove them. The queue is the buffer between them, and its two blocking rules are what make it interesting:
- Full → block the producer. A producer that arrives when the queue is full must wait — not fail, not drop the item — until a consumer makes room.
- Empty → block the consumer. A consumer that arrives when the queue is empty must wait until a producer supplies an item.
Java ships a ready-made BlockingQueue, but the whole point of the exercise is to build the blocking yourself — which forces you to understand locks, waiting, and signalling. We'll build it twice: first with the low-level synchronized + wait/notify primitives, then with the more expressive ReentrantLock + Condition. But first, the theory that makes both make sense.
Threads, waiting, and sleeping
Creating a thread
There's one idiom to remember, and it's the modern one: a lambda passed to Thread.
new Thread(() -> {
// code the thread runs
}).start();
A Runnable is a functional interface with one method, so a lambda is a Runnable. You wrap it in a Thread and call start().
The classic trap
It's start(), never run(). start() launches a new thread and runs the code concurrently. run() just calls the method on the current thread — no new thread, no concurrency at all. Calling run() when you meant start() is the most common beginner mistake.
start vs join
Two methods cover almost everything: start() launches a thread; join() waits for one to finish.
start() is "fire and forget" — it launches the thread and immediately moves to the next line without waiting. join() is the opposite: the calling thread pauses until the joined thread completes. You use join() when the main thread needs to wait for all its workers before continuing — for example, to print a result after all the work is done.
No close, no stop
A thread has no close() or end() — it terminates on its own when its run() method returns. (stop() exists but is deprecated and dangerous — it kills a thread mid-execution, potentially corrupting shared state. Never use it.) To ask a thread to stop early, you interrupt() it — a cooperative signal the thread chooses to honour, not a forced kill. And note: a thread parked in wait() stays alive, so the JVM won't exit while it's blocked.
wait vs sleep — a favourite interview question
Both pause a thread, but the difference is the lock, and it's the whole point.
wait() |
sleep() |
|
|---|---|---|
| Lock | Releases the lock | Keeps the lock |
| Wakes by | another thread's notify() |
the timer elapsing |
| Duration | indefinite — until notified | a fixed time you set |
| Called | only inside synchronized |
anywhere |
| Belongs to | Object |
Thread (static) |
| Purpose | wait for a condition | pause for a time |
The one thing to remember
wait() releases the lock; sleep() holds it. That's because wait() is coordinating with other threads — it must release the lock so another thread can change the condition and wake it. sleep() is just a timed pause, so it has no reason to release anything and wakes itself. Put a sleep() inside a synchronized block and every other thread is locked out for the full duration; a wait() there lets them in.
Locks and the monitor
Every object in Java has an invisible built-in lock called its monitor. synchronized(obj) means: acquire obj's monitor on the way in, release it on the way out. Only one thread can hold a given object's monitor at a time — so if two threads reach synchronized(obj) for the same obj, one enters and the other waits.
The object itself does nothing special — it's a shared token that threads compete for. Think of a single key on a hook: to enter the room (the critical section) you must take the key; only one key exists, so only one person is inside at a time; you hang it back when you leave.
Which object you lock on = who competes
Two threads block each other only if they synchronize on the same object. Lock on a per-user object (like that user's own queue) and different users never block each other while same-user calls serialise. Lock on this (one shared instance) and everyone competes — correct, but coarse. So the object you choose is the granularity decision: same object → serialise, different objects → run in parallel.
A subtle point that matters later: a lock is about ownership, not execution. If the OS pauses a thread while it holds the lock, it still owns the lock — another thread can run but cannot enter the critical section until the owner resumes and exits. However threads interleave, only one is ever inside the block.
wait, notify, and the wait-set
A thread that holds the lock but can't proceed yet — a consumer on an empty queue — shouldn't spin, burning CPU checking the same condition. Instead it calls wait(): it goes to sleep, using no CPU, until another thread wakes it.
wait() releases the lock as it sleeps
This is the mechanism that makes everything work. wait() does two things atomically: releases the monitor, and puts the thread to sleep. Releasing is essential — without it, a sleeping thread would hold the lock forever and nobody could ever change the condition it's waiting on. Because wait() releases, the next thread can enter the same synchronized block and also wait. So many threads can be parked in wait() simultaneously, even though only one runs code at a time — each released the lock on its way to sleep.
The threads asleep on an object's monitor sit in its wait-set — a waiting room. Two calls wake them:
notify()— wakes one arbitrary thread from the wait-set.notifyAll()— wakes all threads in the wait-set.
A woken thread doesn't resume instantly — it must first re-acquire the lock (only one at a time), then continue from just after its wait() call. This "wake, re-acquire, re-check" cycle is why the next section exists.
Why while, not if
This is the single most-probed detail in the entire problem. Guard every wait() with while, never if.
The rule: when a thread wakes from wait(), the condition it waited for may no longer be true — so it must re-check. An if checks once, before sleeping, and never again; the woken thread falls straight through. A while re-checks on every wake and only proceeds when the condition actually holds.
The scenario that breaks if
Queue empty, four consumers all asleep in wait(). A producer adds one item and calls notifyAll(). All four wake and compete for the lock:
- Consumer 1 gets the lock, takes the item. Queue is empty again. Releases the lock.
- Consumer 2 gets the lock next. With
if, it does not re-check — it runspoll()on a now-empty queue → getsnull. Bug. - Consumers 3 and 4: same — they poll an empty queue.
With while, consumers 2, 3, and 4 loop back, re-check "is it empty?", find it is, and go back to sleep. Only the one consumer that can actually proceed does.
The root cause, stated once
Because the lock serialises the woken threads, they run one at a time — which means the earlier ones change the state before the later ones get their turn. So each woken thread must re-verify that the condition is still true for it. notifyAll() wakes everyone who might be able to proceed; while lets each one decide whether it actually can. They're partners: wake wide, then re-check.
A second, independent reason
The JVM is explicitly permitted to wake a thread from wait() with no notify() at all — a spurious wakeup. So even with one waiter, an if could let it proceed when nothing is ready. Either reason alone forces while; together they make it absolute.
Why testing won't catch it
With if, the bug only appears under specific timing — multiple waiters woken for one item. On a roomy queue or a lucky run, it looks fine, then fails randomly in production. A concurrency bug that "doesn't show up when I run it" is still a bug. Make it correct by construction with while — you can't test your way to confidence here.
Building it with synchronized
Now the theory pays off. A plain ArrayDeque holds the items (only BlockingQueue is off-limits — a regular queue for storage is fine, and ArrayDeque is the array-backed, allocation-light choice). We wrap it in synchronized + wait/notifyAll to provide the blocking.
class BoundedBlockingQueue {
private final Queue<Message> q = new ArrayDeque<>();
private final int capacity;
BoundedBlockingQueue(int capacity) {
this.capacity = capacity;
}
void put(Message msg) throws InterruptedException {
synchronized (this) {
while (q.size() >= capacity) { // full -> block the producer
wait();
}
q.offer(msg); // add first...
notifyAll(); // ...then wake consumers
}
}
Message take() throws InterruptedException {
synchronized (this) {
while (q.isEmpty()) { // empty -> block the consumer
wait();
}
Message msg = q.poll(); // remove first...
notifyAll(); // ...then wake producers
return msg; // hand the item back
}
}
}
Three things carry the correctness, and each maps to a theory section above:
while, notif, on both guards — woken threads re-check the condition.- Modify, then notify. Add the item before
notifyAll(), so a woken consumer finds it there. Notifying before the change wastes the wakeup. notifyAll(), notnotify()— the next section explains why this specific choice is forced here.
Don't discard the item
take() must return what it removed. poll() hands back the item, but if the method is void and ignores the return, items go in and vanish — the consumer never receives anything. Capture it and return it.
notify vs notifyAll — the lost wakeup
Why notifyAll() and not the cheaper notify()? Because in this design, producers and consumers wait on the same monitor for different reasons — and notify() wakes one arbitrary thread, which might be the wrong kind.
Suppose the wait-set holds both a blocked producer (waiting for space) and blocked consumers (waiting for items). A consumer removes an item, opening a slot, and calls notify(). If notify() happens to wake another consumer instead of the producer, that consumer re-checks, finds nothing useful for it, and goes back to sleep — while the producer that could have used the new slot stays asleep. The signal was consumed by the wrong thread. Under the wrong interleaving, work stalls with progress available: a lost wakeup, and possibly deadlock.
The rule
Use notifyAll() whenever waiters on the same monitor are waiting for different conditions — because you can't control which one notify() picks, and picking wrong loses the signal. notify() is only safe when every waiter is interchangeable (all waiting for the identical condition). In this queue, producers and consumers want different things, so notifyAll() is mandatory. Its cost is some wasted wakeups — threads that wake, re-check, and re-sleep — but correctness is guaranteed.
Those wasted wakeups are the one blemish on the synchronized version: adding an item wakes everyone, though only consumers can act. With a single wait-set there's no way to wake just the consumers. Fixing that is exactly what Condition is for — but first, the lock it lives on.
synchronized vs ReentrantLock
Both give mutual exclusion — one thread in the critical section at a time — and both are reentrant (a thread holding the lock can re-acquire it without deadlocking itself). So why reach for ReentrantLock? For the flexibility it adds.
| Capability | synchronized |
ReentrantLock |
|---|---|---|
| Mutual exclusion | yes | yes |
| Lock / unlock | automatic, block-scoped | manual — needs try/finally |
| Wait-sets | one (wait/notify) |
many (Conditions) |
| Try without blocking | no | yes (tryLock()) |
| Interruptible acquire | no | yes (lockInterruptibly()) |
| Fairness option | no | yes (new ReentrantLock(true)) |
| Inspect state | no | yes (isLocked(), …) |
Why it matters for this problem
The reason we switch is one row: multiple wait-sets. The synchronized version had a single wait-set, so notifyAll() woke producers and consumers together and most went back to sleep. ReentrantLock lets us create two separate wait-sets (Conditions) — one for producers, one for consumers — so we wake exactly the right group. None of the other features (tryLock, fairness, …) matter here; it's purely about getting separate wait-sets.
The trade-off
synchronized is simpler and safer — it auto-releases, even on exception, so you can't forget to unlock. ReentrantLock is manual: you must release it yourself in a finally, or an exception leaves the lock held forever. You trade automatic safety for extra power. Default to synchronized unless you need what the lock adds.
Locks and Conditions
Keep three things distinct, because they're easy to blur:
| Thing | How many | What it does |
|---|---|---|
Lock (ReentrantLock) |
one | Controls who executes — one thread in the critical section at a time |
| Condition | many, per lock | A waiting room — a thread sleeps here (releasing the lock) until its state is true |
Queue (ArrayDeque) |
one | The data being protected |
There is one lock guarding the whole queue — one gate, shared by put and take, since both touch the same data. A Condition is a different thing: not a lock, but a waiting room created from the lock. You can make several.
ReentrantLock lock = new ReentrantLock(); // ONE lock
Condition notFull = lock.newCondition(); // room: "waiting for space"
Condition notEmpty = lock.newCondition(); // room: "waiting for an item"
The Condition operations mirror wait/notify exactly — same behaviour, just per-room:
| Object monitor | Condition | Meaning |
|---|---|---|
wait() |
await() |
sleep in this room (releases the lock) |
notify() |
signal() |
wake one thread in this room |
notifyAll() |
signalAll() |
wake all threads in this room |
The distinction to lock in
The lock answers "can I execute?" (is anyone else in the critical section?). A condition answers "can I proceed given the current state?" (I hold the lock, but is the queue actually ready?). Two different kinds of waiting: waiting for your turn (the lock) versus waiting for the state to be right (a condition). In put, lock.lock() waits for your turn; then while (full) notFull.await() waits for the state.
Read the names as what's awaited
notFull = "wait here until the queue becomes not full" (where producers sleep). notEmpty = "wait here until it becomes not empty" (where consumers sleep). Naming a condition after the state it waits for reads far better than naming it after the thread type — and reminds you it's a wait-set, not a lock.
The crossover
The one new idea: you await on your own condition, but signal the other one. A producer waits on notFull (it needs space) and, after adding an item, signals notEmpty to wake a consumer (there's now something to take). A consumer waits on notEmpty and, after removing an item, signals notFull to wake a producer (there's now room). You wake the side that your action just unblocked.
Building it with ReentrantLock
Same logic as before — same while guards, same modify-then-signal order — now with targeted signalling and manual locking.
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;
class BoundedBlockingQueue {
private final Queue<Message> q = new ArrayDeque<>();
private final int capacity;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // producers wait
private final Condition notEmpty = lock.newCondition(); // consumers wait
BoundedBlockingQueue(int capacity) {
this.capacity = capacity;
}
void put(Message msg) throws InterruptedException {
lock.lock(); // acquire BEFORE the try
try {
while (q.size() >= capacity) {
notFull.await(); // wait on my own condition
}
q.offer(msg);
notEmpty.signal(); // wake a consumer (the other side)
} finally {
lock.unlock(); // always release, on every path
}
}
Message take() throws InterruptedException {
lock.lock();
try {
while (q.isEmpty()) {
notEmpty.await();
}
Message msg = q.poll();
notFull.signal(); // wake a producer
return msg; // finally still runs before return
} finally {
lock.unlock();
}
}
}
The mandatory pattern
lock.lock();
try {
// everything done while holding the lock
} finally {
lock.unlock();
}
unlock() must be in finally
Unlike synchronized, ReentrantLock does not release automatically. If await() throws (an interruption) and unlock() isn't in a finally, the lock is held forever and every other thread deadlocks. finally runs on every exit — normal completion, exception, or return — so it's the only way to guarantee release. The return inside the try is fine: finally executes before the method actually returns.
Why lock() sits before the try, not inside it
If lock() were the first line inside the try and it threw, control would jump to finally, which would call unlock() on a lock never acquired — throwing IllegalMonitorStateException and masking the real error. With lock() before the try, a failed acquire never enters the try, so finally never runs. Plain lock() rarely throws, but lockInterruptibly() does — so this is the correct, consistent shape. The rule: acquire immediately before the try; the try represents holding the lock.
And because producers and consumers now wait in separate rooms, signal() (wake one) is enough and precise — a notEmpty.signal() can only wake a consumer, so there's no wrong-thread risk and no wasted wakeups. The lost-wakeup problem from the synchronized version is gone by construction.
Running it
To see it work, fire several producers and consumers at a small-capacity queue. Balanced counts (equal puts and takes) ensure every thread completes and the program terminates.
class Message {
int id; String content;
Message(int id, String content) { this.id = id; this.content = content; }
}
public static void main(String[] args) throws InterruptedException {
BoundedBlockingQueue queue = new BoundedBlockingQueue(2); // small -> we see blocking
List<Thread> threads = new ArrayList<>();
for (int p = 0; p < 4; p++) {
final int id = p; // snapshot for the lambda
Thread t = new Thread(() -> {
try { queue.put(new Message(id, "MC" + id)); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
threads.add(t); t.start();
}
for (int c = 0; c < 4; c++) {
Thread t = new Thread(() -> {
try { queue.take(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
threads.add(t); t.start();
}
for (Thread t : threads) t.join(); // wait for all to finish
System.out.println("All done.");
}
Run it and you'll see all items produced and consumed exactly once, in an unpredictable order (proof of real concurrency), the queue never exceeding capacity 2, and a clean exit after join(). To watch the blocking, drop capacity to 1 and add a print just before each wait()/await() — you'll see producers park when full and wake as consumers drain.
The complete program
Here is the whole thing in one file — the ReentrantLock version, a Message, and a main that prints what each thread does so you can watch the interleaving. Save it as BoundedBlockingQueueDemo.java and run it with a single command (JDK 11+).
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;
class Message {
final int id;
final String content;
Message(int id, String content) { this.id = id; this.content = content; }
}
class BoundedBlockingQueue {
private final Queue<Message> q = new ArrayDeque<>();
private final int capacity;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // producers wait here
private final Condition notEmpty = lock.newCondition(); // consumers wait here
BoundedBlockingQueue(int capacity) { this.capacity = capacity; }
void put(Message msg) throws InterruptedException {
lock.lock();
try {
while (q.size() >= capacity) notFull.await();
q.offer(msg);
notEmpty.signal();
} finally {
lock.unlock();
}
}
Message take() throws InterruptedException {
lock.lock();
try {
while (q.isEmpty()) notEmpty.await();
Message msg = q.poll();
notFull.signal();
return msg;
} finally {
lock.unlock();
}
}
}
public class BoundedBlockingQueueDemo {
public static void main(String[] args) throws InterruptedException {
BoundedBlockingQueue queue = new BoundedBlockingQueue(2); // small -> blocking is visible
List<Thread> threads = new ArrayList<>();
for (int p = 0; p < 4; p++) {
final int id = p;
Thread t = new Thread(() -> {
try {
queue.put(new Message(id, "MC" + id));
System.out.println("produced MC" + id);
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
threads.add(t);
t.start();
}
for (int c = 0; c < 4; c++) {
Thread t = new Thread(() -> {
try {
Message m = queue.take();
System.out.println("consumed " + m.content);
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
threads.add(t);
t.start();
}
for (Thread t : threads) t.join();
System.out.println("All done - queue never exceeded capacity 2.");
}
}
Run it:
java BoundedBlockingQueueDemo.java
One run prints something like the following. The order changes on every run — that unpredictability is the proof that the threads really are running concurrently — but every message is produced and consumed exactly once, and the program always exits cleanly:
produced MC1
produced MC3
consumed MC1
consumed MC3
produced MC2
produced MC0
consumed MC2
consumed MC0
All done - queue never exceeded capacity 2.
Revision cheat sheet
The night before a concurrency round, read only this.
Threads
new Thread(() -> {...}).start()—start()launches concurrently;run()does not (runs inline).join()waits for a thread to finish. Noclose()/end()— a thread ends whenrun()returns.- wait vs sleep:
wait()releases the lock and waits for a condition (until notified);sleep()keeps the lock and pauses for a set time (wakes itself).
Locks & waiting
- Every object has a monitor (lock).
synchronized(obj)= one thread holdsobj's monitor at a time. Which object you lock decides who competes. wait()releases the lock as it sleeps — so many threads can wait at once, and another thread can enter to change the condition.- Woken threads must re-acquire the lock and re-check — one at a time.
The two rules that carry the problem
while, notif, around everywait()/await(): many are woken, only some can proceed, so each must re-verify (plus spurious wakeups exist).notifyAll(), notnotify(), on a shared monitor with different waiter types:notify()may wake the wrong kind and lose the signal (deadlock).notify()is only safe when all waiters are interchangeable.
synchronized → ReentrantLock
- Both: mutual exclusion, reentrant.
synchronizedauto-unlocks (safe, less code);ReentrantLockis manual — alwayslock()→try { } finally { unlock() }, or you deadlock on exception. - The reason to switch: multiple wait-sets. One lock, many
Conditions (lock.newCondition()). - Lock = who executes. Condition = whether the state is ready.
await/signal/signalAllmirrorwait/notify/notifyAll. - Crossover:
awaiton your own condition,signalthe other. Producer waitsnotFull, signalsnotEmpty; consumer waitsnotEmpty, signalsnotFull. With separate conditions,signal()(wake one) is precise — no wasted wakeups.
The shape of both solutions
- put: lock →
whilefull, wait → offer → signal consumers → unlock. - take: lock →
whileempty, wait → poll → signal producers → return → unlock. - Storage is a plain
ArrayDeque(onlyBlockingQueueis banned).take()must return the removed item.
Both implementations are complete and correct, and every line traces back to a principle above. If you spot an error or a cleaner framing, I'd like to hear it — that's how the next detail gets sharpened.