Hand-rolling a bounded queue with wait/notify from first principles, extending it into a multi-consumer task-processing system, and the poison-pill technique for graceful shutdown.
Published September 23, 2026
Every other lesson in this course reaches for BlockingQueue and moves on. This one deliberately builds the same mechanism from wait()/notify() — the exercise interviewers use specifically to check whether you understand what BlockingQueue is doing underneath, not just that you know to import it.
class BoundedQueue<T> {
private final Queue<T> queue = new LinkedList<>();
private final int capacity;
BoundedQueue(int capacity) { this.capacity = capacity; }
synchronized void put(T item) throws InterruptedException {
while (queue.size() == capacity) { // WHILE, not if — see below
wait(); // releases the monitor lock, unlike sleep() — see wait() vs sleep() in Thread Basics & Lifecycle
}
queue.add(item);
notifyAll(); // wake any consumer(s) blocked in take()
}
synchronized T take() throws InterruptedException {
while (queue.isEmpty()) {
wait();
}
T item = queue.poll();
notifyAll(); // wake any producer(s) blocked in put()
return item;
}
}
Why while, not if, around the wait() call: this is the single most common bug in hand-written wait/notify code. notifyAll() wakes every waiting thread, not just one — if three consumers are all blocked in take()'s wait() and one item becomes available, notifyAll() wakes all three, but only one of them should actually get that item. With while, each woken thread re-checks the condition (queue.isEmpty()) before proceeding — two of the three find the queue empty again (the third one took it) and go back to waiting. With if, all three would proceed past the check and attempt queue.poll(), with two of them getting incorrect behavior (an empty poll, or worse, corrupting shared state if the check-then-act weren't otherwise protected).
Why notifyAll() rather than notify(): notify() wakes exactly one waiting thread, chosen arbitrarily by the JVM — with both producers and consumers potentially waiting on the same monitor (as here, since put() and take() share the same object's lock), notify() could wake a producer when a consumer was the one that needed to proceed (or vice versa), causing a spurious wakeup that immediately re-waits, or in pathological cases, a missed wakeup. notifyAll() is the safe default; notify() is a valid optimization only when you can prove every waiter is interchangeable.
class TaskProcessingSystem {
private final BoundedQueue<Task> queue;
private final List<Thread> consumers = new ArrayList<>();
private volatile boolean shuttingDown = false;
void submitTask(Task task) throws InterruptedException { queue.put(task); }
void startConsumers(int count, TaskHandler handler) {
for (int i = 0; i < count; i++) {
Thread consumer = new Thread(() -> {
while (!shuttingDown) {
try {
Task task = queue.take();
if (task == POISON_PILL) break; // graceful shutdown signal — see below
handler.process(task);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
consumers.add(consumer);
consumer.start();
}
}
}
Multiple consumer threads pulling from one shared BoundedQueue is exactly why the queue itself needs to be correctly synchronized (as above) — with a single consumer, a simpler design might get away with less rigor, but concurrent take() calls from several consumer threads racing against put() calls from producers is precisely the scenario the while+notifyAll() combination is required for.
The question "how do consumers know to stop when no more tasks are coming" has a clean, idiomatic answer: don't rely on a shared boolean flag alone (a consumer blocked inside take()'s wait() won't notice a flag change until it wakes up for an unrelated reason) — instead, enqueue a special poison pill sentinel value, one per consumer thread, once no more real tasks will be submitted:
void shutdown(int consumerCount) throws InterruptedException {
shuttingDown = true;
for (int i = 0; i < consumerCount; i++) {
queue.put(POISON_PILL); // guarantees every blocked consumer eventually wakes and sees a pill
}
}
Each consumer, on dequeuing a poison pill, breaks its loop and exits — since the pill goes through the exact same put()/take() path as real tasks, it correctly wakes a consumer that's currently blocked waiting for work, which a plain flag check never could (nothing wakes a thread parked inside wait() except another thread calling notify()/notifyAll() on that same monitor, or interruption).
Q: What would go wrong if put()/take() used if instead of while around wait(), even with only ONE consumer? A: With exactly one consumer and one producer it can appear to work by coincidence, but it's still incorrect in general — a spurious wakeup (the JVM is permitted to wake a waiting thread without an actual notify(), a documented possibility for wait()) would let the thread proceed past the check without the condition actually being true, which while protects against but if does not.
Q: Why does put() release the lock during wait() instead of just busy-waiting in a loop? A: Busy-waiting would hold the monitor lock the entire time (or burn CPU checking repeatedly without holding it, depending on implementation), preventing any consumer from ever acquiring the lock to call take() and drain the queue — wait()'s defining behavior is releasing the monitor while parked, which is precisely what allows another thread to make the progress this thread is waiting on.
Q: How does this hand-rolled version compare to just using ArrayBlockingQueue directly? A: Functionally equivalent for the core put/take semantics — ArrayBlockingQueue is battle-tested, likely has better internal optimizations, and should be what real production code uses (as Connection Pool Design does). This exercise's value is purely pedagogical: understanding wait/notify's while-loop and notifyAll() requirements directly is what lets you reason correctly about ANY hand-rolled synchronization code you might encounter, not just this one example.
Q: Is one poison pill per consumer always correct, or could consumer count change dynamically? A: The one-pill-per-consumer approach assumes a fixed, known consumer count at shutdown time — a system where consumers can be added/removed dynamically would need a different signal (e.g. each consumer checking a shared shutdown flag AFTER a bounded-timeout take() rather than an indefinite one, trading a small polling delay for not needing to know the exact consumer count in advance).