In the realm of software development, multithreading emerges as a powerful tool for building applications that can execute multiple tasks seemingly concurrently, enhancing responsiveness and performance. Java, a widely used language, offers robust support for multithreading through its Thread
class and Runnable
interface. Multithreading in Java is a powerful feature that allows concurrent execution of multiple threads within the same process. It’s crucial for building scalable, responsive, and efficient applications, especially in today’s world where multi-core processors are prevalent. In this comprehensive guide, we will delve into the depths of Java multithreading, covering everything from the basics to advanced concepts.
What is Multithreading in Java?
Before understanding multithreading, we first need to understand multitasking. Let’s see what it is.
Multitasking: Executing several tasks simultaneously is the concept of multitasking. There are two types of multitasking:
- Process-based: Executing several tasks simultaneously, where each task is a separate independent program (Process), is called process-based multitasking. For example, while typing a Java program in an editor, we can listen to audio songs from the same system, simultaneously download a file from the internet. All these tasks will be executed simultaneously and independently of each other; hence, it is process-based multitasking. Process-based multitasking is best suited at the OS level.
- Thread-based: Executing several tasks simultaneously, where each task is a separate independent part of the same program, is called thread-based multitasking. Each independent part is called a thread. Thread-based multitasking is best suited at the program level. For example, developing multimedia graphics, animation, video games, web server, and application server, etc.
Whether it is process-based or thread-based, the main objective of multitasking is to reduce the response time of the system and improve performance.
Now, What is Multithreading?
Multithreading refers to the concurrent execution of multiple threads within a single process. Each thread represents an independent flow of control, allowing programs to perform multiple tasks simultaneously.
BTW, What is Concurrency: The ability of tasks to appear to execute simultaneously, even on single-core processors.
Why Multithreading?
Multithreading enables applications to utilize the available CPU resources efficiently, especially on multi-core processors. It improves responsiveness by allowing tasks to run in the background while the main application thread remains responsive.
Threads vs. Processes
A thread is a lightweight process, and multiple threads can exist within a single process. Threads share the same memory space, making communication between them more efficient compared to processes, which have separate memory spaces.
Defining a Thread
We can define a thread by:
- By extending the Thread class
- By implementing the Runnable Interface
Let’s take a closer look at each one.
By Extending the Thread class
To define a thread by extending the Thread class, a new class can be created that extends the Thread class. Here’s an example:
class MyThread extends Thread // This way we can define a thread by extending the Thread class
{
public void run()
{
for(int i=0; i<10; i++) // Inside the run method, whatever is there is the job of the thread. Here, this for loop is the job of this child thread and it is executed independently.
{
System.out.println("child thread");
}
}
}
In this method, the class MyThread
extends the Thread
class, enabling it to define a thread. The run()
method within this class signifies the task to be executed by the thread. In this example, the loop within the run()
method represents the specific job to be performed by the child thread.
Thread Execution and Thread Scheduler
Let’s see below example first.
class ThreadDemo {
public static void main(String[] args) {
// At this point, only one thread is executing, and that is the main thread.
MyThread t = new MyThread(); // Thread instantiation
t.start(); // Starting the thread
// At this point, two threads are executing simultaneously: one is the main thread, and the other is the child thread (t - MyThread).
for(int i=0; i<10; i++) // This for loop is executed by the main thread and not by its child thread.
{
System.out.println("main thread");
}
}
}
The behavior of thread execution is influenced by the thread scheduler, a component of the JVM responsible for managing and scheduling threads.
Thread Scheduler
It is a part of JVM and is responsible for scheduling threads. If multiple threads are waiting to get a chance for execution, the order in which threads will be executed is decided by the thread scheduler. We can’t expect the exact algorithm followed by the thread scheduler as it varies from JVM to JVM. Hence, we can’t expect the thread execution order and exact output. Whenever the situation involves multithreading, there is no guarantee of exact output, but we can provide several possible outputs. The following are various possible outputs for the above program:
Possible Outputs:
Output 1:
Main thread
Main thread
...
Child thread
Child thread
...
Output 2:
Child Thread
Child Thread
...
Main Thread
Main Thread
...
Output 3:
Main Thread
Child Thread
Main Thread
Child Thread
...
Output 4:
Child Thread
Main Thread
Child Thread
...
Due to the unpredictable nature of thread scheduling, any of these scenarios (or variations thereof) could occur during program execution. It’s important to note that while we cannot guarantee a specific output, understanding the behavior of thread scheduling enables us to anticipate various possible outcomes in multithreading scenarios.
Difference between t.start() and t.run()
In the case of t.start()
, a new thread will be created which is responsible for the execution of the run
method. However, in the case of t.run()
, a new thread won’t be created, and the run
method will be executed just like a normal method call by the main thread. Hence, in the above program, if we replace t.start()
with t.run()
, then the output is:
Child Thread
Child Thread
.
.
.
Main Thread
Main Thread
Main Thread
This total output is produced only by the main thread.
Importance of Thread class start method
The start
method of the Thread
class is responsible for registering the thread with the thread scheduler and performing all other mandatory activities. Without executing the start
method of the Thread
class, there is no chance of starting a new thread in Java. Due to this, the start
method of the Thread
class is considered the heart of multithreading.
start()
{
1. Register this thread with the Thread Scheduler
2. Perform all other mandatory activities
3. Invoke run()
}
Each step in the start()
method plays a critical role in setting up and launching a new thread, making it indispensable for effective multithreading in Java.
Overloading of the run method
Overloading of the run
method is always possible, but the start
method of the Thread
class will always invoke the no-argument run
method. The other overloaded methods must be called explicitly like a normal method call.
For instance:
class MyThread extends Thread {
public void run() {
System.out.println("no arg run");
}
public void run(int i) {
System.out.println("int arg run");
}
}
class ThreadDemo {
public static void main(String[] args) {
MyThread t = new MyThread();
t.start(); // This will invoke the no argument run method implicitly.
}
}
Output: no arg run
Here, although the MyThread
class has an overloaded run
method that accepts an integer argument, it won’t be invoked implicitly when the thread starts. To execute the overloaded run
method with an integer argument, it must be called explicitly like any other method.
Without overriding the run method
If we are not overriding the run method, then the Thread class’s run method will be executed, which has an empty implementation. Hence, we won’t get any output.
Means, If the run
method is not overridden in a subclass of Thread
, then the default run
method provided by the Thread
class will be executed. This default run
method has an empty implementation, resulting in no output.
class MyThread extends Thread {
// No run method overridden
}
class Test {
public static void main(String[] args) {
MyThread t = new MyThread();
t.start();
}
}
//No output
Note: It is highly recommended to override the run method; otherwise, don’t proceed with the multithreading concept.
Overriding start method
If we override the start
method, then our overridden start
method will be executed just like a normal method call, and a new thread won’t be created.
class MyThread extends Thread {
public void start() {
System.out.println("start method");
}
public void run() {
System.out.println("run method");
}
}
class Test {
public static void main(String[] args) {
MyThread t = new MyThread();
t.start();
System.out.println("main method");
}
}
////////////////////////////////
output
start method
main method
// Note: This output is produced only by the main thread.
Here, the output is produced solely by the main thread since no new thread is created. The overridden start
method in the MyThread
class behaves like a regular method call, printing “start method” before executing the main method. It’s important to note that overriding the start
method is not recommended in general, especially when dealing with multithreading concepts.
Note: It is not recommended to override the start method; otherwise, don’t proceed with the multithreading concept.
If we made one small change in the above program, then the output will change as follows:
class MyThread extends Thread {
public void start() {
super.start(); // due to this line two threads run simultaneously that is child and main thread so o/p also vary
System.out.println("Start method");
}
public void run() {
System.out.println("run method");
}
}
class Test {
public static void main(String[] args) {
MyThread t = new MyThread();
t.start();
System.out.println("main Thread");
}
}
Here, due to the invocation of super.start()
within the overridden start
method of the MyThread
class, two threads, the child thread, and the main thread, run simultaneously, thereby causing variations in the output. Here are the possible outputs:
Output 1
run method
Start method
main Thread
In this case, the child thread executes its run
method first, followed by the completion of the start
method in the child thread, and then the main thread executes its main
method.
Output 2
Start method
main Thread
run method
Here, the start
method in the child thread executes first, followed by the completion of the main
method in the main thread, and then the run
method in the child thread executes.
Output 3
Start method
run method
main Thread
In this case, the start
method in the child thread executes first, followed by the execution of the run
method in the child thread, and finally, the main
method in the main thread executes.
Thread Lifecycle
Threads in Java go through various states during their lifecycle:
- New: When a thread is created but not yet started.
- Runnable: When a thread is ready to run but waiting for CPU time.
- Blocked: When a thread is waiting for a monitor lock to enter a synchronized block or waiting on I/O operations.
- Waiting: When a thread is waiting indefinitely for another thread to perform a particular action.
- Timed Waiting: Similar to waiting, but with a specified timeout period.
- Terminated: When a thread completes its execution or is terminated prematurely.
The life cycle of a thread in Java can be outlined as follows:
[New/Born] -------- t.start() --------> [Ready/Runnable]
-------- If Thread Scheduler allocates processor -------->
[Running] -------- If run() method completes --------> [Dead]
- New/Born: The thread is created but not yet started. For instance, when you create a new instance of a thread class using
MyThread t = new MyThread()
, the thread is in the “New” or “Born” state. - Ready/Runnable: After invoking the
start()
method on the thread object (t.start()
), the thread becomes eligible to run, but it’s up to the thread scheduler to allocate processor time. The thread is considered “Ready” or “Runnable” at this stage. - Running: When the thread scheduler allocates processor time to the thread, it enters the “Running” state, and its
run()
method starts executing. The thread remains in this state until itsrun()
method completes or is paused by the scheduler to allow other threads to run. - Dead: Once the
run()
method completes execution or the thread is explicitly terminated, the thread enters the “Dead” state. A thread is considered “Dead” when its execution is finished, and it cannot be started again.
What happens if we try to restart an already started thread?
After starting a thread, if we try to restart the same thread once again, then we will get IllegalThreadStateException
.
Thread t = new Thread();
t.start();
// Some other code...
t.start(); // This will throw IllegalThreadStateException
- You create a new thread object
t
. - You start the thread using
t.start()
, which transitions the thread from the new state to the runnable state and invokes itsrun()
method. - If you attempt to start the same thread again using
t.start()
, you’ll encounter theIllegalThreadStateException
because the thread is already in the runnable or running state and cannot be restarted.
Note: Attempting to restart a thread that has already been started violates the threading rules in Java, hence the exception. This exception occurs because a thread cannot be restarted once it has already started running or has been terminated.
Defining a Thread by Implementing the Runnable Interface (I)
When defining a thread by implementing the Runnable
interface, you create a class that implements the run()
method defined in the Runnable
interface. The Runnable
interface is present in the java.lang
package and it contains only one method: public void run()
. Then, you pass an instance of this class to the Thread
constructor, and invoke the start()
method on the Thread
object. This approach allows for better flexibility in Java’s multithreading model.
class MyRunnable implements Runnable { // defining a thread using the second approach, which is implementing the Runnable interface
public void run() {
for(int i=0; i<10; i++) { // whatever code inside the run method is the job of the thread and it is executed by the child thread
System.out.println("child thread");
}
}
}
class ThreadDemo {
public static void main(String[] args) {
MyRunnable r = new MyRunnable();
Thread t = new Thread(r); // r is the target runnable
t.start();
// At this point, two threads started executing simultaneously: the child and main threads
for(int i=0; i<10; i++) { // executed by the main thread
System.out.println("main thread");
}
}
}
We will get mixed output, and we can’t determine the exact output.
Output
main thread
main thread
child thread
child thread
main thread
main thread
child thread
child thread
main thread
main thread
child thread
child thread
main thread
child thread
main thread
child thread
main thread
child thread
main thread
child thread
- Here, we defined a class
MyRunnable
that implements theRunnable
interface and overrides itsrun()
method to define the task of the thread. - You create an instance of
MyRunnable
. - You create a
Thread
objectt
and pass theMyRunnable
instance to its constructor. - You start the thread using
t.start()
, which initiates the execution of the thread’srun()
method concurrently with the main thread’s execution. - Both the main thread and the child thread execute simultaneously, leading to a mixed output.
Due to the concurrent execution of the main thread and the child thread, the output may vary, resulting in a mixed sequence of messages from both threads.
Case Study
Let’s dive into the different outcomes depending on the situation. consider following example:
MyRunnable r = new MyRunnable();
Thread t1 = new Thread();
Thread t2 = new Thread(r);
Case 1:
t1.start(); // A new thread will be created responsible for the Thread class's run method, which has empty implementation.
In this case , a new thread will be created, but since t1
is not associated with any Runnable
object, the new thread will execute the run()
method of the Thread
class, which has an empty implementation.
Case 2:
t1.run(); // No new thread will be created, and the Thread class's run method will be executed like a normal method call.
Here, No new thread will be created, and the run()
method of the Thread
class will be executed just like a normal method call.
Case 3:
t2.start(); // A new thread will be created responsible for the execution of the MyRunnable class's run method.
A new thread will be created, and it will be responsible for executing the run()
method of the MyRunnable
class, as t2
is associated with the MyRunnable
instance r
.
Case 4:
t2.run(); // No new thread will be created, and the MyRunnable class's run method will be executed like a normal method call.
No new thread will be created, and the run()
method of the MyRunnable
class will be executed just like a normal method call.
Case 5:
r.start(); // Compile-time error: MyRunnable class doesn't have start capability (CE: cannot find symbol | symbol: method start() | location: class MyRunnable)
This will result in a compile-time error because the MyRunnable
class does not have a start()
method. It’s the Thread
class that has the start()
method.
Case 6:
r.run(); // No new thread will be created, and the MyRunnable run method will be executed like a normal method call.
No new thread will be created, and the run()
method of the MyRunnable
class will be executed like a normal method call.
Which approach is best to define a thread?
Among the two ways of defining a thread, the “implements Runnable” approach is recommended.
In the first approach, our class always extends the Thread class, leaving no chance of extending any other class. Hence, we are missing inheritance benefits.
However, in the second approach, by implementing the Runnable interface, we can extend any other class. Therefore, we won’t miss any inheritance benefits.
Because of the above reasons, implementing the Runnable approach is recommended over extending the Thread class.
Another Way of Defining a Thread (Not Recommended)
Here’s another way of defining a thread in Java, valid but not recommended. Let’s see why.
class MyThread extends Thread {
public void run() {
System.out.println("Child Thread");
}
}
class ThreadDemo {
public static void main(String[] args) {
MyThread t = new MyThread();
Thread t1 = new Thread(t);
t1.start();
System.out.println("main thread");
}
}
Output (One of the possible outputs):
Child Thread
main Thread
Or
main Thread
Child Thread
Eample given above is valid but not recommended approach due to several reasons, including inflexibility and violation of good object-oriented design principles.
However, is it valid to pass a reference to another thread to the Thread
constructor like this (new Thread(t))?
+-------------------+
| java.lang |
| Object |
+---------+---------+
^
|
+-----------------+-----------------+
| |
+-----------+-----------+ +-----------+-----------+
| | | |
+--+-------------------+ | +---+-------------------+ |
| java.lang | | | java.lang | |
| Thread | | | Runnable | |
+-----------+-------+ | +-------------------+ |
^ | ^ |
| | | |
| | | |
+-----------+-----------+-----------+ | +---+-------------------+
| | | | |
| MyThread |<------+ | YourThread |
| | | |
+-----------------------------------+ +-----------------------+
Here,
java.lang.Thread
is shown as extendingjava.lang.Object
and implementing theRunnable
interface.java.lang.Runnable
is an interface implemented by theThread
class, represented by the dashed line between them.- Custom classes like
MyThread
can extendThread
and, therefore, indirectly implement theRunnable
interface.
So, passing either Runnable interface or implementing class reference is acceptable and completely valid in Java, but this hybrid approach is not recommended.
Part 2: Mastering Thread Constructors, Thread Priority, yield(), join(), and sleep() Methods for Concurrent Efficiency
Conclusion
In this comprehensive guide, we’ve covered various aspects of Java multithreading, from the basics of creating and synchronizing threads to advanced concepts like thread safety, concurrency issues, and performance tuning. By mastering multithreading in Java and following best practices, you can develop efficient, scalable, and responsive applications that leverage the full power of modern multicore processors. Keep experimenting, learning, and refining your multithreading skills to build robust and high-performance software.