Coordinating multiple threads to share data safely is one of the hardest challenges in concurrent programming. If one thread writes data while another reads it, we risk data corruption, race conditions, and deadlocks. To solve this, developers rely on the classic Producer-Consumer Pattern.
In this guide, we will break down the Producer-Consumer pattern using Java's built-in BlockingQueue, explaining the concept with a simple bakery analogy and tracing the execution of the main program step by step.
Imagine a small bakery that specializes in fresh glazed donuts.
The bakery has a **Baker** (the Producer) and a **Customer** (the Consumer). Between them sits a single **display shelf** that has only enough space to hold exactly **one donut** at a time.
To avoid wasting donuts or leaving the Customer empty-handed, they follow a simple set of rules:
- The Baker's Rule: Bake a donut and try to place it on the shelf. If the shelf is already full, stop and wait until the shelf is empty.
- The Customer's Rule: Check the shelf. If there is a donut, take it and eat it. If the shelf is empty, stop and wait until the Baker puts a fresh donut on the shelf.
This single-slot shelf behaves exactly like Java's BlockingQueue with a capacity of 1. It handles all the waiting and signaling automatically!
Walkthrough of the Main Method Scenario
Let's trace exactly how this system executes when we run the program from its entry point:
First, the main thread sets up the queue with a capacity of exactly 1:
BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>(1);
At this point, the shelf is empty. Because the capacity is 1, the queue can hold at most one item before blocking subsequent inserts.
Next, the main method starts two independent threads:
new Thread(new Producer1(blockingQueue)).start();
new Thread(new Consumer1(blockingQueue)).start();
These threads start executing their respective run() loops concurrently in the background.
Here is how the coordination flows during the program's lifecycle:
- Production: The Baker calls
blockingQueue.put(++count). Since the queue is empty, the item (e.g., item1) is added instantly. The Baker prints"Produced item: 1"and goes to sleep for 1 second. - Consumption: Concurrently, the Customer calls
blockingQueue.take(). It retrieves item1from the queue (making the queue empty again), prints"Consumed item: 1", and goes to sleep for 1 second. - Blocking behavior: If the Baker finishes their sleep early and attempts to
put(2)before the Customer has calledtake(), the Baker's thread automatically pauses (blocks). The moment the Customer callstake(), the queue wakes up the Baker to complete the insertion.
Java Implementation
Below is the complete Java implementation showing the Producer1, Consumer1, and the main driver class coordinating via a LinkedBlockingQueue:
package io.practise.accolite;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class ProducerConsumerWithBlockingQueue {
public static void main(String[] args) {
BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>(1);
new Thread(new Producer1(blockingQueue)).start();
new Thread(new Consumer1(blockingQueue)).start();
}
}
class Producer1 implements Runnable {
private BlockingQueue<Integer> blockingQueue;
private static int count = 0;
public Producer1(BlockingQueue<Integer> blockingQueue) {
this.blockingQueue = blockingQueue;
}
@Override
public void run() {
try {
while (true) {
blockingQueue.put(++count);
System.out.println("Produced item: " + count);
Thread.sleep(1000);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
class Consumer1 implements Runnable {
private BlockingQueue<Integer> blockingQueue;
public Consumer1(BlockingQueue<Integer> blockingQueue) {
this.blockingQueue = blockingQueue;
}
@Override
public void run() {
try {
while (true) {
Integer count = blockingQueue.take();
System.out.println("Consumed item: " + count);
Thread.sleep(1000);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
Conclusion
By using a BlockingQueue, we can implement the complex Producer-Consumer pattern in a few lines of clean, readable code. The queue manages the thread-safe communication, queue limits, and blocking signals under the hood, freeing you from dealing with low-level wait() and notify() blocks.