Mastering Inter-Thread Communication in Java: A Comprehensive Guide Part 4

Table of Contents

In multi-threaded programming, communication between threads is essential for coordinating their activities and sharing data. Inter-thread communication in Java refers to the mechanisms through which threads communicate with each other to synchronize their actions or exchange data. This communication enables threads to work cooperatively towards achieving a common goal. In this blog, we will explore the concepts of inter-thread communication in Java, including synchronization, shared memory, and the wait-notify mechanism.

Why Inter-Thread Communication?

Consider a scenario where multiple threads are executing concurrently and need to coordinate their tasks or exchange information. For instance, one thread may produce data, while another consumes it. In such cases, inter-thread communication becomes necessary to ensure the correct execution and synchronization of threads.

Java provides several mechanisms for inter-thread communication, including synchronized blocks, wait-notify, and locks. These mechanisms allow threads to coordinate their activities effectively and avoid issues such as race conditions and deadlock.

Inter-thread communication

Two threads can communicate with each other by using wait(), notify(), and notifyAll(). The thread that is expecting an update is responsible for calling wait(), after which the thread enters a waiting state. The thread responsible for performing the update should call notify() after completing the update, allowing the waiting thread to receive the notification and continue its execution with the updated items.

Why are wait(), notify(), and notifyAll() present in the Object class but not in the Thread class? wait(), notify(), and notifyAll() are present in the Object class rather than the Thread class because a thread can call these methods on any Java object.

To call wait(), notify(), and notifyAll() methods on any object, the thread must be the owner of that object, meaning it must hold the lock of that object. Therefore, these methods can only be called from within a synchronized area; otherwise, a IllegalMonitorStateException will be thrown.

If a thread calls wait() on any object, it immediately releases the lock of that particular object and enters a waiting state.

If a thread calls notify() on any object, it releases the lock of that object, but it may not happen immediately. Unlike wait(), notify(), and notifyAll(), there are no other methods where a thread releases a lock.

Hence, in the cases of yield(), join(), and sleep(), the thread does not release the lock, but it does release the lock in wait(), notify(), and notifyAll().

Prototypes of methods:

Java
public final void wait() throws InterruptedException
public final native void wait(long ms) throws InterruptedException 
public final void wait(long ms, int ns) throws InterruptedException 

public final native void notify()
public final native void notifyAll()

Note: Every wait() method throws InterruptedException, which is a checked exception. Therefore, whenever we use the wait() method, we must handle this InterruptedException, either with a try-catch block or by using the throws keyword; otherwise, a compile-time error will occur.

What is impact of these methods on thread lifecycle?

The wait(), notify(), and notifyAll() methods in Java have a significant impact on the lifecycle of threads. These methods are primarily used for inter-thread communication and synchronization. Let’s explore their impact on the lifecycle of threads:

wait()

  • Impact on Thread Lifecycle: When a thread calls the wait() method, it voluntarily gives up its hold on the object’s monitor (lock) and enters into the “waiting” state. This means that the thread is temporarily suspended and does not consume CPU resources until one of the following conditions occurs:
    • Another thread calls notify() or notifyAll() on the same object.
    • The specified timeout period elapses (if wait(long timeout) is used).
  • Transition in Thread Lifecycle: From the “waiting” state, a thread can transition back to the “runnable” state (ready to run) once it is notified or the timeout expires.
  • Example Use Case: Typically used to wait for a specific condition to be met or for another thread to complete its task before proceeding.

notify()

  • Impact on Thread Lifecycle: The notify() method is used to wake up a single thread that is waiting on the object’s monitor. If multiple threads are waiting, the choice of which thread to wake up is arbitrary and depends on the JVM’s implementation.
  • Transition in Thread Lifecycle: When notify() is called, the awakened thread transitions from the “waiting” state to the “runnable” state. However, it does not immediately acquire the object’s monitor. It competes with other threads to obtain the lock once it becomes available.
  • Example Use Case: Often used to signal that a condition has changed and other threads waiting on that condition can proceed.

notifyAll()

  • Impact on Thread Lifecycle: Similar to notify(), but notifyAll() wakes up all threads that are waiting on the object’s monitor. All awakened threads transition to the “runnable” state and compete for the object’s monitor.
  • Transition in Thread Lifecycle: Threads waiting on the object’s monitor are awakened and transitioned to the “runnable” state. They compete for the lock once it becomes available.
  • Example Use Case: Useful when multiple threads are waiting for a shared resource to become available or when a significant change occurs that affects multiple threads.

Example(Wait,Notify,NotifyAll)

Java
class ThreadA {
    public static void main(String[] args) throws Exception {
        ThreadB b = new ThreadB();
        b.start();
        synchronized (b) {
            System.out.println("Main thread calling wait method"); // -----------------------------> 1
            b.wait();
            System.out.println("Main thread got notification"); //-----------------------------> 4
            System.out.println(b.total); // -----------------------------> 5
        }
    }
}

class ThreadB extends Thread {
    int total = 0;

