Java BlockingQueue and Producer-Consumer
Table of Contents + −
In the last lesson you learned about Java concurrent collections. Those keep shared data safe. But they do not help when one thread makes work and another thread does it, and the two run at different speeds. Java has a tool built for that hand-off, the BlockingQueue.
🤔 The problem: one side is faster than the other
One thread keeps adding jobs. We call it the producer. Another thread keeps taking jobs and doing them. We call it the consumer. This is the producer-consumer problem. The trouble is timing:
- The producer runs fast and piles up thousands of jobs. The list grows and eats all your memory.
- The consumer runs fast and the list is empty. It has nothing to do.
- An empty consumer that keeps checking “anything yet?” in a tight loop burns the CPU for nothing. That waste is called busy-waiting.
So you need two things. The producer must pause when the list is full. The consumer must pause when the list is empty. People used to hand-write this with wait() and notify(). That is fiddly and easy to break. A BlockingQueue gives it to you for free.
🔑 An analogy: a small shelf between two workers
Picture a kitchen with a cook and a waiter. Between them is a small shelf that holds only a few plates. The cook is the producer. The cook puts finished plates on the shelf. The waiter is the consumer. The waiter takes plates off the shelf and carries them out.
Now the shelf is small on purpose. If the shelf is full, the cook simply waits, holding the next plate, until a slot opens up. The cook does not throw plates on the floor. If the shelf is empty, the waiter waits by the shelf until a plate appears. The waiter does not run laps around the kitchen checking every second.
That shelf is a BlockingQueue. It sits between the two workers and smooths out the speed difference. When it is full the producer blocks. When it is empty the consumer blocks. Nobody wastes effort, and nobody piles plates to the ceiling. The waiting is built into the shelf itself.
🧩 What is a BlockingQueue?
A BlockingQueue is a thread-safe queue in java.util.concurrent. You add items at one end and remove them from the other. The magic is at the edges:
put(item)adds an item. If the queue is full, it blocks until space opens up. It never drops the item.take()removes and returns an item. If the queue is empty, it blocks until an item appears.- The word blocks means the thread sleeps quietly using no CPU. Java wakes it at the right moment.
So no busy-waiting and no manual wait/notify. The two methods handle the waiting for you.
The example below creates a small queue and shows the basic moves:
import java.util.concurrent.ArrayBlockingQueue;import java.util.concurrent.BlockingQueue;
public class Main { public static void main(String[] args) throws InterruptedException { BlockingQueue<String> queue = new ArrayBlockingQueue<>(2); // holds 2
queue.put("plate-1"); // space free, returns at once queue.put("plate-2"); // now full System.out.println("Queue size: " + queue.size());
System.out.println("Took: " + queue.take()); // removes plate-1 System.out.println("Took: " + queue.take()); // removes plate-2 System.out.println("Queue size: " + queue.size()); }}This puts two items into a queue that holds two, then takes them back out. Nothing blocked here because we never overfilled it or took from it empty. The output:
Output
Queue size: 2Took: plate-1Took: plate-2Queue size: 0Items came out oldest first, the normal queue behaviour. Blocking only shows up when two threads push and pull at once, which we build next.
📦 The three implementations you will use
BlockingQueue is an interface, so you pick a concrete class. Three cover almost everything:
ArrayBlockingQueueis bounded: a fixed maximum size you set at creation. The safe default, because a full queue makes the producer wait instead of running out of memory.LinkedBlockingQueueuses linked nodes. Give it a size to cap it, or leave the size out and it is unbounded (grows without limit). Handy but risky.PriorityBlockingQueueorders by priority, not arrival. The “smallest” item comes out first. It is unbounded.
The snippet below shows how you create each one:
import java.util.concurrent.*;
// Bounded: holds at most 5 items, producer blocks when fullBlockingQueue<String> bounded = new ArrayBlockingQueue<>(5);
// Unbounded by default: grows freely (be careful)BlockingQueue<String> unbounded = new LinkedBlockingQueue<>();
// Bounded linked queue: also caps at 5BlockingQueue<String> cappedLinked = new LinkedBlockingQueue<>(5);
// Priority order: smallest value comes out first, not oldestBlockingQueue<Integer> priority = new PriorityBlockingQueue<>();For most producer-consumer work, reach for ArrayBlockingQueue with a sensible limit. The cap is a feature. It keeps a fast producer from flooding your memory.
The example below shows PriorityBlockingQueue pulling items out in priority order, not arrival order:
import java.util.concurrent.BlockingQueue;import java.util.concurrent.PriorityBlockingQueue;
public class Main { public static void main(String[] args) throws InterruptedException { BlockingQueue<Integer> queue = new PriorityBlockingQueue<>(); queue.put(30); queue.put(10); queue.put(20);
System.out.println(queue.take()); // smallest first System.out.println(queue.take()); System.out.println(queue.take()); }}We added 30, 10, then 20, but the queue hands back the smallest first. Arrival order is ignored:
Output
102030🗂️ A full producer-consumer example
Now let’s put it together. Two producers make numbered tasks. Two consumers handle them. All share one bounded queue.
The code below holds five items. Each consumer fakes work with a short sleep:
import java.util.concurrent.ArrayBlockingQueue;import java.util.concurrent.BlockingQueue;
public class Main { public static void main(String[] args) throws InterruptedException { BlockingQueue<String> queue = new ArrayBlockingQueue<>(5);
Runnable producer = () -> { String name = Thread.currentThread().getName(); try { for (int i = 1; i <= 3; i++) { String task = name + "-task-" + i; queue.put(task); // blocks if the queue is full System.out.println(name + " produced " + task); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } };
Runnable consumer = () -> { String name = Thread.currentThread().getName(); try { for (int i = 0; i < 3; i++) { String task = queue.take(); // blocks if the queue is empty System.out.println(name + " consumed " + task); Thread.sleep(50); // pretend the work takes time } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } };
Thread p1 = new Thread(producer, "producer-1"); Thread p2 = new Thread(producer, "producer-2"); Thread c1 = new Thread(consumer, "consumer-1"); Thread c2 = new Thread(consumer, "consumer-2");
p1.start(); p2.start(); c1.start(); c2.start();
p1.join(); p2.join(); c1.join(); c2.join();
System.out.println("All done. Items left in queue: " + queue.size()); }}What happens:
- Each producer calls
putthree times. If the queue fills to five, the nextputblocks until a consumer frees a slot. - Each consumer calls
takethree times. If the queue is empty,takeblocks until a producer adds something. - The consumer
sleepmakes work slower than producing, so the queue fills and producers block for real. - Six tasks made, six taken. Everything balances and the queue ends empty.
The exact order shifts every run because four threads race. The shape stays the same: turns take smoothly, nobody overflows, nobody spins on empty:
Output
producer-1 produced producer-1-task-1producer-2 produced producer-2-task-1consumer-1 consumed producer-1-task-1consumer-2 consumed producer-2-task-1producer-1 produced producer-1-task-2producer-2 produced producer-2-task-2consumer-1 consumed producer-1-task-2consumer-2 consumed producer-2-task-2producer-1 produced producer-1-task-3producer-2 produced producer-2-task-3consumer-1 consumed producer-1-task-3consumer-2 consumed producer-2-task-3All done. Items left in queue: 0Look how little code that took. No wait(), no notify(), no synchronized, no full/empty flag. The queue does all that inside itself. You just call put and take.
Always handle InterruptedException
put and take can throw InterruptedException, because a blocked thread can be asked to stop. The safe habit is to catch it and call Thread.currentThread().interrupt() to keep the interrupt flag set, as the example does. Never just swallow it silently.
⏱️ offer and poll with a timeout
Sometimes a thread should not wait forever. For that, BlockingQueue gives you timed versions:
offer(item, time, unit)tries to add. No space within the time? It returnsfalseinstead of blocking forever.poll(time, unit)tries to remove. Nothing arrives in time? It returnsnullinstead of waiting forever.
The example below shows offer giving up on a full queue, then poll timing out on an empty one:
import java.util.concurrent.ArrayBlockingQueue;import java.util.concurrent.BlockingQueue;import java.util.concurrent.TimeUnit;
public class Main { public static void main(String[] args) throws InterruptedException { BlockingQueue<String> queue = new ArrayBlockingQueue<>(1); queue.put("only-slot"); // queue is now full
boolean added = queue.offer("extra", 1, TimeUnit.SECONDS); System.out.println("Was 'extra' added? " + added); // no space, gives up
queue.take(); // empty it again String item = queue.poll(1, TimeUnit.SECONDS); System.out.println("Polled from empty queue: " + item); // times out -> null }}The timed offer waits one second for a slot, never gets one, returns false. Then poll waits one second for an item, never gets one, returns null:
Output
Was 'extra' added? falsePolled from empty queue: nullThere are also instant versions: plain offer(item) returns false at once if full, plain poll() returns null at once if empty. Use these when a thread has something better to do than wait.
⚔️ Why this beats hand-coded wait/notify
Before BlockingQueue, you wrote the hand-off yourself. It looked like this, and it was a classic source of bugs:
// ❌ The old hard way: easy to get wrongsynchronized (lock) { while (queue.isEmpty()) { lock.wait(); // forget the loop and you get bugs } item = queue.removeFirst(); lock.notifyAll(); // forget this and producers stall forever}Every line is a trap:
- Use
while, notif, aroundwait, or a “spurious wakeup” lets a thread run on an empty queue. - Forget to
notifythe other side and it sleeps forever. - Hold the wrong lock and your program crashes, hangs, or processes the same item twice.
A BlockingQueue replaces all of that with put and take. The library already solved the locking, waiting, and waking. Unless your need is very unusual, never hand-write wait/notify for this. Use the queue.
⚠️ Common Mistakes
A few BlockingQueue traps that bite people.
- Using an unbounded queue with a fast producer. A
LinkedBlockingQueuewith no size limit never blocks the producer. If it outruns the consumer, the queue grows until your program runs out of memory and crashes.
// ❌ Wrong: no limit, a fast producer can fill all memoryBlockingQueue<String> queue = new LinkedBlockingQueue<>();
// ✅ Right: a bound makes the producer wait instead of flooding memoryBlockingQueue<String> queue = new ArrayBlockingQueue<>(1000);- Busy-waiting instead of using
take. Looping onpoll()while it returnsnullburns the CPU for nothing. Lettakedo the waiting. It sleeps the thread properly.
// ❌ Wrong: spins the CPU checking an empty queuewhile (true) { String item = queue.poll(); // returns null instantly when empty if (item != null) process(item);}
// ✅ Right: take() blocks quietly until an item arriveswhile (true) { String item = queue.take(); // no CPU wasted while waiting process(item);}-
Swallowing
InterruptedException. Catching it and doing nothing hides the request to stop. Re-set the flag withThread.currentThread().interrupt()so the rest of your code knows the thread was interrupted. -
Putting
nullinto the queue. ABlockingQueuedoes not allownullitems and throws aNullPointerExceptionif you try. That is on purpose, becausepollusesnullto mean “nothing here”. Never putnullin.
✅ Best Practices
Habits for clean producer-consumer code.
- Prefer a bounded queue. Choose
ArrayBlockingQueuewith a size that fits your memory. The cap protects you from a runaway producer. - Use
putandtakefor the simple case. Let the queue handle all the waiting. Do not write your ownwait/notify. - Use timed
offer/pollwhen a thread should not wait forever. A timeout lets a thread give up and do something else instead of blocking. - Always handle
InterruptedExceptionproperly. Re-interrupt the thread or exit the loop cleanly. Do not ignore it. - Never put
nullinto a BlockingQueue. It is not allowed and breaks the empty check. - Consider a “poison pill” to stop consumers. Put a special end-marker item that tells consumers to stop, instead of killing threads by force.
- Pick the right type.
ArrayBlockingQueuefor a firm cap,LinkedBlockingQueuefor higher throughput,PriorityBlockingQueuewhen order matters by priority.
🧩 What You’ve Learned
Nicely done. Let’s recap the BlockingQueue.
- ✅ A BlockingQueue is a thread-safe queue where
putblocks when full andtakeblocks when empty. - ✅ That blocking solves the producer-consumer problem cleanly, with no manual
wait/notify. - ✅
ArrayBlockingQueueis bounded and the safe default;LinkedBlockingQueuecan be unbounded;PriorityBlockingQueueorders by priority. - ✅ A full producer-consumer example needs only
putandtake, no locks or flags. - ✅
offerandpollwith a timeout let a thread give up instead of waiting forever. - ✅ The queue beats hand-coded
wait/notify, which is fiddly and easy to break. - ✅ Avoid unbounded queues with fast producers and never busy-wait when
takewill block for you.
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
What happens when you call take() on an empty BlockingQueue?
Why: take() blocks the calling thread, sleeping without using the CPU, until an item appears.
- 2
Which implementation is bounded and a safe default for producer-consumer?
Why: ArrayBlockingQueue takes a fixed size, so a full queue makes the producer wait instead of running out of memory.
- 3
Why does using an unbounded queue with a fast producer cause trouble?
Why: With no size limit the producer is never made to wait, so a fast producer keeps filling the queue until memory is gone.
- 4
What does poll(time, unit) return if nothing arrives in time?
Why: Timed poll waits up to the given time and returns null if no item appears, instead of blocking forever.
🚀 What’s Next?
A BlockingQueue is perfect when threads hand off items of work. But sometimes threads need to coordinate on timing instead. Wait for everyone to be ready before starting. Let only a few threads in at once. Java has dedicated tools for that kind of coordination. Next you will learn about CountDownLatch, CyclicBarrier, and Semaphore, three small classes that make threads wait for each other in clean, reliable ways. Let’s learn them.