In multithreaded systems, threads must cooperate to share resources. While high-level thread-safe structures like BlockingQueue handle locking and signaling automatically, understanding the low-level building blocks—synchronized blocks, wait(), and notifyAll()—is essential to mastering Java concurrency.
In this guide, we will break down the classic Producer-Consumer pattern implemented without a BlockingQueue, using a simple bakery display shelf analogy and a step-by-step trace of the main execution flow.
Imagine a bakery with a **Baker** (the Producer), a **Customer** (the Consumer), and a wooden **display shelf** with exactly **10 slots** for fresh donuts.
Unlike a modern smart shelf, this is a dumb wooden shelf. If the Baker and Customer attempt to touch the shelf at the same split-second, they will clash and drop the donuts. To avoid this, the display area is locked, and there is **only one key** to open it:
- Locking (`synchronized`): Whoever wants to access the shelf must take the key, enter, and lock the door. No one else can touch the shelf while they are inside.
- Waiting (`wait`): If the Baker locks the door but finds the shelf is already full, they cannot add a donut. They must put down the key, sit on a waiting bench, and go to sleep. This releases the key so the Customer can enter and eat.
- Signaling (`notifyAll`): Once the Customer takes a donut, they shout out, "Hey, the shelf has space!" before unlocking the door. This wakes up the sleeping Baker to check the shelf again.
Walkthrough of the Main Method Scenario
Let's trace how this low-level system coordinates step by step starting from the main entry point:
First, the main thread sets up a standard, non-thread-safe LinkedList to store the items:
LinkedList<Integer> buffer = new LinkedList<>();
The buffer represents our shared display shelf. It has a max capacity limit of 10 enforced in our thread logic.
The main thread spawns the Producer (Baker) and Consumer (Customer) threads, pointing them to the same shared buffer:
new Thread(new Producer(buffer)).start();
new Thread(new Consumer(buffer)).start();
They immediately start competing to acquire the monitor lock (the key) for the `buffer` object.
Here is how the wait and notify loop works during execution:
- Acquiring Lock: The Baker gains the lock (`synchronized (buffer)`). It checks `buffer.size()`. If it's less than 10, it adds an item, logs `Produced an item: X`, calls `buffer.notifyAll()` to wake up any waiting customers, and exits the synchronized block.
- Waiting when Full: If the Baker produces 10 items and the Customer is sleeping, the next loop checks `while (buffer.size() == maxBuffer)`. Since it is true, the Baker calls `buffer.wait()`. This puts the Baker to sleep and releases the lock, allowing the Customer to acquire it.
- Consuming & Waking Up: The Customer locks the buffer, sees there are items, calls `buffer.removeFirst()`, prints `Consumed an item: X`, calls `buffer.notifyAll()` to wake up the waiting Baker, and exits. The Customer then sleeps for 1 second (`Thread.sleep(1000)`) outside the lock.
Java Implementation
Here is the full Java code demonstrating low-level concurrency coordination using custom synchronization loops:
package io.practise.accolite;
import java.util.LinkedList;
public class ProducerConsumerWithoutBlockingQueue {
public static void main(String[] args) {
LinkedList<Integer> buffer = new LinkedList<>();
new Thread(new Producer(buffer)).start();
new Thread(new Consumer(buffer)).start();
}
}
class Producer implements Runnable {
private LinkedList<Integer> buffer;
private final int maxBuffer = 10;
private static int counter = 0;
public Producer(LinkedList<Integer> buffer) {
this.buffer = buffer;
}
@Override
public void run() {
try {
while (true) {
synchronized (buffer) {
while (buffer.size() == maxBuffer) {
buffer.wait();
}
if (buffer.size() < maxBuffer) {
buffer.add(++counter);
System.out.println("Produced an item: " + counter);
buffer.notifyAll();
}
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
class Consumer implements Runnable {
private LinkedList<Integer> buffer;
private final int maxBuffer = 10;
public Consumer(LinkedList<Integer> buffer) {
this.buffer = buffer;
}
@Override
public void run() {
try {
while (true) {
synchronized (buffer) {
while (buffer.size() == 0) {
buffer.wait();
}
if (!buffer.isEmpty()) {
Integer num = buffer.removeFirst();
System.out.println("Consumed an item: " + num);
buffer.notifyAll();
}
}
Thread.sleep(1000);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
Conclusion
Coordinating threads with wait() and notifyAll() requires strict adherence to Java monitor rules: always wait inside a loop checking the condition, and always hold the monitor lock when calling wait or notify. While it is more complex than high-level queues, it forms the foundation of all Java multithreaded communication APIs.