    public void run() {
        System.out.println("Child thread starts calculation"); // ----------------------------->2
        for (int i = 1; i <= 100; i++) {
            total = total + i;
        }
        System.out.println("Child thread giving notification"); // ----------------------------->3
        this.notify();
    }
}
Java
Main thread calling wait method
Child thread starts calculation 
Child thread giving notification 
Main thread got notification 
5050

Here,

  • The main thread starts and creates an instance of ThreadB and starts it.
  • Main thread enters a synchronized block with object b.
  • The main thread calls wait() on b, releasing the lock and going into a waiting state.
  • Meanwhile, ThreadB starts execution and calculates the sum.
  • After calculation, ThreadB calls notify(), waking up the waiting main thread.
  • The main thread resumes execution, prints “Main thread got notification”, and then prints the total sum calculated by ThreadB, which is 5050.

Producer-Consumer Problem

In the Producer-Consumer problem, the Producer thread is responsible for producing items to the queue, while the Consumer thread is responsible for consuming items from the queue. If the queue is empty, the Consumer thread calls the wait() method and enters a waiting state. After the Producer thread produces an item and adds it to the queue, it is responsible for calling notify(). This notification allows the waiting Consumer thread to resume execution and continue processing the updated items.

Java
// Producer thread is responsible for producing items to the queue
class ProducerThread {
    void produce() {
        synchronized(q) {
            // Produce items to the queue
            q.notify();
        }
    }
}

// Consumer thread is responsible for consuming items from the queue
class ConsumerThread {
    void consume() {
        synchronized(q) {
            if (q.isEmpty())
                q.wait(); // If the queue is empty, consumer thread enters waiting state
            else
                consumeItems(); // Consume items from the queue
        }
    }
}
  • In the Producer-Consumer Problem, there are two threads: ProducerThread and ConsumerThread.
  • ProducerThread is responsible for producing items and ConsumerThread is responsible for consuming items.
  • When ProducerThread produces an item, it notifies any waiting ConsumerThread by calling notify() after synchronizing on the queue object.
  • ConsumerThread, after synchronizing on the queue object, checks if the queue is empty.
  • If the queue is empty, ConsumerThread calls wait(), entering a waiting state until notified by the ProducerThread.
  • Once ProducerThread produces an item and notifies, the waiting ConsumerThread gets the notification and continues its execution, consuming the items from the queue.

This process ensures that the ProducerThread and ConsumerThread synchronize their actions properly to avoid issues such as producing when the queue is full or consuming when the queue is empty.

Difference between notify() and notifyAll()

The notify() method is used to provide a notification to only one waiting thread. If multiple threads are waiting, only one thread will be notified, and the remaining threads have to wait for further notifications. Which thread will be notified cannot be predicted, as it depends on the JVM.

On the other hand, the notifyAll() method is used to provide notification to all waiting threads of a particular object. Even if multiple threads are notified, execution will be performed one by one because threads require a lock, and only one lock is available.

Using notify() and notifyAll() allows for controlling the synchronization and communication between threads effectively, depending on the specific requirements of the application.

Understanding Lock Acquisition in Synchronized Blocks with wait()

To unserstand this concept better, let’s first see blow eamples and then we will discuss it further.

Java
Stack s1 = new Stack();
Stack s2 = new Stack();

synchronized(s1) {
    // ...
    s2.wait(); // This will result in an IllegalMonitorStateException
    // ...
}

We will encounter an IllegalMonitorStateException in the above case. This is because the wait() method is being called on object s2, but the synchronization block is held on object s1. Therefore, the thread does not have the lock for s2, leading to the illegal state exception.

However, in the second example:

Java
synchronized(s1) {
    // ...
    s1.wait(); // This is a valid usage
    // ...
}

The above example is perfectly valid. Here, the wait() method is called on the same object s1 on which the synchronization block is held. Hence, the thread has the lock for s1, ensuring that the call to wait() is valid.

Note: When calling the wait() method on an object, the thread requires the lock of that particular object. For instance, if wait() is called on s1, the thread acquires the lock of s1 but not s2.

Deadlocks

Deadlocks occur primarily due to the use of the synchronized keyword, hence special care must be taken when using it. There are no resolution techniques for deadlocks, but several prevention techniques are available.

Java
class A {
    public synchronized void d1(B b) {
        System.out.println("Thread 1 starts execution of d1() method");
        try {
            Thread.sleep(6000);
        } catch(InterruptedException e) {}
        System.out.println("Thread 1 trying to call B's last()");
        b.last();
    }

    public synchronized void last() {
        System.out.println("Inside A, this is last() method");
    } 
}

class B {
    public synchronized void d2(A a) {
        System.out.println("Thread 2 starts execution of d2() method");
        try {
            Thread.sleep(6000);
        } catch(InterruptedException e) {}
        System.out.println("Thread 2 trying to call A's last()");
        a.last();
    }

    public synchronized void last() {
        System.out.println("Inside B, this is last() method");
    }
}

class DeadLock1 extends Thread {
    A a = new A();
    B b = new B();

