Java Multithreading | Part 2
Published on July 16, 2024 • 12 min read

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 torun()
.
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()
, andnotifyAll()
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.
Additional Thread States
-
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. -
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.
-
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.
-
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()
ornotifyAll()
. - The specified time expires.
- The waiting thread is interrupted.
- It receives a notification via
-
Suspended State: If a running thread calls
suspend()
, it enters the suspended state. It will only move to the Ready state whenresume()
is called. -
Termination: When a running thread calls
stop()
, it immediately enters the dead state.
Note: The methods
suspend()
,resume()
, andstop()
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 specifiedcapacity
. It provides synchronized methodsproduce(int item)
andconsume()
to add items to and remove items from the buffer respectively. It useswait()
andnotify()
for synchronization. -
Producer: Extends
Thread
and produces (adds) items to the shared buffer usingproduce(int item)
method ofSharedBuffer
. -
Consumer: Also extends
Thread
and consumes (removes) items from the shared buffer usingconsume()
method ofSharedBuffer
. -
Main: Creates an instance of
SharedBuffer
with capacity 3, and instances ofProducer
andConsumer
. 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()
andnotify()
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 priority10
: 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.