Java Inter-thread Communication Tutorial

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.

Related posts

Java Lambda Expressions

Java Base64 encoding and decoding

Java Regular Expressions tutorial with Examples