Inter-Thread Communication in Java: Mastering Thread Coordination
Written on
Understanding Inter-Thread Communication
In the world of multithreaded programming, where numerous threads operate simultaneously, effective communication and coordination among them are vital. This is where inter-thread communication in Java is crucial. It enables threads to share data and synchronize their activities, creating a collaborative framework within your application.
The Importance of Inter-Thread Communication
Consider a scenario involving two threads—Thread A and Thread B—that share a common resource, such as a counter variable. If these threads function autonomously without any communication, inconsistencies in data can occur. For example, if both threads try to increment the counter at the same time, the final result may be incorrect, leading to unpredictable behavior in the application.
Mechanisms for Communication: wait(), notify(), and notifyAll()
Inter-thread communication allows threads to interact using the methods wait(), notify(), and notifyAll(). The thread awaiting an update enters a waiting state by invoking wait(). Once the updating thread completes its task, it calls notify(), which wakes the waiting thread, allowing it to continue with the new information.
These methods are found in the Object class instead of the Thread class, as threads can call them on any Java object. To use wait(), notify(), or notifyAll(), a thread must hold the lock of that object by operating within a synchronized block. Otherwise, attempting to use these methods will trigger an IllegalMonitorStateException.
When a thread invokes wait() on an object, it relinquishes the lock of that object and enters a waiting state. Conversely, when notify() is called, the lock is released, but the waiting thread may not immediately resume its operation.
Unlike yield(), join(), and sleep(), which do not release locks, the methods wait(), notify(), and notifyAll() are unique in that they allow a thread to release a lock.
The method signatures are as follows:
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 crucial to remember that any wait() method will throw an InterruptedException, which is a checked exception. Therefore, using wait() requires handling this exception via a try-catch block or declaring the method to throw InterruptedException, or else you'll encounter a compilation error.
The 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 this problem, two threads—ProducerThread and ConsumerThread—interact with a shared queue. The ProducerThread generates items while the ConsumerThread consumes them. When the ProducerThread adds an item, it notifies any waiting ConsumerThread by calling notify() after synchronizing on the queue object. If the ConsumerThread finds the queue empty, it calls wait(), entering a waiting state until notified by the ProducerThread. Once notified, the ConsumerThread resumes its operation, consuming items from the queue.
Understanding notify() vs. notifyAll()
- notify(): This method signals just one waiting thread. If several threads are waiting, only one will be notified, and which one gets notified depends on the JVM's scheduling strategy.
- notifyAll(): This method notifies all waiting threads for the specified object. Although multiple threads are informed, they will execute one at a time since each requires a lock, and only one lock is available at any given moment.
Deadlocks
A deadlock occurs when two or more threads are caught waiting for each other to release resources required for their execution. Often, the synchronized keyword is the main cause of deadlocks, as it permits only one thread to execute a synchronized method on an object at any given time, leading to scenarios where one thread holds a lock while waiting for another lock to be released.
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 DeadLockExample 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) {
DeadLockExample d = new DeadLockExample();
d.m1();
}
}
In this example, deadlock occurs because both classes A and B have synchronized methods (d1() and d2()) that invoke each other's last() method while holding their respective locks. One thread holds the lock on object A while waiting for the lock on B, while the other thread holds the lock on B while waiting for the lock on A. Consequently, both threads are indefinitely stalled.
Eliminating any single synchronized keyword from the methods can prevent deadlock, allowing threads to execute methods concurrently without blocking one another. This highlights the need for caution when employing synchronized blocks or methods to avoid potential deadlock scenarios.
Deadlock vs. Starvation
Deadlock arises when two or more threads are indefinitely waiting for each other to release resources, leading to a situation where none can proceed. This waiting can only be resolved through external intervention.
Starvation, however, occurs when a thread is unable to access resources or make progress despite being eligible to do so. In this scenario, the waiting thread eventually gains access to resources but only after a significant delay. This often happens when lower-priority threads are consistently preempted by higher-priority threads, causing them to wait for long periods. For instance, in a system where low-priority threads must wait until all high-priority threads complete their execution, the low-priority threads may face starvation. Their waiting eventually ends when high-priority threads finish their tasks and free up the resources, distinguishing starvation from deadlock, which requires external intervention to resolve.
Daemon Threads
Daemon threads operate in the background, providing support to non-daemon threads like the main thread. Examples of daemon threads include the Garbage Collector, Signal Dispatcher, and Attach Listener.
The primary purpose of daemon threads is to offer auxiliary services for non-daemon threads. For example, if the main thread is low on memory, the JVM may activate the Garbage Collector daemon thread to reclaim memory by removing unused objects, thereby enhancing the available memory for the main thread's execution.
Daemon threads generally have a lower priority than non-daemon threads. However, they can also run with higher priority based on specific requirements.
You can determine if a thread is a daemon thread by using the isDaemon() method of the Thread class, which returns a boolean indicating its daemon status. Additionally, you can change a thread's daemon status using the setDaemon() method, but this can only be done before the thread starts. Attempting to change the daemon status of a running thread results in an IllegalThreadStateException.
Default Thread Nature
By default, the main thread is always non-daemon, while all other threads inherit their daemon status from their parent thread. If the parent thread is a daemon, its child threads will also be daemons, and vice versa for non-daemon parent threads.
It's crucial to note that the daemon status of the main thread cannot be altered since it is initiated automatically by the JVM at the beginning of 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
}
}
In this example, the main thread is non-daemon, and when a new thread t is created, it inherits the non-daemon status from its parent thread. However, we can explicitly change the daemon status of t using the setDaemon() method.
Additionally, when the last non-daemon thread terminates, all daemon threads are automatically terminated, regardless of their execution position. 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 the child thread t is set as a daemon, it will terminate automatically when the main thread finishes, regardless of its execution state. Conversely, if the child thread is non-daemon, both threads will continue executing until they complete.
Java Thread Management Models
Green Thread Model: In this model, threads are fully managed by the JVM without relying on support from the underlying operating system. This approach was utilized in older systems like SUN Solaris but is now deprecated due to inefficiency and lack of widespread support.
Native OS Model: In this model, threads are managed by the JVM with the help of the underlying operating system. Most major operating systems, especially Windows-based ones, support this model.
Thread Management Methods:
- Stopping a Thread: The stop() method was historically used to terminate a thread immediately, causing it to enter a dead state. However, this method is deprecated due to safety concerns and the potential for leaving resources in an inconsistent state.
- Suspending and Resuming a Thread: The deprecated suspend() and resume() methods were previously used to halt and resume thread execution, respectively. These methods are no longer recommended due to their potential to cause deadlocks and synchronization issues.
Impact on Thread Lifecycle:
When suspend() is invoked, the thread enters a suspended state, halting its execution. The resume() method can be used to continue execution from the suspended point. However, using these methods is discouraged due to their potential to create thread safety issues and deadlocks. It is advisable to use 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 vital in Java's multithreaded programming landscape for coordinating concurrent thread activities. By utilizing synchronization and mechanisms like the wait-notify protocol, threads can effectively communicate and synchronize their actions to prevent issues such as race conditions and deadlocks. A solid understanding of these concepts is essential for developing thread-safe and efficient concurrent applications in Java.
This video provides insights into inter-thread communication in Java, focusing on multithreading techniques.
In this video, discover various inter-thread communication methods in Java, including practical examples and best practices.