Multithreading in Java - Complete Tutorial

Introduction

Multithreading is a Java feature that allows concurrent execution of two or more parts of a program to maximize the utilization of CPU. Each part of such a program is called a thread. Threads can be thought of as lightweight processes that enable a program to perform multiple tasks simultaneously.

Table of Contents

  1. Overview of Multithreading
  2. Creating Threads
    • Extending Thread Class
    • Implementing Runnable Interface
    • Using Callable and Future
  3. Thread Lifecycle
  4. Thread Priority
  5. Synchronization
    • Synchronized Methods
    • Synchronized Blocks
    • Static Synchronization
  6. Inter-thread Communication
    • wait(), notify(), and notifyAll()
  7. Thread Pooling
    • Using ExecutorService
    • Using Executors Factory Methods
  8. Advanced Concepts
    • Reentrant Locks
    • Semaphores
    • Barriers
  9. Best Practices
  10. Conclusion

1. Overview of Multithreading

Multithreading in Java enables the concurrent execution of two or more threads. By default, every Java program has at least one thread, the main thread, which is created by the Java Virtual Machine (JVM). Multithreading is primarily used for performing time-consuming operations in the background while keeping the application responsive.

2. Creating Threads

Extending Thread Class

The simplest way to create a thread is by extending the Thread class and overriding its run() method.

Example:

class MyThread extends Thread {
    public void run() {
        System.out.println("Thread is running.");
    }

    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start();  // Start the thread
    }
}

Implementing Runnable Interface

Another way to create a thread is by implementing the Runnable interface. This is generally preferred because it allows the class to extend another class.

Example:

class MyRunnable implements Runnable {
    public void run() {
        System.out.println("Thread is running.");
    }

    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start();  // Start the thread
    }
}

Using Callable and Future

The Callable interface is similar to Runnable, but it can return a result and throw a checked exception. The result of the Callable task can be obtained via a Future object.

Example:

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

class MyCallable implements Callable<String> {
    public String call() throws Exception {
        return "Thread is running.";
    }

    public static void main(String[] args) {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        Future<String> future = executor.submit(new MyCallable());
        try {
            String result = future.get();
            System.out.println(result);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        } finally {
            executor.shutdown();
        }
    }
}

3. Thread Lifecycle

A thread goes through various states in its lifecycle:

  1. New: A thread is created but not yet started.
  2. Runnable: A thread is ready to run and waiting for CPU time.
  3. Running: A thread is executing.
  4. Blocked: A thread is blocked waiting for a monitor lock.
  5. Waiting: A thread is waiting indefinitely for another thread to perform a particular action.
  6. Timed Waiting: A thread is waiting for another thread to perform a particular action within a stipulated amount of time.
  7. Terminated: A thread has completed its execution.

4. Thread Priority

Java threads have priority levels that determine the order in which threads are scheduled for execution. Thread priority is an integer ranging from 1 (MIN_PRIORITY) to 10 (MAX_PRIORITY). By default, each thread is given priority 5 (NORM_PRIORITY).

Example:

class MyThread extends Thread {
    public void run() {
        System.out.println("Thread is running with priority " + Thread.currentThread().getPriority());
    }

    public static void main(String[] args) {
        MyThread thread1 = new MyThread();
        MyThread thread2 = new MyThread();

        thread1.setPriority(Thread.MIN_PRIORITY); // Set priority to 1
        thread2.setPriority(Thread.MAX_PRIORITY); // Set priority to 10

        thread1.start();
        thread2.start();
    }
}

5. Synchronization

Synchronization is used to control the access of multiple threads to shared resources. It prevents data inconsistency and race conditions.

Synchronized Methods

A synchronized method ensures that only one thread can execute it at a time for a particular object.

Example:

class Counter {
    private int count = 0;

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

    public int getCount() {
        return count;
    }
}

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

        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

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

Synchronized Blocks

A synchronized block is a block of code within a method that is synchronized on a specified object, providing more granular control over synchronization.

Example:

class Counter {
    private int count = 0;

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

    public int getCount() {
        return count;
    }
}

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

        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

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

Static Synchronization

Static synchronization ensures that a class-level lock is acquired, so all instances of the class share the same lock.

Example:

class Counter {
    private static int count = 0;

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

    public static int getCount() {
        return count;
    }
}

public class StaticSynchronizationExample {
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                Counter.increment();
            }
        };

        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

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

