In Java, inter-thread communication refers to the process where threads can communicate with each other, allowing them to coordinate their work.
It is often used in scenarios where one thread produces data and another thread consumes that data (producer-consumer problems).
Java provides built-in mechanisms to achieve inter-thread communication using methods like wait(), notify(), and notifyAll(), which work with synchronization to ensure safe communication between threads.
In this tutorial, we will explore these concepts with examples to demonstrate how threads communicate with each other in Java.
1. Key Methods for Inter-thread Communication
Java provides three key methods from the Object class that are essential for inter-thread communication:
wait(): Causes the current thread to wait until another thread calls notify() or notifyAll() on the same object.
notify(): Wakes up a single thread that is waiting on the object's monitor.
notifyAll(): Wakes up all the threads that are waiting on the object's monitor.
These methods are always used within a synchronized block or synchronized method to ensure that only one thread can access the shared resource at a time.
2. Understanding wait(), notify(), and notifyAll()
wait(): When a thread calls wait(), it releases the lock on the object and enters a waiting state until another thread calls notify() or notifyAll() on the same object.
notify(): Wakes up one waiting thread (chosen randomly) that is waiting on the object's monitor. If no threads are waiting, nothing happens.
notifyAll(): Wakes up all the threads waiting on the object's monitor. The threads will compete to acquire the lock once it is available.
3. Producer-Consumer Problem (Basic Example)
A classic use case for inter-thread communication is the producer-consumer problem, where one thread (producer) generates data and another thread (consumer) processes it.
The producer should wait if the shared resource (buffer) is full, and the consumer should wait if the buffer is empty.
Example: Producer-Consumer with wait() and notify()
class SharedResource { private int value; private boolean hasValue = false; // To track if value is produced // Synchronized method for producing a value public synchronized void produce(int newValue) throws InterruptedException { // Wait if the value has already been produced while (hasValue) { wait(); } this.value = newValue; System.out.println("Produced: " + newValue); hasValue = true; // Mark that the value has been produced notify(); // Notify the consumer } // Synchronized method for consuming a value public synchronized void consume() throws InterruptedException { // Wait if there is no value to consume while (!hasValue) { wait(); } System.out.println("Consumed: " + value); hasValue = false; // Mark that the value has been consumed notify(); // Notify the producer } } public class ProducerConsumerExample { public static void main(String[] args) { SharedResource resource = new SharedResource(); // Producer thread Thread producer = new Thread(() -> { try { for (int i = 1; i <= 5; i++) { resource.produce(i); Thread.sleep(100); // Simulate some work } } catch (InterruptedException e) { e.printStackTrace(); } }); // Consumer thread Thread consumer = new Thread(() -> { try { for (int i = 1; i <= 5; i++) { resource.consume(); Thread.sleep(150); // Simulate some work } } catch (InterruptedException e) { e.printStackTrace(); } }); // Start both threads producer.start(); consumer.start(); } }
Explanation:
The SharedResource class has two synchronized methods: produce() and consume().
The producer produces values (1 to 5), and the consumer consumes them.
If the producer has already produced a value, it waits until the consumer consumes it. Similarly, the consumer waits until the producer produces a new value.
Output:
Produced: 1 Consumed: 1 Produced: 2 Consumed: 2 Produced: 3 Consumed: 3 Produced: 4 Consumed: 4 Produced: 5 Consumed: 5
4. Using notifyAll()
In cases where multiple threads are waiting for a resource, notifyAll() can be used to wake up all the waiting threads. Once all threads are awakened, they will compete to acquire the lock and proceed with their work.
Example: Producer-Consumer with Multiple Consumers (notifyAll())
class SharedResourceMultiConsumer { private int value; private boolean hasValue = false; // Synchronized method for producing a value public synchronized void produce(int newValue) throws InterruptedException { while (hasValue) { wait(); } this.value = newValue; System.out.println("Produced: " + newValue); hasValue = true; notifyAll(); // Notify all consumers } // Synchronized method for consuming a value public synchronized void consume(String consumerName) throws InterruptedException { while (!hasValue) { wait(); } System.out.println(consumerName + " consumed: " + value); hasValue = false; notifyAll(); // Notify producer } } public class MultiConsumerExample { public static void main(String[] args) { SharedResourceMultiConsumer resource = new SharedResourceMultiConsumer(); // Producer thread Thread producer = new Thread(() -> { try { for (int i = 1; i <= 5; i++) { resource.produce(i); Thread.sleep(100); } } catch (InterruptedException e) { e.printStackTrace(); } }); // Consumer threads Thread consumer1 = new Thread(() -> { try { for (int i = 1; i <= 5; i++) { resource.consume("Consumer 1"); } } catch (InterruptedException e) { e.printStackTrace(); } }); Thread consumer2 = new Thread(() -> { try { for (int i = 1; i <= 5; i++) { resource.consume("Consumer 2"); } } catch (InterruptedException e) { e.printStackTrace(); } }); // Start threads producer.start(); consumer1.start(); consumer2.start(); } }
Explanation:
notifyAll() is used to wake up all waiting consumers after a value is produced.
Once consumers are notified, they compete to acquire the lock and consume the value.
Sample Output (may vary):
Produced: 1 Consumer 1 consumed: 1 Produced: 2 Consumer 2 consumed: 2 Produced: 3 Consumer 1 consumed: 3 Produced: 4 Consumer 2 consumed: 4 Produced: 5 Consumer 1 consumed: 5
5. The wait() and notify() in the Context of Locks
The wait(), notify(), and notifyAll() methods are tightly coupled with synchronization in Java. They must always be called within a synchronized block, as the calling thread must own the lock on the object before invoking these methods.
Example: Incorrect Use of wait() without Synchronization
class IncorrectSharedResource { public void produce() throws InterruptedException { wait(); // This will throw IllegalMonitorStateException } public void consume() { notify(); } } public class IncorrectUsageExample { public static void main(String[] args) { IncorrectSharedResource resource = new IncorrectSharedResource(); Thread producer = new Thread(() -> { try { resource.produce(); } catch (InterruptedException e) { e.printStackTrace(); } }); Thread consumer = new Thread(resource::consume); producer.start(); consumer.start(); } }
Explanation:
This code will throw IllegalMonitorStateException because wait() and notify() are being called outside a synchronized block.
You must always use these methods within a synchronized context to avoid such exceptions.
Correct Usage:
synchronized (this) { wait(); // Safe usage }
6. Best Practices for Inter-thread Communication
Always use wait(), notify(), and notifyAll() inside synchronized blocks or synchronized methods.
Use notifyAll() when multiple threads are waiting on the same object to avoid starvation.
Avoid holding multiple locks while using wait() and notify() to reduce the chances of deadlocks.
Always use a while loop for the condition check in wait() to avoid spurious wakeups, which are rare but possible.
7. Real-World Scenario: Bounded Buffer (Producer-Consumer)
In real-world systems, there’s often a bounded buffer (like a queue) between the producer and consumer. The producer cannot add to the buffer if it is full, and the consumer cannot consume from the buffer if it is empty.
Example: Producer-Consumer with Bounded Buffer
import java.util.LinkedList; import java.util.Queue; class BoundedBuffer { private final Queue buffer = new LinkedList<>(); private final int capacity; public BoundedBuffer(int capacity) { this.capacity = capacity; } // Produce a value public synchronized void produce(int value) throws InterruptedException { while (buffer.size() == capacity) { wait(); // Wait if buffer is full } buffer.add(value); System.out.println("Produced: " + value); notifyAll(); // Notify consumers that a new item is available } // Consume a value public synchronized int consume() throws InterruptedException { while (buffer.isEmpty()) { wait(); // Wait if buffer is empty } int value = buffer.poll(); System.out.println("Consumed: " + value); notifyAll(); // Notify producers that a slot is available return value; } } public class BoundedBufferExample { public static void main(String[] args) { BoundedBuffer buffer = new BoundedBuffer(2); // Buffer with capacity of 2 // Producer thread Thread producer = new Thread(() -> { try { for (int i = 1; i <= 5; i++) { buffer.produce(i); Thread.sleep(100); // Simulate production delay } } catch (InterruptedException e) { e.printStackTrace(); } }); // Consumer thread Thread consumer = new Thread(() -> { try { for (int i = 1; i <= 5; i++) { buffer.consume(); Thread.sleep(150); // Simulate consumption delay } } catch (InterruptedException e) { e.printStackTrace(); } }); // Start producer and consumer producer.start(); consumer.start(); } }
Explanation:
The buffer has a capacity of 2.
The producer produces values into the buffer, and the consumer consumes values from the buffer.
If the buffer is full, the producer waits. If the buffer is empty, the consumer waits.
Output:
Produced: 1 Produced: 2 Consumed: 1 Produced: 3 Consumed: 2 Produced: 4 Consumed: 3 Produced: 5 Consumed: 4 Consumed: 5
Conclusion
Java provides powerful mechanisms for inter-thread communication through wait(), notify(), and notifyAll(). These methods help threads coordinate their execution when they share resources. Key points to remember:
wait() puts the thread into a waiting state until another thread calls notify() or notifyAll().
notify() wakes up a single waiting thread, while notifyAll() wakes up all waiting threads.
Always use these methods inside a synchronized block to ensure safe communication between threads.
By mastering these concepts, you can build robust and efficient multithreaded applications in Java.