Multi-threading in Java

Mohit  UpadhyayMohit Upadhyay
7 min read

On Day 19, the topic focuses on Multithreading in Java, one of the core features for enabling concurrent execution in programs. Java provides built-in support for multithreading, allowing multiple threads to run simultaneously, optimizing CPU usage and improving application performance, especially in complex programs like web servers or simulations.


1. What is Multithreading?

Multithreading refers to the ability to execute multiple parts of a program (threads) concurrently. In Java, a thread is a lightweight process that runs within a program (i.e., a single process). By running multiple threads simultaneously, Java applications can perform multiple tasks concurrently, such as handling user input, performing calculations, or accessing files.

Each thread has its own call stack but shares the same memory space (heap) with other threads within the same process.


2. Why Use Multithreading?

Java's multithreading capabilities help in:

  1. Efficient resource usage: CPU can switch between threads, reducing idle time.

  2. Improving performance: Multiple tasks can be performed at the same time (e.g., in web servers where multiple requests are handled concurrently).

  3. Enhanced responsiveness: In GUI applications, multithreading ensures the application remains responsive while performing long-running tasks in the background.

  4. Parallelism: Tasks can run in parallel, making better use of multi-core processors.


3. Life Cycle of a Thread

A thread in Java goes through various states during its life cycle:

  1. New: A thread is in this state when it is created using the Thread class but hasn’t started executing yet.

  2. Runnable: The thread is ready to run but may not be running (waiting for CPU).

  3. Running: The thread is executing its task.

  4. Blocked/Waiting: The thread is waiting for resources (e.g., I/O, locks).

  5. Terminated: The thread finishes its execution and dies.


4. Creating Threads in Java

There are two primary ways to create threads in Java:

A. Extending the Thread Class

You can create a thread by extending the Thread class and overriding the run() method. The run() method defines the code that will execute when the thread starts.

Example:
class MyThread extends Thread {
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + " is running");
        }
    }
}

public class Main {
    public static void main(String[] args) {
        MyThread thread1 = new MyThread();
        thread1.start();  // Starts the thread and calls the run() method
    }
}

B. Implementing the Runnable Interface

A better approach is to implement the Runnable interface, as it allows the class to extend other classes as well (since Java supports only single inheritance). In this case, you pass the Runnable object to a Thread object and then call start().

Example:
class MyRunnable implements Runnable {
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + " is running");
        }
    }
}

public class Main {
    public static void main(String[] args) {
        MyRunnable runnable = new MyRunnable();
        Thread thread = new Thread(runnable);
        thread.start();  // Starts the thread and calls the run() method
    }
}

5. Key Methods in Thread Class

Here are some important methods provided by the Thread class:

  • start(): Starts the execution of the thread. Internally calls the run() method.

  • run(): The entry point for the thread. You override this method to define the task the thread should execute.

  • sleep(long millis): Causes the currently executing thread to pause for a specified number of milliseconds.

  • join(): Waits for the thread to die. It ensures one thread waits until another thread finishes.

  • yield(): Pauses the current thread to allow other threads of equal priority to execute.

  • interrupt(): Interrupts the thread. If the thread is sleeping or waiting, it throws an InterruptedException.

  • isAlive(): Checks if a thread is still running.


6. Thread Synchronization

When multiple threads share resources (like variables or objects), there can be race conditions, leading to inconsistent results. To prevent such issues, Java provides synchronization mechanisms.

A. Synchronized Methods

You can declare a method as synchronized, meaning that only one thread can execute it at a time for a given object.

Example:
class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("Final count: " + counter.getCount());
    }
}

In this example, the increment() method is synchronized to ensure only one thread can access it at a time, avoiding race conditions.

B. Synchronized Blocks

Instead of synchronizing the whole method, you can synchronize a block of code.

Example:
class Counter {
    private int count = 0;

    public void increment() {
        synchronized (this) {
            count++;
        }
    }
}

Only the block of code inside the synchronized statement is protected, improving efficiency by allowing unsynchronized code to run concurrently.


7. Deadlock

Deadlock is a situation in multithreading where two or more threads are blocked forever, waiting for each other to release resources.

Example of Deadlock:
public class Deadlock {
    private final Object lock1 = new Object();
    private final Object lock2 = new Object();

    public void method1() {
        synchronized (lock1) {
            System.out.println("Thread 1: Holding lock 1...");
            synchronized (lock2) {
                System.out.println("Thread 1: Holding lock 2...");
            }
        }
    }

    public void method2() {
        synchronized (lock2) {
            System.out.println("Thread 2: Holding lock 2...");
            synchronized (lock1) {
                System.out.println("Thread 2: Holding lock 1...");
            }
        }
    }

    public static void main(String[] args) {
        Deadlock deadlock = new Deadlock();

        Thread t1 = new Thread(deadlock::method1);
        Thread t2 = new Thread(deadlock::method2);

        t1.start();
        t2.start();
    }
}

In the above example, Thread 1 holds lock 1 and waits for lock 2, while Thread 2 holds lock 2 and waits for lock 1. Neither thread can proceed, causing a deadlock.


8. Inter-Thread Communication

Java provides methods like wait(), notify(), and notifyAll() for thread communication. These methods are used when a thread needs to wait for some condition to be fulfilled by another thread.

  • wait(): Causes the current thread to wait until another thread invokes notify() or notifyAll().

  • notify(): Wakes up one waiting thread.

  • notifyAll(): Wakes up all waiting threads.

Example:
class SharedResource {
    private boolean flag = false;

    public synchronized void produce() throws InterruptedException {
        while (flag) {
            wait();
        }
        System.out.println("Produced");
        flag = true;
        notify();
    }

    public synchronized void consume() throws InterruptedException {
        while (!flag) {
            wait();
        }
        System.out.println("Consumed");
        flag = false;
        notify();
    }
}

public class Main {
    public static void main(String[] args) {
        SharedResource resource = new SharedResource();

        Thread producer = new Thread(() -> {
            try {
                resource.produce();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread consumer = new Thread(() -> {
            try {
                resource.consume();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        producer.start();
        consumer.start();
    }
}

In this example, the producer waits for the consumer to consume the resource before producing a new one.


9. Thread Pooling

Creating a new thread for every task can be expensive in terms of system resources. To solve this problem, Java provides the Executor Framework for managing a pool of threads.

The ExecutorService manages a pool of threads and allows reuse of threads.

Example:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(5);

        for (int i = 0; i < 10; i++) {
            executor.submit(() -> System.out.println(Thread.currentThread().getName() + " is executing"));
        }

        executor.shutdown();  // Shutdown the executor after tasks are completed
    }
}

Conclusion

Multithreading in Java is an essential feature for building concurrent applications. With proper usage of thread creation, synchronization, and thread pools, you can significantly improve the performance and responsiveness of your applications. However, care should be taken to avoid common pitfalls such as deadlock and race- conditions through proper synchronization techniques. Stay tuned! .

10
Subscribe to my newsletter

Read articles from Mohit Upadhyay directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mohit  Upadhyay
Mohit Upadhyay