- Conceptual Analysis
1.1 Introduction
The producer-consumer pattern is also known as the wait-notify mechanism. This is a classic multi-threading cooperation pattern in Java. Understanding this mechanism will provide deeper insight into multi-threaded execution behavior.
As previously learned, thread execution is inherently random. With two threads running, the output could be completely unpredictable. However, the wait-notify mechanism changes this behavior by enforcing alternating execution between threads. This ensures a controlled execution pattern where threads take turns: producer executes once, then consumer executes once, and so on.
One thread serves as the producer responsible for generating data, while the other thread acts as the consumer responsible for consuming that data.
To understand the complex logic, let's use a real-world analogy. Consider two people: a diner and a chef.
The diner is the consumer, and the chef is the producer. However, these two alone are insufficient. We need a third entity - a table - to control thread execution, since by default thread execution is random.
Assume there's a bowl of noodles on the table. If noodles exist, the diner executes to consume them. If no noodles exist, the chef executes to prepare them.
1.2 Scenario One: Consumer Waiting
In the ideal scenario, the chef acquires CPU execution first. Since the table is empty, the chef prepares noodles and places them on the table, then the diner consumes them. This creates a pattern where the chef produces one item and the diner consumes one item.
However, programs don't always behave as expected. Thread execution is random, so we must consider all possible scenarios. Fortunately, there are only two main scenarios to handle.
Let's analyze the first scenario: consumer waiting.
Assume the diner acquires CPU execution first. Since the table is empty, the diner cannot proceed - it must wait. In code, this is called wait(). Once the diner waits, the chef will inevitably acquire CPU execution. The chef checks the table, finds no noodles, prepares a bowl, and places it on the table.
After preparation, the chef must wake the waiting diner to eat. This action is called notify. Once awakened, the diner begins consuming.
The core logic is: check the table - if no noodles exist, the consumer waits.
1.3 Scenario Two: Producer Waiting
Now consider the second scenario: producer waiting.
If the chef acquires CPU execution first when the table is empty, the chef performs the three steps: prepare food, place on tible, and notify the waiting consumer.
However, if noone is waiting (which is fine - the notification simply has no effect), and the chef continues to acquire CPU execution again, the chef cannot prepare more food since the table already has noodles. The chef must wait.
This requires adding a check in the producer logic. When the chef waits, the diner will acquire CPU execution. The diner checks if food exists - if not, it waits; if yes, it consumes and then notifies the chef to continue producing.
1.4 Important Methods
Three key methods are involved in this mechanism:
| Method | Description |
|---|---|
| void wait() | Causes current thread to wait until another thread invokes notify() or notifyAll() on this object |
| void notify() | Wakes up a single random thread waiting on this object's monitor |
| void notifyAll() | Wakes up all threads waiting on this object's monitor |
- SharedResource Class Implementation
Based on the analysis above, we need at least three components: producer, consumer, and a shared resource that controls both.
public class SharedResource {
/*
* Purpose: Controls producer and consumer execution
*/
// How to control? Use a status flag: 0 means no food, 1 means food present
// Why use int instead of boolean? Boolean has only two values and can only control two threads.
// For future scalability (controlling 3+ threads), int provides more flexibility
public static int foodStatus = 0;
// Maximum number of items to produce/consume
public static int totalCount = 10;
// Lock object for synchronization
public static Object lock = new Object();
}
- Consumer Implementation
When writing multi-threaded code, follow these four steps:
1. Loop
2. Synchronized block (can later be refactored to synchronized method or Lock)
3. Check if shared data has reached the end (handle end condition first - simpler)
4. If not at end, execute core logic
Here's the consumer implementation:
public class Consumer extends Thread {
@Override
public void run() {
// 1. Loop
while (true) {
// 2. Synchronized block
synchronized (SharedResource.lock) {
// 3. Check if all items have been consumed
if (SharedResource.totalCount == 0) {
break;
} else {
// 4. Not finished - execute core logic
if (SharedResource.foodStatus == 0) {
// No food available - wait
try {
// Must call wait() on the lock object to associate thread with the lock
// This allows notifyAll() to properly wake threads bound to this lock
SharedResource.lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
// Food is available - consume it
SharedResource.totalCount--;
System.out.println("Consumer ate noodles. Remaining: " + SharedResource.totalCount);
// Notify producer to continue
SharedResource.lock.notifyAll();
// Update table status
SharedResource.foodStatus = 0;
}
}
}
}
}
}
- Producer Implementation
The producer also follows the same four-step pattern:
1. Loop
2. Synchronized block
3. Check if production is complete
4. If not complete, execute core logic
Here's the producer implementation:
public class Producer extends Thread {
@Override
public void run() {
// 1. Loop
while (true) {
// 2. Synchronized block
synchronized (SharedResource.lock) {
// 3. Check if production is complete
if (SharedResource.totalCount == 0) {
break;
} else {
// Not complete
if (SharedResource.foodStatus == 1) {
// Food already exists - wait for consumer to finish
try {
SharedResource.lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
// No food - produce some
System.out.println("Producer made noodles");
// Update food status
SharedResource.foodStatus = 1;
// Wake up waiting consumer
SharedResource.lock.notifyAll();
}
}
}
}
}
}
- Testing
public static void main(String[] args) {
// Create thread objects
Producer p = new Producer();
Consumer c = new Consumer();
// Set thread names
p.setName("Producer");
c.setName("Consumer");
// Start threads
p.start();
c.start();
}
Upon execution, the output demonstrates the expected alternating pattern: producer makes noodles, consumer eats noodles, repeatedly until all items are consumed. The program terminates gracefully when the count reaches zero.