Multithreading in Java: A Practical Introduction

In the world of programming, especially when dealing with complex and resource - intensive tasks, multithreading plays a crucial role. Multithreading allows a program to perform multiple tasks concurrently, which can significantly improve the performance and responsiveness of an application. Java, being a widely used and versatile programming language, provides robust support for multithreading. This blog will offer a practical introduction to multithreading in Java, covering fundamental concepts, usage methods, common practices, and best practices.

Table of Contents

  1. Fundamental Concepts of Multithreading in Java
    • What is a Thread?
    • Difference between Process and Thread
    • Thread States in Java
  2. Usage Methods of Multithreading in Java
    • Extending the Thread Class
    • Implementing the Runnable Interface
    • Using the ExecutorService
  3. Common Practices in Multithreading
    • Synchronization
    • Thread Safety
    • Inter - Thread Communication
  4. Best Practices in Multithreading
    • Avoiding Deadlocks
    • Proper Resource Management
    • Using High - Level Concurrency Utilities
  5. Conclusion
  6. References

Fundamental Concepts of Multithreading in Java

What is a Thread?

A thread is the smallest unit of execution within a process. In Java, a thread is an instance of the java.lang.Thread class. Each thread has its own call stack, program counter, and local variables. Multiple threads can run within a single Java program, allowing for concurrent execution of different parts of the code.

Difference between Process and Thread

  • Process: A process is an independent program that has its own memory space, system resources, and is managed by the operating system. Different processes do not share memory directly.
  • Thread: A thread is a lightweight subprocess that exists within a process. Multiple threads within a process share the same memory space, which means they can access and modify the same variables.

Thread States in Java

Java threads can be in one of the following states:

  • New: When a new Thread object is created but the start() method has not been called.
  • Runnable: After the start() method is called, the thread is in the runnable state. It is waiting to be scheduled by the thread scheduler to run on the CPU.
  • Running: When the thread scheduler selects a runnable thread to execute on the CPU, it enters the running state.
  • Blocked: A thread can enter the blocked state when it is waiting for a monitor lock to enter a synchronized block or method.
  • Waiting: A thread can be in the waiting state when it calls methods like wait(), join(), or park(). It will remain in this state until another thread wakes it up.
  • Timed Waiting: Similar to the waiting state, but the thread will wake up after a specified amount of time. For example, when calling sleep(long millis) or wait(long timeout).
  • Terminated: A thread enters the terminated state when its run() method has completed execution or an unhandled exception has occurred.

Usage Methods of Multithreading in Java

Extending the Thread Class

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("Thread is running: " + Thread.currentThread().getName());
    }
}

public class ThreadClassExample {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start();
    }
}

In this example, we create a new class MyThread that extends the Thread class. We override the run() method, which contains the code that the thread will execute. Then we create an instance of MyThread and call the start() method to start the thread.

Implementing the Runnable Interface

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Thread is running: " + Thread.currentThread().getName());
    }
}

public class RunnableInterfaceExample {
    public static void main(String[] args) {
        MyRunnable runnable = new MyRunnable();
        Thread thread = new Thread(runnable);
        thread.start();
    }
}

Here, we create a class MyRunnable that implements the Runnable interface. We implement the run() method. Then we create an instance of MyRunnable and pass it to the Thread constructor. Finally, we call the start() method on the Thread object.

Using the ExecutorService

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

class MyTask implements Runnable {
    @Override
    public void run() {
        System.out.println("Task is running: " + Thread.currentThread().getName());
    }
}

public class ExecutorServiceExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(2);
        for (int i = 0; i < 5; i++) {
            executor.submit(new MyTask());
        }
        executor.shutdown();
    }
}

The ExecutorService is a high - level concurrency utility in Java. In this example, we create a fixed - size thread pool with two threads using Executors.newFixedThreadPool(2). We submit five tasks to the executor service, and it will manage the execution of these tasks using the available threads in the pool. Finally, we call the shutdown() method to gracefully shut down the executor service.

Common Practices in Multithreading

Synchronization

Synchronization is used to ensure that only one thread can access a shared resource at a time. In Java, we can use the synchronized keyword.

class Counter {
    private int count = 0;

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

    public int getCount() {
        return count;
    }
}

public class SynchronizationExample {
    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("Count: " + counter.getCount());
    }
}

In this example, the increment() method is declared as synchronized. This ensures that only one thread can execute this method at a time, preventing race conditions.

Thread Safety

Thread - safe classes are classes that can be safely used by multiple threads without any additional synchronization. Immutable classes, for example, are inherently thread - safe because their state cannot be changed after creation.

final class ImmutableClass {
    private final int value;

    public ImmutableClass(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }
}

The ImmutableClass is thread - safe because its state is immutable. Multiple threads can safely access the getValue() method without any synchronization issues.

Inter - Thread Communication

Java provides methods like wait(), notify(), and notifyAll() for inter - thread communication.

class Message {
    private String msg;
    private boolean empty = true;

    public synchronized String read() {
        while (empty) {
            try {
                wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        empty = true;
        notifyAll();
        return msg;
    }

    public synchronized void write(String msg) {
        while (!empty) {
            try {
                wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        empty = false;
        this.msg = msg;
        notifyAll();
    }
}

public class InterThreadCommunicationExample {
    public static void main(String[] args) {
        Message msg = new Message();
        Thread writer = new Thread(() -> {
            msg.write("Hello, World!");
        });
        Thread reader = new Thread(() -> {
            System.out.println(msg.read());
        });

        writer.start();
        reader.start();
    }
}

In this example, the Message class uses wait() and notifyAll() methods to coordinate the reading and writing operations between two threads.

Best Practices in Multithreading

Avoiding Deadlocks

Deadlocks occur when two or more threads are blocked forever, each waiting for the other to release a resource. To avoid deadlocks, we can follow these rules:

  • Lock Ordering: Always acquire locks in the same order in all threads.
  • Use Timeouts: When acquiring locks, use methods that support timeouts. If a lock cannot be acquired within a specified time, the thread can release the locks it already holds and try again later.

Proper Resource Management

Threads should properly manage the resources they use. For example, if a thread opens a file or a network connection, it should close them properly when they are no longer needed. We can use try - with - resources statements in Java to ensure proper resource management.

Using High - Level Concurrency Utilities

Java provides many high - level concurrency utilities like ExecutorService, Semaphore, CountDownLatch, etc. These utilities are designed to simplify multithreading programming and reduce the chances of errors. For example, using ExecutorService to manage thread pools can avoid creating too many threads, which can lead to performance degradation.

Conclusion

Multithreading in Java is a powerful feature that can significantly improve the performance and responsiveness of applications. By understanding the fundamental concepts, usage methods, common practices, and best practices, developers can write efficient and reliable multithreaded programs. However, multithreading also introduces challenges such as race conditions, deadlocks, and resource management issues. Therefore, it is important to follow best practices and use high - level concurrency utilities provided by Java.

References