Java Multithreading | Part 2

Published on July 16, 2024 12 min read

Tags:
Java

Different Ways of Thread Creation in Java

Creating threads in Java can be done in two primary ways: by implementing the Runnable interface or by extending the Thread class. Each method has its own use cases and advantages.

Implementing Runnable Interface

Using the Runnable interface is a common approach for creating threads in Java. This method promotes better design as it separates the task to be performed from the actual thread execution.

Step 1: Create a Runnable Object

Create a class that implements the Runnable interface and implement the run() method to define the task the thread should perform.

public class Task implements Runnable {
 
    @Override
    public void run() {
        System.out.println("Task executed by thread: " + Thread.currentThread().getName());
    }
}
Step 2: Start the Thread

Create an instance of the class that implements Runnable, pass it to a Thread constructor, and start the thread.

public class Main {
    public static void main(String[] args) {
        System.out.println("Starting main method: " + Thread.currentThread().getName());
 
        Task task = new Task();
        Thread thread = new Thread(task);
        thread.start();
 
        System.out.println("Main method finished: " + Thread.currentThread().getName());
    }
}

Output:

Starting main method: main
Main method finished: main
Task executed by thread: Thread-0

Extending the Thread Class

Another way to create a thread is by extending the Thread class. This method is less flexible than implementing Runnable because it doesn't allow extending any other class.

Example:
public class TaskThread extends Thread {
 
    @Override
    public void run() {
        System.out.println("Task executed by thread: " + Thread.currentThread().getName());
    }
}
 
public class Main {
    public static void main(String[] args) {
        System.out.println("Starting main method: " + Thread.currentThread().getName());
 
        TaskThread thread = new TaskThread();
        thread.start();
 
        System.out.println("Main method finished: " + Thread.currentThread().getName());
    }
}

Output:

Starting main method: main
Main method finished: main
Task executed by thread: Thread-0

When to Use Which?

  • Implementing Runnable Interface: Preferred when the task being performed by the thread can be separated from the thread's execution mechanism. It allows the class to extend other classes if needed.
  • Extending Thread Class: Used when you need to override other thread methods in addition to run().

Monitor Locks

A monitor lock is a synchronization mechanism used in concurrent programming to ensure that only one thread can execute a block of code or access a shared resource at a time. In the context of Java, every object has an associated monitor lock. When a thread enters a synchronized block or method, it acquires the monitor lock for the object, preventing other threads from entering any synchronized blocks or methods associated with that object until the lock is released.

Key points about monitor locks:

  • Exclusive Access: Only one thread can hold the monitor lock at a time.
  • Synchronization: Used to synchronize access to shared resources, preventing data inconsistency and race conditions.
  • Intrinsic Locks: In Java, these are also known as intrinsic locks or intrinsic monitors.
  • Wait and Notify: Threads can enter a waiting state within a synchronized block and be notified to resume execution using wait(), notify(), and notifyAll() methods, which also involve the monitor lock.

Here's a basic example in Java:

public class Counter {
    private int count = 0;
 
    public synchronized void increment() {
        count++;
    }
 
    public synchronized int getCount() {
        return count;
    }
}

In this example, the increment() and getCount() methods are synchronized, meaning they use the monitor lock of the Counter object. Only one thread can execute either of these methods at a time.

Spurious Wakeups

Spurious wakeups occur when a thread is woken up without receiving any signal. Understanding and handling these occurrences is crucial for writing robust multithreaded Java applications.

The term "spurious" means fake or false. A spurious wakeup is when a thread is woken up even though no signal has been received. This phenomenon is a reality and is one reason why waiting on a condition variable typically happens in a while loop.

Here's an excerpt from Java's documentation for the wait(long timeout) method:

* A thread can also wake up without being notified, interrupted, or
* timing out, a so-called <i>spurious wakeup</i>. While this will rarely
* occur in practice, applications must guard against it by testing for
* the condition that should have caused the thread to be awakened and
* continuing to wait if the condition is not satisfied. In other words,
* waits should always occur in loops, like this one:
*
* synchronized (obj) {
*     while (condition does not hold)
*         obj.wait(timeout);
*     ... // Perform action appropriate to condition
* }

Handling Spurious Wakeups

