In the realm of multithreaded programming, where multiple threads execute concurrently, ensuring proper communication and coordination between them becomes paramount. This is where the concept of inter-thread communication in Java comes into play. It empowers threads to exchange information and synchronize their actions, fostering a collaborative environment within your application.

Understanding the Why: The Need for Inter-Thread Communication

Imagine a scenario where two threads, let's call them Thread A and Thread B, are tasked with managing a shared resource, like a counter variable. If these threads operate independently, without any form of communication, data inconsistency can arise. For instance, if both threads attempt to increment the counter simultaneously, the final value might be incorrect, leading to unexpected program behavior.

Introducing the Mechanisms: wait(), notify(), and notifyAll()

Inter-thread communication allows two threads to communicate with each other using the wait(), notify(), and notifyAll() methods. The thread that is expecting an update enters a waiting state by calling wait(). The thread responsible for performing the update then calls notify() after the update, allowing the waiting thread to receive the notification and continue its execution with the updated items.

These methods (wait(), notify(), and notifyAll()) are present in the Object class rather than the Thread class because threads can call these methods on any Java object.

To call wait(), notify(), or notifyAll() methods on an object, the thread must own the lock of that object, meaning it should be within a synchronized area. Otherwise, attempting to call these methods will result in a IllegalMonitorStateException.

When a thread calls wait() on an object, it immediately releases the lock of that particular object and enters a waiting state. On the other hand, when a thread calls notify() on an object, it releases the lock of that object, but the waiting thread may not immediately resume execution.

Unlike yield(), join(), and sleep(), which do not release locks, wait(), notify(), and notifyAll() are the only methods where a thread releases a lock.

The prototypes of these methods are:

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()

It's important to note that every wait() method throws an InterruptedException, a checked exception. Therefore, whenever using wait(), it's necessary to handle this exception either by using a try-catch block or by declaring the method to throw InterruptedException. Failure to do so will result in a compilation error.

Producer-Consumer Problem

class ProducerThread {
    void produce() {
        synchronized(q) {
            // produce items to the queue
            q.notify();
        }
    }
}

class ConsumerThread {
    void consume() {
        synchronized(q) {
            if (q.isEmpty())
                q.wait();
            else
                consumeItems();
        }
    }
}
  • 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.

Difference between notify() and notifyAll()

  • notify(): This method is used to give a notification to only one waiting thread. If multiple threads are waiting, only one thread will be notified, and the rest will continue to wait for further notifications. Which thread gets notified depends on the JVM's scheduling mechanism.
  • notifyAll(): This method is used to give a notification to all waiting threads for a particular object. Even if multiple threads are notified, the execution will still be performed one by one because each thread requires a lock, and only one lock is available at a time.

Deadlocks

Deadlock occurs when two or more threads are stuck waiting for each other to release resources that they need to proceed. The synchronized keyword is often the culprit for deadlock situations because it allows only one thread to execute a synchronized method on an object at a time, potentially leading to situations where one thread holds a lock while waiting for another thread to release a lock.

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();
    }
}

Here, deadlock arises because both class A and class B have synchronized methods (d1() and d2(), respectively) that call each other's last() method while holding the lock on their respective objects. This creates a scenario where one thread holds the lock on object A and waits for object B's lock, while another thread holds the lock on object B and waits for object A's lock. As a result, both threads are stuck in a waiting state indefinitely, leading to deadlock.

Removing any single synchronized keyword from the methods prevents the program from entering deadlock because it allows threads to execute methods concurrently without blocking each other. This highlights the importance of the synchronized keyword in causing deadlock situations. Therefore, it's crucial to exercise caution when using synchronized blocks or methods to avoid potential deadlock scenarios.

Difference between Deadlock and Starvation

Deadlock occurs when two or more threads are stuck waiting for each other to release resources, resulting in a situation where none of the threads can proceed. In deadlock, the waiting continues indefinitely, and there is no resolution unless external intervention breaks the cycle.