    public void m1() {
        this.start();
        a.d1(b);  // This line executed by main thread
    }

    public void run() {
        b.d2(a); // this line executed by child thread 
    }

    public static void main(String[] args) {
        DeadLock1 d = new DeadLock1();
        d.m1();
    }
}

Output-

Java
Thread 1 starts execution of d1() method
Thread 2 starts execution of d2() method
Thread 2 trying to call A's last()
Thread 1 trying to call B's last()

In the above program, if we remove any single synchronized keyword, then the program won’t enter into a deadlock. Therefore, the synchronized keyword is the only reason for the deadlock situation. Due to this, special care must be taken when using synchronized keywords.

Deadlock Versus Starvation

Deadlock occurs when threads are blocked forever because they are each waiting for a resource that the other thread holds. It results in a situation where no progress can be made.

On the other hand, starvation refers to a situation where a thread is unable to gain regular access to shared resources and is unable to make progress. However, the waiting of the thread in starvation eventually ends at certain points.

For example, if a low priority thread has to wait until completing all high priority threads, it will experience long waiting but will eventually get a chance to execute, which is a form of starvation.

Daemon Threads

Daemon threads are those executing in the background without interfering with the termination of the main application. Examples of daemon threads include the Garbage Collector, Signal Dispatcher, and Attach Listener.

The main objective of daemon threads is to provide support for non-daemon threads (such as the main thread). For instance, if the main thread is running with low memory, the JVM may run the garbage collector to reclaim memory from unused objects. This action improves the amount of free memory, allowing the main thread to continue its execution smoothly.

Usually, daemon threads have low priority, but depending on our requirements, they can run with high priority as well.

We can check if a thread is a daemon thread by using the isDaemon() method of the Thread class:

Java
public boolean isDaemon()

Similarly, we can change the daemon nature of a thread using the setDaemon() method of the Thread class, but this change is only possible before starting the thread. If we attempt to change the daemon nature after starting the thread, we will encounter an IllegalThreadStateException.

Default Nature of Thread

The default nature of thread is such that the main thread is always non-daemon, while for all other threads, the daemon nature is inherited from their parent thread. This means that if the parent thread is a daemon, then the child thread will also be a daemon, and if the parent thread is non-daemon, then the child thread will also be non-daemon.

It is impossible to change the daemon nature of the main thread because it is already started by the JVM at the beginning.

For example:

Java
class MyThread extends Thread {
}

class Test {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().isDaemon()); // false
        // Thread.currentThread().setDaemon(true);  // This will result in IllegalThreadStateException
        MyThread t = new MyThread();
        System.out.println(t.isDaemon()); // false
        t.setDaemon(true);
        System.out.println(t.isDaemon()); // true
    }
}

When the last non-daemon thread terminates, all daemon threads are automatically terminated regardless of their position.

For example:

Java
class MyThread extends Thread {
    public void run() {
        for(int i = 0; i < 10; i++) {
            System.out.println("Child thread");
            try {
                Thread.sleep(2000);
            } catch(InterruptedException e) {}
        }
    }
}

class DaemonThreadDemo {
    public static void main(String[] args) {
        MyThread t = new MyThread();
        t.setDaemon(true); // This makes the child thread a daemon
        t.start();
        System.out.println("End of main thread");
    }
}

If we comment out line 1 (t.setDaemon(true);), then both the main and child threads are non-daemon, and both threads will execute until their completion. However, if line 1 is not commented, the main thread is non-daemon, and the child thread is daemon. In this case, when the main thread terminates, the child thread will also be terminated. The possible outputs in this case are:

Java
//output 1

End of main thread
Child Thread


//output 2

End of main thread


//output 3

Child Thread
End of main thread
```"

Multithreading Models

Java multithreading concepts are implemented using the following two models:

  1. Green Thread Model: Threads managed entirely by the JVM without relying on underlying OS support are called green threads. Very few operating systems, such as SUN Solaris, provide support for the green thread model. However, the green thread model is deprecated and not recommended for use.
  2. Native OS Model: Threads managed by the JVM with the assistance of the underlying operating system are referred to as the native OS model. All Windows-based operating systems provide support for the native OS model.

How to stop a thread?

If the stop() method is called, the thread immediately enters the dead state. However, the stop() method is deprecated and not recommended for use.

How to suspend and resume a thread?

The suspend() and resume() methods are used to suspend and resume threads, respectively. However, these methods are deprecated and not recommended for use.

When the suspend() method is called, the thread immediately enters the suspended state. A suspended thread can be resumed using the resume() method of the Thread class, allowing the suspended thread to continue its execution.

Part 5:

Conclusion

Inter-thread communication is essential in Java multi-threaded programming for coordinating the activities of concurrent threads. By using synchronization and mechanisms like the wait-notify protocol, threads can communicate effectively and synchronize their actions to avoid issues such as race conditions and deadlock. Understanding these concepts is crucial for writing thread-safe and efficient concurrent programs in Java.

Skill Up: Software & AI Updates!

Receive our latest insights and updates directly to your inbox

Related Posts

error: Content is protected !!