To handle spurious wakeups correctly, always wait in a loop that checks the condition:

synchronized (obj) {
    while (condition does not hold)
        obj.wait(timeout);
    // Perform action appropriate to condition
}

Spurious wakeups are rare but can cause issues if not handled properly. By waiting in a loop and rechecking the condition, you can ensure your application behaves correctly even in the presence of spurious wakeups.


Understanding the Lifecycle of a Thread in Java

A thread in Java goes through various stages while executing or completing tasks. These stages collectively represent the lifecycle of a thread. Here's an overview of the main and additional states a thread can go through.

Main Thread States

  • New/Born: When we create a thread with Thread t1 = new Thread(), it is in the New/Born state.
  • Ready/Runnable: When we call t1.start(), the thread enters the Ready or Runnable state.
  • Running: If the thread scheduler allocates CPU time to the thread, it enters the Running state.
  • Dead: Once the run() method completes successfully, the thread enters the Dead state.

These are the basic main states of a thread. However, threads can also transition through various other states based on different conditions.

Thread Lifecycle

Additional Thread States

  1. Ready to Running: If a running thread calls Thread.yield(), it moves back to the Ready state to give other threads of the same priority a chance to run.

  2. Waiting State: When a thread calls join(), it enters the waiting state. It can come out of this state and move to the Ready/Runnable state if:

    • The thread completes its execution.
    • The specified time expires.
    • The waiting thread is interrupted.
  3. Sleeping State: If a running thread calls sleep(), it immediately enters the sleeping state. The thread will come out of this state and return to the Ready state if:

    • The specified time expires.
    • The sleeping thread is interrupted.
  4. Waiting for Notification: When a thread calls wait(), it enters a waiting state. The thread moves to another waiting state to acquire a lock if:

    • It receives a notification via notify() or notifyAll().
    • The specified time expires.
    • The waiting thread is interrupted.
  5. Suspended State: If a running thread calls suspend(), it enters the suspended state. It will only move to the Ready state when resume() is called.

  6. Termination: When a running thread calls stop(), it immediately enters the dead state.

Note: The methods suspend(), resume(), and stop() are deprecated and not recommended for use due to potential deadlock issues.

STOP: Abruptly terminates the thread without releasing locks or cleaning up resources.

SUSPEND: Temporarily holds the thread without releasing locks. (This is similar to wait(), but unlike wait(), suspend() does not release locks.)

RESUME: Deprecated along with SUSPEND, thus no longer in use.

Example Producer-Consumer problem in Java using a shared buffer and basic synchronization:

The Producer-Consumer problem involves multiple threads: producers adding data items to a shared buffer and consumers removing them. The challenge is to synchronize their access to the buffer, ensuring producers wait when it's full and consumers wait when it's empty, while preventing race conditions and ensuring efficient thread communication

import java.util.LinkedList;
import java.util.Queue;
 
class SharedBuffer {
    private Queue<Integer> buffer = new LinkedList<>();
    private int capacity;
 
    public SharedBuffer(int capacity) {
        this.capacity = capacity;
    }
 
    public synchronized void produce(int item) throws InterruptedException {
        while (buffer.size() == capacity) {
            wait(); // Wait if buffer is full
        }
        buffer.offer(item);
        System.out.println("Produced: " + item);
        notify(); // Notify consumer that new item is produced
    }
 
    public synchronized int consume() throws InterruptedException {
        while (buffer.isEmpty()) {
            wait(); // Wait if buffer is empty
        }
        int item = buffer.poll();
        System.out.println("Consumed: " + item);
        notify(); // Notify producer that an item is consumed
        return item;
    }
}
 
class Producer extends Thread {
    private SharedBuffer buffer;
 
    public Producer(SharedBuffer buffer) {
        this.buffer = buffer;
    }
 