6. Inter-thread Communication

Inter-thread communication in Java is achieved using wait(), notify(), and notifyAll() methods. These methods are used to synchronize the activities of multiple threads.

Example:

class SharedResource {
    private int data = 0;
    private boolean available = false;

    public synchronized void produce(int value) throws InterruptedException {
        while (available) {
            wait();
        }
        data = value;
        available = true;
        notify();
    }

    public synchronized int consume() throws InterruptedException {
        while (!available) {
            wait();
        }
        available = false;
        notify();
        return data;
    }
}

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

        Thread producer = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                try {
                    sharedResource.produce(i);
                    System.out.println("Produced: " + i);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread consumer = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                try {
                    int value = sharedResource.consume();
                    System.out.println("Consumed: " + value);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

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

7. Thread Pooling

Thread pooling is a technique to reuse a fixed number of threads to execute tasks, which improves the performance of a multithreaded application.

Using ExecutorService

Example:

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

public class ThreadPoolingExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(3);

        for (int i = 1; i <= 5; i++) {


            final int taskId = i;
            executorService.submit(() -> {
                System.out.println("Task " + taskId + " executed by " + Thread.currentThread().getName());
                try {
                    Thread.sleep(2000); // Simulate a long-running task
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }

        executorService.shutdown();
    }
}

Using Executors Factory Methods

  • newFixedThreadPool(int nThreads): Creates a thread pool with a fixed number of threads.
  • newCachedThreadPool(): Creates a thread pool that creates new threads as needed but will reuse previously constructed threads when they are available.
  • newSingleThreadExecutor(): Creates an executor that uses a single worker thread.
  • newScheduledThreadPool(int corePoolSize): Creates a thread pool that can schedule commands to run after a given delay or to execute periodically.

8. Advanced Concepts

Reentrant Locks

Reentrant locks are a more flexible way to control synchronization. The ReentrantLock class provides all the capabilities of synchronized blocks with additional features.

Example:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class SharedResource {
    private int count = 0;
    private final Lock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public int getCount() {
        return count;
    }
}

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

        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                resource.increment();
            }
        };

        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

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

Semaphores

Semaphores are used to control access to a shared resource through the use of counters.

Example:

import java.util.concurrent.Semaphore;

class SharedResource {
    private final Semaphore semaphore = new Semaphore(1);

    public void accessResource() {
        try {
            semaphore.acquire();
            System.out.println(Thread.currentThread().getName() + " accessed the resource");
            Thread.sleep(2000); // Simulate resource access
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            semaphore.release();
        }
    }
}

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

        Runnable task = resource::accessResource;

        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        thread1.start();
        thread2.start();
    }
}

Barriers

Barriers are synchronization aids that allow a set of threads to all wait for each other to reach a common barrier point.

Example:

import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

public class BarrierExample {
    private static final int PARTIES = 3;
    private static final CyclicBarrier barrier = new CyclicBarrier(PARTIES, () -> {
        System.out.println("All parties have arrived at the barrier, let's proceed");
    });

    public static void main(String[] args) {
        for (int i = 0; i < PARTIES; i++) {
            new Thread(new Task()).start();
        }
    }

    static class Task implements Runnable {
        public void run() {
            System.out.println(Thread.currentThread().getName() + " is waiting at the barrier");
            try {
                barrier.await();
                System.out.println(Thread.currentThread().getName() + " has crossed the barrier");
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
        }
    }
}

9. Best Practices

  • Minimize the Scope of Synchronization: Only synchronize the critical section of the code to reduce contention.
  • Prefer Synchronized Blocks Over Methods: Provides more granular control over the synchronization.
  • Use Thread Pools: Use thread pools to manage a large number of short-lived tasks efficiently.
  • Handle InterruptedException Properly: Ensure that your code can gracefully handle interruptions.
  • Avoid Nested Locks: Nested locks can lead to deadlocks. Use a single lock or carefully design the lock order.

10. Conclusion

Multithreading in Java is a powerful feature that allows you to perform multiple tasks simultaneously. Understanding the concepts of thread creation, synchronization, inter-thread communication, and advanced synchronization aids is crucial for writing efficient and robust multithreaded applications. By following best practices and leveraging the Java concurrency utilities, you can manage concurrency effectively and build high-performance applications.

Happy coding!

Comments