Mastering Multi-Threading in Java: Unlocking True Parallelism! ⚡

CoffeeDev - Feb 14 - - Dev Community

🔽 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)
|-------------------|
Enter fullscreen mode Exit fullscreen mode

🔄 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
Enter fullscreen mode Exit fullscreen mode

(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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Hi
Hi
Hi
Hi
Hi
Hi
Hi
Hi
Hi
Hi
Hello
Hello
Hello
Hello
Hello
Hello
Hello
Hello
Hello
Hello
Enter fullscreen mode Exit fullscreen mode

🧐 What’s Happening?

  • JVM starts execution from main and runs the code line by line.
  • When we call a.show(), execution waits until all Hi messages are printed.
  • Then b.show() runs, printing all Hello 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();
    }
}
Enter fullscreen mode Exit fullscreen mode

🧐 Is this enough?

No! ❌

We need to call start() instead of show() because:

  • Even though A and B 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
    }
}
Enter fullscreen mode Exit fullscreen mode

🔍 Behind the Scenes: start() and run()

  • The start() method belongs to the Thread 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
Enter fullscreen mode Exit fullscreen mode

🔹 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
Enter fullscreen mode Exit fullscreen mode

💡 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 before Hello 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
Enter fullscreen mode Exit fullscreen mode

We can check the priority of our object a using the getPriority() method.

System.out.println(a.getPriority());
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Hi
Hello
Hello
Hi
Hello
Hi
Hi
Hello
Hello
Hi
Hello
Hi
Hi
Hello
Hello
Hi
Hello
Hi
Hi
Hello
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

🛣️ 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);
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

🚨 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
    }
}

Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Task 1
Task 2
Task 1
Task 2
...
Enter fullscreen mode Exit fullscreen mode

✨ 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 }
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Hello, World!
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Output (Same as Before):

Hello, World!
Enter fullscreen mode Exit fullscreen mode

💡 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));
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Sum: 15
Enter fullscreen mode Exit fullscreen mode

✨ 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();
    }
}
Enter fullscreen mode Exit fullscreen mode

🏃🏻‍♂️ 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 time

  • Example 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

But still the answer is not 2000, 🤔 why ?

t1 -------|                  |------- t2
          |--> increment()<--|
                    |
                    |--> count++ (count = count+1)
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

😲 Thread States (Life cycle of thread)

Image showing different thread states in java

The diagram illustrates the different states a thread can go through in its lifecycle:

  1. 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.
  1. 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.
  1. Running
  • The thread is actively executing its task.
  • Transition to Waiting: If sleep() or wait() is called, the thread moves to Waiting.
  • Transition to Dead: If stop() is called or the thread finishes execution, it moves to Dead.
  1. Waiting
  • The thread is temporarily inactive, waiting for a condition or time to resume.
  • Transition: If notify() is called, the thread returns to Runnable.
  1. 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.

.