Starvation, on the other hand, refers to a situation where a thread is unable to gain access to resources or make progress despite being eligible to do so. In starvation, the waiting thread eventually gets access to resources but after a prolonged delay. This often happens in scenarios where lower-priority threads are continuously preempted by higher-priority threads, causing them to wait for extended periods.

For instance, in a system where low-priority threads have to wait until all high-priority threads complete their execution, the low-priority threads may experience starvation. Although they have to wait for an extended duration, their waiting eventually ends when high-priority threads finish their tasks and release the resources. This distinguishes starvation from deadlock, where waiting never ends unless intervened externally.

Daemon Threads

Daemon threads are threads that execute in the background, providing support for non-daemon threads, such as the main thread. Examples of daemon threads include the Garbage Collector, Signal Dispatcher, and Attach Listener.

The primary objective of daemon threads is to provide auxiliary services for non-daemon threads. For instance, if the main thread is running with low memory, the JVM may activate the Garbage Collector daemon thread to reclaim memory by destroying unused objects. This action improves the amount of free memory available for the main thread to continue its execution smoothly.

Daemon threads typically have a lower priority compared to non-daemon threads. However, depending on specific requirements, daemon threads can also run with higher priority.

You can determine whether a thread is a daemon thread or not by using the isDaemon() method of the Thread class, which returns a boolean indicating the daemon nature of the thread. Additionally, you can change the daemon nature of a thread using the setDaemon() method of the Thread class. However, it's important to note that changing the daemon nature of a thread is only possible before starting the thread. If you attempt to change the daemon nature of a thread after it has started, you will encounter a IllegalThreadStateException.

Default Nature of Thread

By default, the main thread is always non-daemon, while for all other threads, the daemon nature is inherited from their parent thread. If the parent thread is a daemon, then its child threads will also be daemons, and if the parent thread is non-daemon, then its child threads will also be non-daemons.

It's crucial to note that it's impossible to change the daemon nature of the main thread because it is started automatically by the JVM at the beginning of the program execution.

class MyThread extends Thread {
}

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

Here, the main thread is non-daemon, and when a new thread t is created, it inherits the daemon nature from its parent thread, making it non-daemon by default. However, we can explicitly set the daemon nature of t using the setDaemon() method.

Additionally, when the last non-daemon thread terminates, all daemon threads are automatically terminated, regardless of their position in the execution. This behavior ensures that daemon threads do not keep the JVM running unnecessarily.

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) {
                e.printStackTrace();
            }
        }
    }
}

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

If we set the child thread t as a daemon, then it will be terminated automatically when the main thread terminates, regardless of its position in the execution. Otherwise, if the child thread is non-daemon, both threads will continue execution until completion.

Java Thread Model

Green Thread Model:

  • In the Green Thread Model, threads are managed entirely by the JVM without relying on underlying OS support.
  • This model was implemented in some older systems like SUN Solaris but is deprecated and not recommended for use anymore due to its inefficiency and lack of widespread support.

Native OS Model:

  • In the Native OS Model, threads are managed by the JVM with the assistance of the underlying operating system.
  • All major operating systems, especially Windows-based ones, provide support for this model.

Thread Management Methods:

Stopping a Thread:

  • The stop() method is used to immediately terminate a thread, causing it to enter a dead state.
  • However, this method is deprecated and not recommended for use due to its unsafe nature and potential for leaving resources in an inconsistent state.

Suspending and Resuming a Thread:

  • Deprecated methods suspend() and resume() were historically used to suspend and resume the execution of a thread, respectively.
  • However, these methods are also deprecated and not recommended for use due to their potential to cause deadlocks and other synchronization issues.

Impact on Thread Lifecycle:

  • When the suspend() method is called, the thread immediately enters a suspended state, halting its execution.
  • The resume() method can be used to resume a suspended thread, allowing it to continue its execution from where it was suspended.
  • It's important to note that using suspend() and resume() methods is discouraged due to their potential to cause thread safety issues and deadlocks. It's recommended to use more modern concurrency utilities provided by Java, such as wait() and notify(), or higher-level concurrency constructs from the java.util.concurrent package.

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.