Priya’s QuickCart backend has two busy areas. The kitchen prepares orders — packing items, printing labels, updating stock. The counter serves customers — charging cards, sending emails, marking orders complete. The kitchen works faster than the counter can finish. If the kitchen dumps everything on the counter at once, things get messy. Priya needs a safe middle space where finished orders wait until a counter worker is free.
That middle space is the Producer-Consumer pattern.
What is Producer-Consumer?
Producers make work. Consumers do work. They share a queue between them.
- Producers add items to the queue.
- Consumers take items from the queue.
- The queue handles waiting and thread safety.
Why use it?
- Decouple — kitchen and counter do not need to know about each other’s timing.
- Smooth spikes — if the kitchen is fast for a minute, the queue holds extra orders instead of crashing the counter.
- Scale separately — you can run three kitchen threads and five counter threads.
In QuickCart, producers might be “pack order” tasks. Consumers might be “send confirmation email” tasks.
The problem this solves: producers often generate work faster than consumers can finish it. You need a thread-safe buffer between them so neither side crashes the other.
BlockingQueue: the easy way
Java gives you BlockingQueue. It is thread-safe and knows how to wait.
put(item)— add an item. If the queue is full, the producer blocks (waits).take()— remove an item. If the queue is empty, the consumer blocks (waits).
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class QuickCartKitchenCounter {
private final BlockingQueue<String> readyOrders =
new ArrayBlockingQueue<>(10);
// Kitchen thread — producer
public void packOrder(String orderId) throws InterruptedException {
System.out.println("Kitchen packed: " + orderId);
readyOrders.put(orderId); // waits if queue is full
}
// Counter thread — consumer
public String serveNextOrder() throws InterruptedException {
String orderId = readyOrders.take(); // waits if queue is empty
System.out.println("Counter serving: " + orderId);
return orderId;
}
}
The queue size is 10. If the kitchen fills all 10 slots, the next put() waits until the counter takes one. If the counter is idle and the queue is empty, take() waits until the kitchen adds one.
You do not write wait() and notify() yourself. The queue does it for you.
put vs offer, take vs poll
Not every situation needs blocking. Java gives you a choice:
| Method | Queue full | Queue empty |
|---|---|---|
put(item) |
blocks until space | — |
offer(item) |
returns false | — |
offer(item, timeout, unit) |
waits up to timeout, then false | — |
take() |
— | blocks until item available |
poll() |
— | returns null immediately |
poll(timeout, unit) |
— | waits up to timeout, then null |
Use put and take when you want workers to wait politely — the kitchen slows down when the counter falls behind. Use offer and poll when you want to skip or drop work instead of blocking.
// Non-blocking producer — skip if queue is full
boolean added = readyOrders.offer(orderId);
if (!added) {
System.out.println("Queue full — order " + orderId + " dropped");
}
// Non-blocking consumer — return null if nothing ready
String order = readyOrders.poll();
if (order != null) {
serveOrder(order);
}
For QuickCart’s log writer, Priya uses offer so a slow disk never blocks the main order thread. For order packing, she uses put so no order is lost.
Multiple kitchen workers and counter workers
Real QuickCart runs more than one thread on each side. Three kitchen threads pack. Two counter threads serve. They all share one queue safely.
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class QuickCartMultiWorkerPipeline {
private final BlockingQueue<String> queue = new ArrayBlockingQueue<>(10);
private static final int KITCHEN_COUNT = 3;
private static final int COUNTER_COUNT = 2;
public void start() {
ExecutorService kitchen = Executors.newFixedThreadPool(KITCHEN_COUNT);
ExecutorService counter = Executors.newFixedThreadPool(COUNTER_COUNT);
// Start kitchen workers (producers)
for (int i = 0; i < KITCHEN_COUNT; i++) {
final int workerId = i;
kitchen.submit(() -> {
try {
for (int n = 0; n < 100; n++) {
String order = "QC-" + workerId + "-" + n;
queue.put(order);
System.out.println("Kitchen " + workerId + " packed: " + order);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
// Start counter workers (consumers)
for (int i = 0; i < COUNTER_COUNT; i++) {
final int workerId = i;
counter.submit(() -> {
try {
while (true) {
String order = queue.take();
System.out.println("Counter " + workerId + " served: " + order);
Thread.sleep(50); // simulate payment + email
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
}
Two threads pack. Three threads serve. The queue between them keeps everything in order and thread-safe. Any kitchen worker can put. Any counter worker can take. The queue handles all the coordination.
Manual version with wait() and notify()
Before BlockingQueue existed, developers built producer-consumer by hand with synchronized, wait(), and notifyAll(). Priya does not write this in new code, but reading it helps you understand what BlockingQueue does under the hood.
import java.util.LinkedList;
import java.util.Queue;
public class ManualQuickCartQueue {
private final Queue<String> queue = new LinkedList<>();
private final int CAPACITY = 10;
private final Object lock = new Object();
public void produce(String orderId) throws InterruptedException {
synchronized (lock) {
// Wait while queue is full — use while, not if (spurious wakeups)
while (queue.size() == CAPACITY) {
lock.wait();
}
queue.offer(orderId);
System.out.println("Produced: " + orderId);
// Wake up waiting consumers
lock.notifyAll();
}
}
public String consume() throws InterruptedException {
synchronized (lock) {
// Wait while queue is empty
while (queue.isEmpty()) {
lock.wait();
}
String orderId = queue.poll();
System.out.println("Consumed: " + orderId);
// Wake up waiting producers
lock.notifyAll();
return orderId;
}
}
}
Three rules from this manual version:
- Use
while, notif, when checking the condition — threads can wake up without the condition being true (spurious wakeup). - Use
notifyAll(), notnotify()— multiple kitchen and counter threads may be waiting. - Synchronize on one shared lock object — both sides must use the same lock.
BlockingQueue follows these same rules internally. That is why it is the better choice for new code.
Picking a queue size and backpressure
Too small — kitchen workers block often. Too large — memory holds many orders the counter has not touched yet.
For QuickCart, Priya starts with 10 to 50 slots and watches queue size in logs. If the queue is always full, add counter workers or speed up serving. If it is always empty, the kitchen is the bottleneck.
Backpressure is the idea that a full queue pushes back on producers. When the queue is full, put() blocks the kitchen. The kitchen slows down instead of flooding memory. That is healthy — it means the system protects itself.
// Monitor queue size in production
int size = queue.size();
if (size > 8) { // queue capacity is 10
log.warn("Order queue nearly full: {}/10 — counter may be slow", size);
}
Avoid unbounded queues (like new LinkedBlockingQueue<>() with no limit) unless you monitor size. An unbounded queue never blocks producers. It can grow until the server runs out of memory.
Graceful shutdown with a poison pill
The counter loop above runs forever. To stop cleanly, send a special “stop” message:
private static final String POISON = "__STOP__";
// Kitchen sends one poison pill per consumer when done packing
for (int i = 0; i < COUNTER_COUNT; i++) {
queue.put(POISON);
}
// Counter checks each item
String order = queue.take();
if (POISON.equals(order)) {
break; // exit the loop
}
One poison pill per consumer thread. Or use a shared volatile boolean shutdown flag and check it after each take():
private volatile boolean shutdown = false;
public void stop() {
shutdown = true;
}
// In consumer loop:
while (!shutdown || !queue.isEmpty()) {
String order = queue.take();
processOrder(order);
}
The while (!shutdown || !queue.isEmpty()) pattern drains remaining orders before stopping.
Real QuickCart uses
Priya sees this pattern everywhere once she knows to look.
Order task queue
Web requests submit work. Pool workers process it in the background.
public class QuickCartTaskProcessor {
private final BlockingQueue<OrderTask> taskQueue = new ArrayBlockingQueue<>(100);
private volatile boolean shutdown = false;
public void submitTask(OrderTask task) throws InterruptedException {
taskQueue.put(task);
}
public void startProcessing() {
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < 5; i++) {
executor.submit(() -> {
while (!shutdown || !taskQueue.isEmpty()) {
try {
OrderTask task = taskQueue.take();
processTask(task);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
}
}
private void processTask(OrderTask task) {
// charge card, update stock, send email
}
public void shutdown() {
shutdown = true;
}
}
Log writing
App threads add log lines. One writer thread saves to disk. Producers use offer so logging never blocks order processing.
public class QuickCartLogWriter {
private final BlockingQueue<LogEntry> logQueue = new LinkedBlockingQueue<>();
// Any thread can add a log line — non-blocking
public void addLog(LogEntry entry) {
logQueue.offer(entry);
}
// One background thread writes to disk
public void startWriter() {
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(() -> {
while (true) {
try {
LogEntry entry = logQueue.take();
writeToFile(entry);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
}
private void writeToFile(LogEntry entry) {
// append to log file
}
}
Three-stage order pipeline
Sometimes one stage is both a consumer and a producer. QuickCart’s photo upload flow: load images, resize them, save them.
public class QuickCartImagePipeline {
private final BlockingQueue<Image> rawImages = new ArrayBlockingQueue<>(50);
private final BlockingQueue<Image> processedImages = new ArrayBlockingQueue<>(50);
// Stage 1: Load images (producer for rawImages)
public void loadImages(List<String> paths) {
ExecutorService loader = Executors.newFixedThreadPool(2);
for (String path : paths) {
loader.submit(() -> {
try {
Image image = loadImage(path);
rawImages.put(image);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
// Stage 2: Resize (consumer of rawImages, producer for processedImages)
public void resizeImages() {
ExecutorService processor = Executors.newFixedThreadPool(4);
for (int i = 0; i < 4; i++) {
processor.submit(() -> {
while (true) {
try {
Image raw = rawImages.take();
Image resized = resize(raw);
processedImages.put(resized);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
}
}
// Stage 3: Save (consumer of processedImages)
public void saveImages() {
ExecutorService saver = Executors.newFixedThreadPool(2);
for (int i = 0; i < 2; i++) {
saver.submit(() -> {
while (true) {
try {
Image processed = processedImages.take();
saveToStorage(processed);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
}
}
}
Same shape every time: produce, queue, consume. One queue per handoff between stages.
BlockingQueue vs ConcurrentLinkedQueue
QuickCart uses BlockingQueue when one side should wait. ConcurrentLinkedQueue is different — it is lock-free and non-blocking, but also unbounded.
| Feature | BlockingQueue | ConcurrentLinkedQueue |
|---|---|---|
| Blocking | yes (put/take) |
no (offer/poll) |
| Bounded | can be bounded | always unbounded |
| Use when | producers/consumers at different speeds | high-throughput, no backpressure needed |
For kitchen-to-counter, Priya always picks BlockingQueue.
What to remember
- Producer-Consumer splits “make work” from “do work” with a shared queue.
- Use
BlockingQueue—put()andtake()handle blocking and thread safety. - Use
offer()/poll()when you want non-blocking behavior. - Pick a bounded queue size — it creates healthy backpressure when full.
- Always handle
InterruptedException— callThread.currentThread().interrupt(). - Use a poison pill or shutdown flag to stop consumer loops cleanly.
- Never use a plain
LinkedListas a shared queue — it is not thread-safe.
What Priya does next: two parts of her app grab locks in the wrong order, and everything freezes. She learns about deadlock.