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.

Illustration of a cozy bakery with a baker placing a donut on a shelf and a customer waiting
Real-World Analogy: The Bakery and the Display Shelf

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:

Step 1: Setting Up the Shelf

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.

Step 2: Hiring the Baker and Customer (Thread Activation)

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.

Step 3: The Production and Consumption Dance

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., item 1) 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 item 1 from 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 called take(), the Baker's thread automatically pauses (blocks). The moment the Customer calls take(), 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.