🔽 Download the full document here: https://tinyurl.com/4n4c6tj6
When we run an application, off course, the code we have written will be executed on the Operating System (OS). Beneath the OS, we have our hardware components that perform the actual processing.
🏭 System Architecture
|-------------------|
| 🖥 Software |
|-------------------|
| Operating System |
|-------------------|
| | <-----> ⚡ CPU (Processes tasks)
| 🔧 Hardware |
| | <-----> 💾 RAM (Temporary memory for processing)
|-------------------|
🔄 How Execution Works
Let's say we perform a simple operation like adding 2 + 2
. This request will go to the OS, which will then forward it to the CPU for processing. The CPU executes the operation and returns the result.
For a simple task, this process is straightforward. But what happens when we have multiple software applications running at the same time? 🤔
⏳ Multitasking & Time-Sharing
Modern operating systems support multitasking. However, it's not that all applications execute simultaneously. Instead, OS's use a concept called time-sharing.
For example, imagine we have three software programs: A, B, and C. The OS will allocate small time slots to each program, like this:
✅ "Hey A, execute now!"
✅ "Now it's B's turn!"
✅ "Okay C, your turn!"
This switching happens so quickly that it feels like everything is running in parallel. Thanks to time-sharing, we can listen to music 🎵 while browsing the web 🌍 or playing a game 🎮 at the same time!
🔥 Why Do We Need Threads?
It's not just about running multiple applications at once. Even within a single software, we might need to execute multiple tasks simultaneously.
💡 Example: Writing in VS Code
Imagine you're using VS Code and typing "ABC". The moment you type:
✔ You see the text on the screen.
✔ The spell checker highlights errors.
✔ IntelliSense suggests auto-completions.
All these tasks happen at the same time within the same software. This isn't just multitasking—it's about breaking down a task into smaller independent units.
And that’s where Threads come in! 🧵✨
⚽ Real-Life Example: Football & Threads
Imagine a football match:
- Players are running at the same time 🏃♂️
- The ball is moving ⚽
- The audience is cheering 📣
Multiple things are happening simultaneously. This is just like multiple threads running together!
✅ Checking Threads in Task Manager
If you open Task Manager (Windows) ⚙ or Activity Monitor (Mac) 🍏, you’ll see multiple software applications running at the same time.
Each of these applications consumes multiple threads:
Java - 42 threads
VS Code - 29 threads
(These are just assumptions; actual numbers may vary.)
📝 Note: Having 42
or 29
threads doesn’t mean they consume 42 or 29 different resources. They share threads among processes.
⚒️ Do We Manually Create Threads?
Most of the time, we don’t create threads manually. Instead, we use frameworks that manage them for us.
For example, if we’re working on a large project, there’s a high chance that we won’t write threads ourselves.
However, it’s always good to understand the underlying concepts! 💡
🧵 Creating Threads in Java
Let's break it down step by step!
🔹 Single-Threaded Execution
class A {
public void show() {
for (int i = 0; i < 10; i++) {
System.out.println("Hi");
}
}
}
class B {
public void show() {
for (int i = 0; i < 10; i++) {
System.out.println("Hello");
}
}
}
class Demo {
public static void main(String[] args) {
A a = new A();
B b = new B();
a.show();
b.show();
}
}
✅ Output:
Hi
Hi
Hi
Hi
Hi
Hi
Hi
Hi
Hi
Hi
Hello
Hello
Hello
Hello
Hello
Hello
Hello
Hello
Hello
Hello
🧐 What’s Happening?
-
JVM starts execution from
main
and runs the code line by line. - When we call
a.show()
, execution waits until allHi
messages are printed. - Then
b.show()
runs, printing allHello
messages.
🚨 Problem: b.show()
waits until a.show()
finishes. But what if we want them to run in parallel?
🚀 Converting Classes into Threads
To achieve parallel execution, we can convert our normal classes into threads by extending Thread
.
🔹 Making Classes Threads
class A extends Thread {
public void show() {
for (int i = 0; i < 10; i++) {
System.out.println("Hi");
}
}
}
class B extends Thread {
public void show() {
for (int i = 0; i < 10; i++) {
System.out.println("Hello");
}
}
}
class Demo {
public static void main(String[] args) {
A a = new A();
B b = new B();
a.show();
b.show();
}
}
🧐 Is this enough?
No! ❌
We need to call start()
instead of show()
because:
- Even though
A
andB
are now threads, they are still acting like normal objects. - The
start()
method actually starts a new thread in Java.
✅ Correct Way: Using start()
and run()
class A extends Thread {
public void run() { // Use run() instead of show()
for (int i = 0; i < 10; i++) {
System.out.println("Hi");
}
}
}
class B extends Thread {
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("Hello");
}
}
}
class Demo {
public static void main(String[] args) {
A a = new A();
B b = new B();
a.start(); // Start thread A
b.start(); // Start thread B
}
}
🔍 Behind the Scenes: start()
and run()
- The
start()
method belongs to theThread
class. - It calls the
run()
method, which contains our logic. - The JVM schedules threads to run independently.
🔹 If we call run()
directly instead of start()
, the threads will not execute in parallel!
Each execution might produce different results.
✅ Output:
Hi
Hi
Hi
Hello
Hi
Hello
Hello
Hi
Hello
Hi
Hello
Hi
Hello
Hi
Hi
Hello
Hi
Hello
Hello
🔹 Why?
- The CPU and OS dynamically schedule threads.
- Threads do not execute in a fixed order.
- Execution depends on system speed, CPU load, and the OS Scheduler’s decision.
⚡ What Happens on a Faster System?
If the system is fast, we might not get the above output. Instead, we might see:
✅ Output:
Hi
Hi
Hi
Hi
Hi
Hi
Hi
Hi
Hi
Hi
Hello
Hello
Hello
Hello
Hello
Hello
Hello
Hello
Hello
Hello
💡 Why?
- On modern systems, CPUs are so fast that when a thread gets even a fraction of a second, it might complete its entire loop before switching to the next thread.
- This makes it seem like
Hi
prints completely beforeHello
starts, even though the threads are running in parallel.
🕵 Behind the Scenes: The Scheduler
In an Operating System (OS), there’s a Scheduler that controls when and how threads execute.
- Even if we have multiple threads, they must go through the Scheduler to execute.
- The Scheduler decides which thread gets CPU time and for how long.
💡 What About Multi-Core CPUs?
Modern CPUs have multiple cores:
- 4-core CPU → Can run 4 threads simultaneously.
- 8-core CPU → Can run 8 threads at a time.
🧐 But what if we have 2300+ threads?
- The system cannot execute them all at once.
- Instead, it uses time-sharing to schedule the threads efficiently.
- The Scheduler assigns a small time slot to each thread, allowing them to execute in a controlled manner.
🥇 Thread Priority
Can i get the below output ? 🤔 without different
or randomized
results, just like the below one
✅ Output:
Hi
Hello
Hi
Hello
Hi
Hello
Hi
Hello
Hi
Hello
We can check the priority of our object a
using the getPriority()
method.
System.out.println(a.getPriority());
Now the above code might return 4 or 5
any number between 1-10
. The range of the priority is 1-10
.
By default the threads have a normal priority betwwen 4-5
.
🫴🏻 How to change the priority?
Each scheduler has its own algorithm and logic. Yes we can change the priority, but behind the scence we cannot take full control over the schedule we can only suggest it to change the priority of a particular task.
-
In the parenthesis,
we can set a value between (1-10)
.a.setPriority(10);
-
We can also use a constant in it,
MAX_PRIORITY
,MIN_PRIORITY
,NORMAL_PRIORITY
. Like the below one.b.setPriority(Thread.MAX_PRIORITY);
🤔 Can't i tell my system to wait after one thread is executed and then execute the other thread?
Yes, We can tell the thread to wait (sleep) for sometime.
Using Thread.sleep()
method, However when we use it we get an Interrupted Exception
, so to handle it we have to use try and catch or we can use throws.
class A extends Thread {
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("Hi");
// ⏱️ Adding a sleep (wait) in milis
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println(e);
}
}
}
}
class B extends Thread {
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("Hello");
// ⏱️ Adding a sleep (wait) in milis
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println(e);
}
}
}
}
class Demo {
public static void main(String[] args) {
A a = new A();
B b = new B();
a.start();
b.start();
}
}
✅ Output:
Hi
Hello
Hello
Hi
Hello
Hi
Hi
Hello
Hello
Hi
Hello
Hi
Hi
Hello
Hello
Hi
Hello
Hi
Hi
Hello
Notice we have Hi Hi
or Hello Hello
, together. This doesn't mean that out code is wrong, its just that the scheduler is sending the 2nd thread twice. We cannot control it. However, if we still want to acheive Hi Hello
sequence, we can add a gap.
a.start();
// ⏱️ Adding a sleep (wait) in milis
try {
Thread.sleep(2);
} catch (InterruptedException e) {
System.out.println(e);
}
b.start();
🛣️ Thread creation ways
There are mainly two
ways to create thread
- extending a class to Thread.
- using Runnable class.
But why we need more options ? 🤔
Imagine we have a class A (Super / Parent Class) and we have another class B which we want to make (Sub/ Child Class) of A as well was make that class B a thread.
But this will not work as multi level inheritance is not supported by java.
1️⃣ Problem: Why Can't We Extend Thread?
class A {
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("Hi");
}
}
}
// ❌ This is invalid because Java does NOT allow multiple inheritance!
class B extends Thread, A {
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("Hello");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println(e);
}
}
}
}
🚨 Error: Java does not support multiple inheritance, so we cannot extend both Thread and A.
2️⃣ Solution: Use Runnable Instead of Extending Thread
If we observer Thread
is a class which implements Runnable
and if go to Runnable
and it has a method called run()
. That means if we use Runnable instead of extending Thread, we can implement the Runnable interface. This allows B to extend A while still being a thread.
✅ Corrected Code Using Runnable
class A {
public void show() { // Some method in A
System.out.println("Inside A");
}
}
// ✅ B extends A and implements Runnable (instead of extending Thread)
class B extends A implements Runnable {
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("Hello");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println(e);
}
}
}
}
class Demo {
public static void main(String[] args) {
Runnable obj = new B();
// ✅ Pass the object to a Thread
Thread t1 = new Thread(obj);
t1.start();
obj.show(); // We can also use methods from A
}
}
3️⃣ Benefits of Using Runnable
Feature | Extending Thread
|
Implementing Runnable
|
---|---|---|
Can extend another class? | ❌ No (because Java does not support multiple inheritance) | ✅ Yes (since it only implements an interface) |
Reusability | 🚫 Less (Tightly coupled) | ✅ More (Flexible, loosely coupled) |
Separation of logic | 🚫 Business logic is tied to Thread class | ✅ Business logic is separate from threading logic |
Recommended? | ❌ Not preferred | ✅ Preferred for better design |
4️⃣ When to Use Which Approach?
Situation | Recommended Approach |
---|---|
If the class does not need to extend any other class | ✅ Extend Thread
|
If the class already extends another class | ✅ Implement Runnable
|
If we want to separate threading logic from business logic | ✅ Implement Runnable
|
5️⃣ Another Example: Multiple Threads Using Runnable
If we need multiple threads, Runnable
is the best approach:
class MyTask implements Runnable {
private String message;
MyTask(String msg) {
this.message = msg;
}
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(message);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
System.out.println(e);
}
}
}
}
class Demo {
public static void main(String[] args) {
Thread t1 = new Thread(new MyTask("Task 1"));
Thread t2 = new Thread(new MyTask("Task 2"));
t1.start();
t2.start();
}
}
✅ Output:
Task 1
Task 2
Task 1
Task 2
...
✨ Final Takeaway
-
Extending
Thread
is simple but not flexible because it prevents extending other classes. -
Implementing
Runnable
allows a class to be a thread while still extending another class, making it more reusable and flexible.
That's why Java provides multiple ways to create threads! 🚀
😎 Lambda Expression?
Lambda expressions introduce functional programming in Java. They allow us to write concise, readable, and more flexible code.
📝 Syntax:
(parameter) -> { body }
⚡ Key Points:
✔ Removes boilerplate code for simple functions.
✔ Used mainly in functional interfaces (interfaces with a single abstract method).
✔ Introduced in Java 8 to enable functional programming.
🔥 Example 1: Without Lambda (Traditional Approach)
Let's create a simple interface with a single method.
interface Greeting {
void sayHello();
}
class HelloWorld implements Greeting {
public void sayHello() {
System.out.println("Hello, World!");
}
}
public class Main {
public static void main(String[] args) {
Greeting greeting = new HelloWorld();
greeting.sayHello();
}
}
✅ Output:
Hello, World!
⚡ Example 2: Using Lambda Expression
Instead of creating a separate class, we can use a Lambda expression:
public class Main {
public static void main(String[] args) {
Greeting greeting = () -> System.out.println("Hello, World!");
greeting.sayHello();
}
}
✅ Output (Same as Before):
Hello, World!
💡 Notice how much shorter and cleaner the Lambda version is!
🎯 Lambda Expressions with Parameters
💡 Example: Adding Two Numbers
interface MathOperation {
int add(int a, int b);
}
public class Main {
public static void main(String[] args) {
MathOperation addition = (a, b) -> a + b;
System.out.println("Sum: " + addition.add(5, 10));
}
}
✅ Output:
Sum: 15
✨ Lamda Expression for Threads
class Demo {
public static void main(String[] args) {
Runnable obj1 = () -> {
for (int i = 0; i < 10; i++) {
System.out.println("Hi");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println(e);
}
}
};
Runnable obj2 = () -> {
for (int i = 0; i < 10; i++) {
System.out.println("Hello");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println(e);
}
}
};
Thread t1 = new Thread(obj1);
Thread t2 = new Thread(obj2);
t1.start();
t2.start();
}
}
🏃🏻♂️ Race Conditions
By now you might have understood, what are Threads
and how they can help us. But do you know about Mutation
.
Mutable
means which can be changed or modified.
🤔 What if perform mutation with threads ?
Example 1:
Suppose we have 2 threads t1 and t2 and both are working with a integer value i. t1 and t2 both changes the value of i at the same timeExample 2:
Suppose you have just one bank account and 2 cards of that same bank account. You gave one card to your friend and kept the other one with yourself. Now, Both of you wen to a atm machine and tried to withdraw a sum of 7000 Rs at the same time and the bank has only 10000 Rs.
Off course, It will create a instability in the system.
Hence, threads and mutations together is not a good idea and that's why when working with thread
Note:
- Always try to work with data which are immutable.
- Always make them thread safe.
🛡️ Thread Safe
It states that only one thread can work with the mutated thing (method or variable etc) at a time.
But 🤔 how ? Let's see
class Counter {
int count;
public void increment() {
count++;
}
}
class Demo {
public static void main(String[] args) throws InterruptedException {
Counter c = new Counter();
Runnable obj1 = () -> {
for (int i = 1; i <= 1000; i++) {
c.increment();
}
};
Runnable obj2 = () -> {
for (int i = 1; i <= 1000; i++) {
c.increment();
}
};
Thread t1 = new Thread(obj1);
Thread t2 = new Thread(obj2);
t1.start();
t2.start();
System.out.println(c.count);
}
}
In the above code we have two threads t1 and t2 both of them should increment the count by 1000 times each so total 2000 (expected count value)
Why we didn't get 2000 🤔?
main()
t1 -------| | |------- t2
1000 | | | 1000
c.count
Look at the above diagram, Here's what happens behind the scene, once the JVM executes main, main starts t1 and t2 threads and t1 and t2 is running meanwhile main thinks his job is done and so he executes c.count.
Meanwhile, If t1 or t2 might have mutated the count value thus we see the unexpected output.
What should we do 🤔 ?
Easy, we can tell main to wait for the t1 and t2 threads to complete their execution and join them with main, then main should execute c.count.
💡 Rough Idea
main()
t1 -------| | |------- t2
1000 | | | 1000
|-------->|<--------|
|
c.count
How to join both the threads in main thread 🤔 ?
Using join()
we tell the main thread to wait for the other two threads to comeback and join. Join may throw an InterruptedException so we can handle it use throws or try-catch.
class Counter {
int count;
public void increment() {
count++;
}
}
class Demo {
public static void main(String[] args) throws InterruptedException {
Counter c = new Counter();
Runnable obj1 = () -> {
for (int i = 1; i <= 1000; i++) {
c.increment();
}
};
Runnable obj2 = () -> {
for (int i = 1; i <= 1000; i++) {
c.increment();
}
};
Thread t1 = new Thread(obj1);
Thread t2 = new Thread(obj2);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(c.count);
}
}
But still the answer is not 2000, 🤔 why ?
t1 -------| |------- t2
|--> increment()<--|
|
|--> count++ (count = count+1)
It is possible that when t1 and t2 reaches to the count at the same time ?
🤔Confused?
Suppose t1 goes to increment() it see there is written count++ meaning count= count+1. That means it will first get what's the current value of count.
May be front start it will be 0.
- 1 - count = 0+1
- 2 - count = 1+1
- 3 - count = 2+1
- 4 - count = 3+1
- soon...
what if both t1
and t2
get the current value which is 3
, they both said 3 + 1 = 4
. We got two iteration
, that means we called increment() twice
but count value increased by just one
.
That's what happens when two threads works with a mutable variable or object.
🤔 How to fix it?
If t1
is working with count
then t2
must wait for t1
to finishing updating it. Similiarly t2
should also follow.
In that case we can use just a very simple keyword
synchronized
class Counter {
int count;
// 🔁 synchronized, makes t2 to wait for t1 to finish and similiarly t1 waits for t2 to finish mutating count
public synchronized void increment() {
count++;
}
}
class Demo {
public static void main(String[] args) throws InterruptedException {
Counter c = new Counter();
Runnable obj1 = () -> {
for (int i = 1; i <= 1000; i++) {
c.increment();
}
};
Runnable obj2 = () -> {
for (int i = 1; i <= 1000; i++) {
c.increment();
}
};
Thread t1 = new Thread(obj1);
Thread t2 = new Thread(obj2);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(c.count);
}
}
😲 Thread States (Life cycle of thread)
The diagram illustrates the different states a thread can go through in its lifecycle:
- New (Created)
- This is the initial state when a thread is created but hasn’t started yet.
-
Transition: When
start()
is called, the thread moves to the Runnable state.
- Runnable
- The thread is ready to run but waiting for the CPU to schedule it.
- Transition: When the CPU assigns resources, the thread moves to Running.
-
Transition Back: If the thread is notified (
notify()
), it can return to Runnable from Waiting.
- Running
- The thread is actively executing its task.
-
Transition to Waiting: If
sleep()
orwait()
is called, the thread moves to Waiting. -
Transition to Dead: If
stop()
is called or the thread finishes execution, it moves to Dead.
- Waiting
- The thread is temporarily inactive, waiting for a condition or time to resume.
-
Transition: If
notify()
is called, the thread returns to Runnable.
-
Dead (Terminated)
- The thread has completed execution or has been stopped.
- No further transitions from this state.
Transitions Between States
-
start()
: Moves a thread from New → Runnable. -
run()
: Moves a thread from Runnable → Running. -
sleep()/wait()
: Moves a thread from Running → Waiting. -
notify()
: Moves a thread from Waiting → Runnable. -
stop()
: Moves a thread from Running → Dead.
🥳 Well done 🍻, reaching the end. You have understood multi-threading in java.