Java BlockingQueue and Producer-Consumer

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: 2
Took: plate-1
Took: plate-2
Queue size: 0

Items 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:

  • ArrayBlockingQueue is 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.
  • LinkedBlockingQueue uses linked nodes. Give it a size to cap it, or leave the size out and it is unbounded (grows without limit). Handy but risky.
  • PriorityBlockingQueue orders 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 full
BlockingQueue<String> bounded = new ArrayBlockingQueue<>(5);
// Unbounded by default: grows freely (be careful)
BlockingQueue<String> unbounded = new LinkedBlockingQueue<>();
// Bounded linked queue: also caps at 5
BlockingQueue<String> cappedLinked = new LinkedBlockingQueue<>(5);
// Priority order: smallest value comes out first, not oldest
BlockingQueue<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

10
20
30

🗂️ 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 put three times. If the queue fills to five, the next put blocks until a consumer frees a slot.
  • Each consumer calls take three times. If the queue is empty, take blocks until a producer adds something.
  • The consumer sleep makes 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-1
producer-2 produced producer-2-task-1
consumer-1 consumed producer-1-task-1
consumer-2 consumed producer-2-task-1
producer-1 produced producer-1-task-2
producer-2 produced producer-2-task-2
consumer-1 consumed producer-1-task-2
consumer-2 consumed producer-2-task-2
producer-1 produced producer-1-task-3
producer-2 produced producer-2-task-3
consumer-1 consumed producer-1-task-3
consumer-2 consumed producer-2-task-3
All done. Items left in queue: 0

Look 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 returns false instead of blocking forever.
  • poll(time, unit) tries to remove. Nothing arrives in time? It returns null instead 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? false
Polled from empty queue: null

There 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 wrong
synchronized (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, not if, around wait, or a “spurious wakeup” lets a thread run on an empty queue.
  • Forget to notify the 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 LinkedBlockingQueue with 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 memory
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
// ✅ Right: a bound makes the producer wait instead of flooding memory
BlockingQueue<String> queue = new ArrayBlockingQueue<>(1000);
  • Busy-waiting instead of using take. Looping on poll() while it returns null burns the CPU for nothing. Let take do the waiting. It sleeps the thread properly.
// ❌ Wrong: spins the CPU checking an empty queue
while (true) {
String item = queue.poll(); // returns null instantly when empty
if (item != null) process(item);
}
// ✅ Right: take() blocks quietly until an item arrives
while (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 with Thread.currentThread().interrupt() so the rest of your code knows the thread was interrupted.

  • Putting null into the queue. A BlockingQueue does not allow null items and throws a NullPointerException if you try. That is on purpose, because poll uses null to mean “nothing here”. Never put null in.

✅ Best Practices

Habits for clean producer-consumer code.

  • Prefer a bounded queue. Choose ArrayBlockingQueue with a size that fits your memory. The cap protects you from a runaway producer.
  • Use put and take for the simple case. Let the queue handle all the waiting. Do not write your own wait/notify.
  • Use timed offer/poll when a thread should not wait forever. A timeout lets a thread give up and do something else instead of blocking.
  • Always handle InterruptedException properly. Re-interrupt the thread or exit the loop cleanly. Do not ignore it.
  • Never put null into 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. ArrayBlockingQueue for a firm cap, LinkedBlockingQueue for higher throughput, PriorityBlockingQueue when order matters by priority.

🧩 What You’ve Learned

Nicely done. Let’s recap the BlockingQueue.

  • ✅ A BlockingQueue is a thread-safe queue where put blocks when full and take blocks when empty.
  • ✅ That blocking solves the producer-consumer problem cleanly, with no manual wait/notify.
  • ArrayBlockingQueue is bounded and the safe default; LinkedBlockingQueue can be unbounded; PriorityBlockingQueue orders by priority.
  • ✅ A full producer-consumer example needs only put and take, no locks or flags.
  • offer and poll with 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 take will block for you.

Check Your Knowledge

Test what you learned. Pick an answer for each question, then click Check.

  1. 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. 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. 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. 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.

Java CountDownLatch, CyclicBarrier, and Semaphore

Share & Connect