    @Override
    public void run() {
        for (int i = 1; i <= 5; i++) {
            try {
                buffer.produce(i);
                Thread.sleep((int) (Math.random() * 100)); // Simulate some work
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}
 
class Consumer extends Thread {
    private SharedBuffer buffer;
 
    public Consumer(SharedBuffer buffer) {
        this.buffer = buffer;
    }
 
    @Override
    public void run() {
        for (int i = 1; i <= 5; i++) {
            try {
                int item = buffer.consume();
                Thread.sleep((int) (Math.random() * 100)); // Simulate some work
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}
 
public class Main {
    public static void main(String[] args) {
        SharedBuffer buffer = new SharedBuffer(3); // Buffer capacity of 3
        Producer producer = new Producer(buffer);
        Consumer consumer = new Consumer(buffer);
 
        producer.start();
        consumer.start();
    }
}
Explanation:
  • SharedBuffer: Manages a shared buffer (Queue<Integer> buffer) with a specified capacity. It provides synchronized methods produce(int item) and consume() to add items to and remove items from the buffer respectively. It uses wait() and notify() for synchronization.

  • Producer: Extends Thread and produces (adds) items to the shared buffer using produce(int item) method of SharedBuffer.

  • Consumer: Also extends Thread and consumes (removes) items from the shared buffer using consume() method of SharedBuffer.

  • Main: Creates an instance of SharedBuffer with capacity 3, and instances of Producer and Consumer. Starts their threads to run concurrently.

Key Points:
  • The produce() method checks if the buffer is full (buffer.size() == capacity) and waits (wait()) if true. Once an item is produced, it adds (offer()) it to the buffer and notifies (notify()) the consumer.

  • The consume() method checks if the buffer is empty (buffer.isEmpty()) and waits (wait()) if true. Once an item is consumed, it removes (poll()) it from the buffer and notifies (notify()) the producer.

  • wait() and notify() methods are used for synchronization to ensure that producers and consumers wait appropriately when the buffer is full or empty, and to avoid race conditions.


JOIN Method

When the JOIN method is invoked on a thread object, the current thread will be blocked, waiting for the specific thread to finish. This is particularly useful when coordinating between threads or ensuring that a certain task is completed before proceeding.

Here’s an example demonstrating the usage of JOIN:

package multithreading;
 
public class ThreadJoin {
    public static void main(String[] args) {
        System.out.println("Main Thread started " + Thread.currentThread().getName());
        SharedItem item = new SharedItem();
        Thread t1 = new Thread(() -> {
            item.runTask();
        });
        t1.start();
        try {
            t1.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("Main Thread execution end");
    }
}
 
class SharedItem {
    public synchronized void runTask() {
        System.out.println("Lock acquired by t1");
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("Lock released by t2");
    }
}

Output with JOIN:

Main Thread started main
Lock acquired by t1
Lock released by t2
Main Thread execution end

In this example, the main thread waits for t1 to finish its execution before proceeding.

Output without JOIN:

If we remove t1.join();, the main thread might end before t1 completes:

public class ThreadJoin {
    public static void main(String[] args) {
        System.out.println("Main Thread started " + Thread.currentThread().getName());
        SharedItem item = new SharedItem();
        Thread t1 = new Thread(() -> {
            item.runTask();
        });
        t1.start();
        // t1.join(); // Removed join
        System.out.println("Main Thread execution end");
    }
}
Main Thread started main
Main Thread execution end
Lock acquired by t1
Lock released by t2

Thread Priority

Thread priorities are integers ranging from 1 to 10:

  • 1: Low priority
  • 10: Highest priority

While thread priorities can be set, they do not guarantee a specific execution order. Instead, they serve as a hint to the thread scheduler.

When a new thread is created, it inherits the priority of its parent thread. You can set a custom priority using the setPriority(int priority) method.

Thread t1 = new Thread(() -> {
    // Task for thread t1
});
t1.setPriority(7); // Setting custom priority
t1.start();

Note: The actual scheduling of threads depends on the underlying OS and JVM implementation, so priority does not enforce strict order.

Daemon Threads

Daemon threads are designed for background tasks that do not prevent the JVM from exiting. Unlike regular threads, daemon threads do not keep the application running when all user threads have finished their execution.

To set a thread as a daemon, use the setDaemon(true) method before starting the thread.

Thread t1 = new Thread(() -> {
    // Background task for thread t1
});
t1.setDaemon(true); // Setting the thread as daemon
t1.start();

Understanding and managing thread behavior is crucial for developing efficient Java applications. By using methods like JOIN, adjusting thread priorities, and utilizing daemon threads, you can have better control over your application's concurrency.

For more in-depth information, stay tuned for future posts on advanced thread management techniques.