Multithreading in Java: A Comprehensive Guide to Concurrency and Parallelism

What is Multithreading?

In modern computing, performance is not just about executing one task faster but about doing more simultaneously. Multithreading allows a program to execute multiple threads concurrently, effectively enabling multitasking within a single process.

A thread is the smallest unit of execution in a process. In Java, threads are part of the foundation of the language itself, providing a built-in mechanism to implement concurrent behavior. For example, consider a video streaming application: one thread decodes video frames, another processes audio, while yet another synchronizes both streams for smooth playback. This seamless experience is made possible by multithreading.

Java’s multithreading model leverages the capabilities of the underlying operating system while providing a developer-friendly abstraction through its java.lang.Thread class and java.util.concurrent package.

Why Use Multithreading in Java?

Multithreading in Java is not just an advanced feature; it is essential for developing responsive, efficient, and scalable applications. Consider a scenario where you are developing a web server. If each incoming request had to be processed sequentially, users would experience significant delays. Multithreading allows you to handle multiple requests simultaneously, reducing latency and enhancing the user experience.

Java’s strong thread support, combined with the JVM's robust management, makes it ideal for implementing multithreading. Features such as platform independence, automatic memory management, and built-in thread primitives ensure that developers can focus on business logic without worrying about low-level thread management.

Benefits of Multithreading

  1. Responsiveness: Applications like GUI-based systems can continue responding to user input while performing background tasks. For example, a file download manager can update progress in real time without freezing the user interface.

  2. Resource Sharing: Multithreading allows threads within the same process to share memory and resources efficiently. For instance, in a data analysis application, multiple threads can process different parts of a dataset concurrently.

  3. Parallelism: By dividing tasks across multiple threads, applications can utilize multi-core processors effectively. For example, a video editor can render different segments of a video in parallel, significantly reducing processing time.

Challenges of Multithreading

While the benefits are significant, multithreading introduces its own set of challenges:

  1. Concurrency Issues: When multiple threads access shared resources, inconsistencies may arise if proper synchronization is not enforced. For example, two threads incrementing the same counter can lead to race conditions, producing incorrect results.

  2. Deadlocks: Improper locking strategies can result in deadlocks, where two or more threads wait indefinitely for resources held by each other. This can bring an application to a standstill.

  3. Thread Management: Creating and managing threads has an overhead. Excessive thread creation can exhaust system resources, leading to reduced performance.

To overcome these challenges, Java provides a wide range of tools, including synchronization mechanisms, locks, and the java.util.concurrent package.

How the JVM Manages Threads and Thread Scheduling

The Java Virtual Machine (JVM) abstracts much of the complexity of thread management, allowing developers to work with high-level constructs. Here’s how the JVM manages threads:

  1. Thread Lifecycle: Threads in Java follow a lifecycle, moving through states such as NEW, RUNNABLE, BLOCKED, WAITING, and TERMINATED. The JVM ensures smooth transitions between these states based on the thread's behavior and resource availability.

  2. Thread Scheduling: The JVM relies on the underlying operating system for thread scheduling. Java threads are typically scheduled using preemptive multitasking, where the OS allocates CPU time to threads based on priority and fairness. However, thread priority in Java is advisory and may not always guarantee execution order.

  3. Garbage Collection: In multithreaded applications, the JVM’s garbage collector operates concurrently to reclaim unused memory. Modern JVMs use sophisticated algorithms, such as G1GC and ZGC, to minimize the impact of garbage collection on application performance.

  4. Thread Safety: The JVM ensures thread safety in critical areas, such as loading classes and initializing static variables, using intrinsic locks. Developers can leverage similar primitives for their own synchronization needs.

Java’s multithreading capabilities are built on decades of research and real-world experience, offering a balance of performance, usability, and safety. By mastering these concepts, you unlock the potential to create applications that are not only efficient but also capable of handling the demands of modern computing.

Basic Multithreading

Using Runnable (Without Lambda)

The Runnable interface provides a structured way to define the behavior of a thread while separating the task logic from the thread management. To use it, you implement the run() method, encapsulate your task logic there, and pass an instance of the Runnable implementation to a Thread object. This approach is particularly useful when your class needs to extend another class, as it avoids the restriction of single inheritance in Java.

Code Example: Printing Numbers in a Separate Thread
public class RunnableExample {
    public static void main(String[] args) {
        Runnable task = new Runnable() {
            @Override
            public void run() {
                for (int i = 1; i <= 5; i++) {
                    System.out.println("Thread: " + i);
                    try {
                        Thread.sleep(500); // Simulate work
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };

        Thread thread = new Thread(task);
        thread.start();

        for (int i = 1; i <= 5; i++) {
            System.out.println("Main: " + i);
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

This example highlights how tasks defined using Runnable are managed by a Thread instance. The Runnable approach is preferred in most cases due to its flexibility and clear separation of concerns.

Using Thread Directly

Extending the Thread class and overriding its run() method provides a direct way to create threads. This method, while straightforward for small tasks, tightly couples the task logic with the thread itself. For example, if you need to perform matrix multiplication in a separate thread, extending Thread can make the implementation self-contained and simple.

Matrix Multiplication in a Separate Thread
public class MatrixMultiplicationThread extends Thread {
    private int[][] matrixA;
    private int[][] matrixB;
    private int[][] result;

    public MatrixMultiplicationThread(int[][] matrixA, int[][] matrixB) {
        this.matrixA = matrixA;
        this.matrixB = matrixB;
        this.result = new int[matrixA.length][matrixB[0].length];
    }

    @Override
    public void run() {
        for (int i = 0; i < matrixA.length; i++) {
            for (int j = 0; j < matrixB[0].length; j++) {
                for (int k = 0; k < matrixB.length; k++) {
                    result[i][j] += matrixA[i][k] * matrixB[k][j];
                }
            }
        }
        System.out.println("Matrix multiplication completed.");
    }

    public static void main(String[] args) {
        int[][] matrixA = {{1, 2}, {3, 4}};
        int[][] matrixB = {{5, 6}, {7, 8}};

        MatrixMultiplicationThread thread = new MatrixMultiplicationThread(matrixA, matrixB);
        thread.start();

        System.out.println("Main thread is free to perform other tasks.");
    }
}

While this approach is simple for encapsulating thread-specific tasks, it comes with limitations. Because the Thread class is already being extended, your class cannot inherit from any other class. This restricts its reusability and flexibility. Furthermore, mixing the thread lifecycle management and task logic can make your design less modular, leading to challenges in maintaining and testing the code.

Using Lambda with Runnable

With Java 8, lambdas introduced a new way to implement functional interfaces like Runnable. This concise syntax reduces boilerplate and improves readability. Instead of defining an entire class or anonymous inner class, you can pass a lambda expression directly to a Thread constructor. This is particularly beneficial when the task logic is short and does not require a separate class.

Code Example: Matrix Multiplication Using Lambdas
public class LambdaMatrixMultiplication {
    public static void main(String[] args) {
        int[][] matrixA = {{1, 2}, {3, 4}};
        int[][] matrixB = {{5, 6}, {7, 8}};
        int[][] result = new int[matrixA.length][matrixB[0].length];

        Runnable task = () -> {
            for (int i = 0; i < matrixA.length; i++) {
                for (int j = 0; j < matrixB[0].length; j++) {
                    for (int k = 0; k < matrixB.length; k++) {
                        result[i][j] += matrixA[i][k] * matrixB[k][j];
                    }
                }
            }
            System.out.println("Matrix multiplication completed.");
        };

        Thread thread = new Thread(task);
        thread.start();

        System.out.println("Main thread is free to perform other tasks.");
    }
}

Using lambdas simplifies thread creation and keeps the focus on the task at hand. However, the readability advantage of lambdas diminishes when the logic becomes more complex. Lambdas are best suited for short, self-contained tasks, while more elaborate operations may still benefit from dedicated classes.

Comparison of Approaches

Using Runnable provides greater flexibility as it decouples the task logic from the thread. This allows you to reuse the task in different contexts without being tied to the thread lifecycle. In contrast, extending Thread is a more direct but less flexible approach, suitable for cases where task-specific logic is closely associated with thread behavior. Lambdas, on the other hand, bring simplicity and conciseness but are better suited for tasks with straightforward logic. Each approach has its place, and choosing the right one depends on the requirements of your application.

Understanding Race Conditions

Definition and Cause of Race Conditions

A race condition occurs when two or more threads access shared data simultaneously, and the final outcome depends on the sequence in which the threads execute. In other words, the behavior of the program becomes unpredictable and inconsistent because of uncontrolled access to shared resources.

For instance, imagine a shared counter that multiple threads increment concurrently. If the threads interleave their execution improperly, the counter's value might not reflect all increments accurately, leading to incorrect results.

Race conditions typically arise when:

  1. Threads share data or resources.

  2. There is no proper coordination or synchronization between the threads accessing the shared data.

Example Code: Incrementing a Shared Counter Without Synchronization

Let’s look at a simple example to understand how race conditions manifest in a multithreaded environment.

public class RaceConditionExample {

    private static int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        // Creating two threads that increment the shared counter
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter++;
            }
        });

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

        // Start both threads
        t1.start();
        t2.start();

        // Wait for both threads to finish
        t1.join();
        t2.join();

        // Print the final value of the counter
        System.out.println("Final Counter Value: " + counter);
    }
}

Demonstration of Inconsistent Results

In the above code, two threads increment the counter variable 1000 times each. Logically, the expected final value of counter is 2000. However, when you run the program, the output often falls short of this value. For example:

Final Counter Value: 1873

This discrepancy occurs because the counter++ operation is not atomic—it involves three steps:

  1. Reading the current value of counter.

  2. Incrementing the value.

  3. Writing the new value back to counter.

When two threads execute these steps simultaneously, their operations can interleave, causing some increments to be lost. For example:

  • Thread 1 reads counter as 10.

  • Thread 2 reads counter as 10 before Thread 1 writes the incremented value.

  • Both threads increment the value to 11 and write it back, resulting in counter being 11 instead of 12.

Explanation of Why Race Conditions Happen

Race conditions occur because:

  1. Shared State: Multiple threads operate on the same data, such as the counter variable in the example.

  2. No Synchronization: There is no mechanism to control how and when threads access the shared state.

  3. Interleaving of Instructions: The operating system’s thread scheduler can pause and resume threads at any point, leading to an unpredictable sequence of operations.

This lack of control makes the program’s behavior non-deterministic, where the result varies each time you run the program.

Resolving Race Conditions

To fix race conditions, you need to ensure that threads access shared resources in a controlled manner. This can be achieved using synchronization mechanisms such as locks or atomic variables, which we will explore in later sections. These tools help enforce mutually exclusive access to critical sections of code, ensuring consistent and predictable behavior.

Locks and Synchronization in Java

Understanding synchronized in Java

Multithreading brings powerful capabilities to Java programs, but it also introduces challenges in maintaining data integrity. The synchronized keyword in Java is the cornerstone of thread synchronization, ensuring that only one thread can execute a critical section of code at a time. This is achieved through the use of intrinsic locks, also known as monitor locks.

What Are Intrinsic Locks?

In Java, intrinsic locks (also known as monitor locks) are built-in mechanisms that control access to synchronized blocks and methods. Every object in Java is associated with an intrinsic lock, ensuring that only one thread can execute a synchronized method or block on that object at any given time. Intrinsic locks are automatically acquired and released by the JVM when entering or exiting synchronized code.

For example:

synchronized (someObject) {
    // Only one thread can execute this block at a time.
}

When a thread enters a synchronized block or method, it acquires the intrinsic lock of the object being synchronized on. Once the thread exits the block or method, the lock is released, allowing other threads to acquire it.

Object Locks vs. Class Locks

Intrinsic locks can be categorized into two types based on their scope:

  1. Object Locks: Each instance of a class has its own intrinsic lock. When a thread synchronizes on an instance (e.g., by using a synchronized instance method or block), it acquires the intrinsic lock of that specific object. Other threads cannot execute synchronized blocks or methods on the same object until the lock is released.

    Example:

     javaCopy codepublic class Example {
         public synchronized void instanceMethod() {
             // Intrinsic lock on the current instance (this)
         }
    
         public void anotherMethod() {
             synchronized (this) {
                 // Also acquires the intrinsic lock on the current instance
             }
         }
     }
    

    In this case, the intrinsic lock is tied to the specific instance of the Example class.

  2. Class Locks: A class itself also has an intrinsic lock, which is associated with its Class object. Synchronizing on static methods or synchronized blocks using the class object (Example.class) acquires the class-level lock. This prevents other threads from executing static synchronized methods or blocks on the same class.

    Example:

     public class Example {
         public static synchronized void staticMethod() {
             // Intrinsic lock on the Example.class
         }
    
         public void anotherStaticMethod() {
             synchronized (Example.class) {
                 // Also acquires the intrinsic lock on Example.class
             }
         }
     }
    

    The class-level lock is independent of the object-level locks, meaning threads can execute synchronized instance methods concurrently with synchronized static methods, as they are governed by different locks.

How the synchronized Keyword Works

The synchronized keyword can be applied to methods or code blocks, and its behavior depends on the context:

  1. Acquiring the Object Lock (Instance Method or Block)

    • When a thread executes a synchronized instance method or block, it acquires the lock associated with the object on which the method or block is being executed.

    • While the lock is held, no other thread can execute any synchronized method or block on the same object.

  2. Acquiring the Class Lock (Static Method or Block)

    • When a thread executes a synchronized static method or block, it acquires the lock associated with the class object (i.e., the Class object in the JVM representing the class).

    • This ensures mutual exclusion for static methods or blocks across all instances of the class.

What Happens When a Thread Enters a Synchronized Block?

  1. Lock Acquisition:

    • The thread attempts to acquire the relevant lock (object or class lock). If the lock is already held by another thread, the current thread is blocked until the lock becomes available.
  2. Execution:

    • Once the lock is acquired, the thread executes the critical section.
  3. Lock Release:

    • After completing the synchronized block, the thread releases the lock, allowing other threads to acquire it.

This mechanism ensures mutual exclusion and prevents race conditions on shared resources.

Code Example: Managing a Shared Counter

public class SharedCounter {
    private int counter = 0;

    // Synchronized method to increment the counter
    public synchronized void increment() {
        counter++;
    }

    // Synchronized method to get the counter value
    public synchronized int getCounter() {
        return counter;
    }

    public static void main(String[] args) {
        SharedCounter sharedCounter = new SharedCounter();

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

        // Creating and starting multiple threads
        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

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

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Final Counter Value: " + sharedCounter.getCounter());
    }
}

Explanation:

  • The increment() and getCounter() methods are synchronized to prevent race conditions.

  • Multiple threads increment the shared counter, but synchronization ensures that the final value is consistent.

Pitfalls of Synchronization

  1. Thread Contention:

    • When multiple threads compete for the same lock, they are blocked, leading to contention.

    • Contention can degrade performance, especially with frequent lock acquisition and release.

  2. Deadlocks:

    • If two or more threads acquire locks in different orders, they can block each other indefinitely.

    • Example: Thread A holds Lock 1 and waits for Lock 2, while Thread B holds Lock 2 and waits for Lock 1.

Synchronized Methods vs. Synchronized Blocks

  1. Synchronized Methods:

    • Easier to implement as they synchronize the entire method.

    • May result in reduced performance since the entire method is locked, even if synchronization is required for only a small portion.

  2. Synchronized Blocks:

    • More fine-grained control as only the critical section is synchronized.

    • Better performance when only part of the method needs synchronization.

Code Example: Bank Account Transfer

public class BankAccount {
    private int balance;

    public BankAccount(int initialBalance) {
        this.balance = initialBalance;
    }

    // Method to transfer money using a synchronized block
    public void transfer(BankAccount targetAccount, int amount) {
        synchronized (this) {
            if (this.balance >= amount) {
                this.balance -= amount;
                synchronized (targetAccount) {
                    targetAccount.balance += amount;
                }
            }
        }
    }

    public int getBalance() {
        return balance;
    }

    public static void main(String[] args) {
        BankAccount account1 = new BankAccount(1000);
        BankAccount account2 = new BankAccount(500);

        // Runnable task to perform a transfer
        Runnable task = () -> account1.transfer(account2, 200);

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

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

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Account 1 Balance: " + account1.getBalance());
        System.out.println("Account 2 Balance: " + account2.getBalance());
    }
}

Explanation:

  • The transfer method uses synchronized blocks to ensure thread safety while transferring money between accounts.

  • Locks are acquired on the source and target accounts in a specific order to prevent deadlocks.

Locks in wait and notify

The wait() and notify() methods in Java are essential for thread communication and coordination, allowing threads to signal and wait for specific conditions. These methods are defined in the Object class, which makes them universally available for any object in Java. However, their behavior is intricately tied to intrinsic locks, which are the foundation of synchronization in Java.

Role of Intrinsic Locks in wait() and notify()

The wait() and notify() methods rely on intrinsic locks to coordinate thread communication. They must always be used within a synchronized block or method because they interact with the intrinsic lock of the object being synchronized on. Here's how they work:

  1. wait():

    • When a thread calls wait() on an object, it releases the intrinsic lock of that object and enters a waiting state.

    • The thread remains in this state until another thread calls notify() or notifyAll() on the same object.

    • The lock is reacquired by the thread before it resumes execution.

Example:

    public synchronized void waitExample() {
        try {
            System.out.println("Thread is waiting...");
            wait(); // Releases the intrinsic lock of 'this' and waits
            System.out.println("Thread resumed.");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
  1. notify():

    • When a thread calls notify() on an object, it signals one of the threads waiting on the same object to wake up.

    • The awakened thread must reacquire the intrinsic lock before it can continue execution.

Example:

    public synchronized void notifyExample() {
        System.out.println("Notifying a waiting thread...");
        notify(); // Signals one waiting thread on 'this'
    }

These methods rely on intrinsic locks to ensure that threads are coordinated correctly, preventing race conditions and ensuring predictable behavior.

Relationship Between Intrinsic Locks and wait()/notify()

The intrinsic lock plays a crucial role in the lifecycle of wait() and notify():

  • wait() temporarily releases the lock and allows other threads to execute synchronized blocks or methods on the same object. This makes it possible for a producer thread, for example, to add data to a buffer while a consumer thread waits for that data.

  • notify() signals waiting threads, but the lock is not immediately released. The notifying thread retains the lock until it exits the synchronized block or method.

Using these methods outside of a synchronized context will result in an IllegalMonitorStateException, as there is no lock associated with the object to manage the thread's state.

Why Intrinsic Locks Are Critical

Intrinsic locks are integral to ensuring thread safety and managing access to shared resources. They are also the foundation for higher-level concurrency utilities provided by the java.util.concurrent package. While intrinsic locks work well for simple synchronization scenarios, they can become a bottleneck or lead to deadlocks in complex systems if not used carefully.

In contrast, tools like ReentrantLock offer more advanced locking capabilities, such as fairness policies and lock interruption, but they come with added complexity. However, understanding intrinsic locks is a prerequisite for effectively using such advanced constructs.

By grasping the distinction between object locks and class locks and their role in synchronization, you can design thread-safe programs that leverage Java's multithreading capabilities effectively. In the next sections, we will delve deeper into practical examples of wait() and notify() and their applications in real-world scenarios, such as producer-consumer problems.

Code Example: Producer-Consumer Scenario

import java.util.LinkedList;
import java.util.Queue;

public class ProducerConsumer {
    private final Queue<Integer> buffer = new LinkedList<>();
    private final int MAX_SIZE = 5;

    public void produce() throws InterruptedException {
        int value = 0;
        while (true) {
            synchronized (this) {
                while (buffer.size() == MAX_SIZE) {
                    wait(); // Release lock and wait
                }
                buffer.add(value);
                System.out.println("Produced: " + value);
                value++;
                notify(); // Notify a waiting consumer
            }
            Thread.sleep(500); // Simulate production time
        }
    }

    public void consume() throws InterruptedException {
        while (true) {
            synchronized (this) {
                while (buffer.isEmpty()) {
                    wait(); // Release lock and wait
                }
                int value = buffer.poll();
                System.out.println("Consumed: " + value);
                notify(); // Notify a waiting producer
            }
            Thread.sleep(500); // Simulate consumption time
        }
    }

    public static void main(String[] args) {
        ProducerConsumer pc = new ProducerConsumer();

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

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

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

Explanation:

  • The producer adds items to the buffer, and the consumer removes them.

  • The wait() method is used when the buffer is full or empty, pausing the thread and releasing the lock.

  • The notify() method signals the other thread to wake up and continue execution.

Advanced Synchronization Primitives in Java

Java provides advanced synchronization primitives to tackle more complex concurrency problems than what synchronized can address. These tools offer greater flexibility and control, enabling developers to implement robust thread coordination and resource management strategies.

1. ReentrantLock and ReadWriteLock

ReentrantLock: Explanation
  • ReentrantLock is part of the java.util.concurrent.locks package and provides an explicit locking mechanism.

  • Unlike synchronized, it offers additional features such as:

    • Fairness: Ensures threads acquire locks in the order they request them.

    • Try-Lock: Non-blocking attempts to acquire a lock.

    • Interruptible Lock Acquisition: Allows a thread to stop waiting for a lock if interrupted.

Differences Between Intrinsic Locks and ReentrantLock
FeatureIntrinsic Locks (synchronized)ReentrantLock
Acquisition FairnessNot guaranteedCan be configured
Lock InterruptionNot possibleSupported
Try-Lock (Non-blocking)Not supportedSupported
Condition VariablesSingle implicit conditionMultiple Condition objects
Code Example: Bank Account Operations with ReentrantLock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class BankAccount {
    private int balance;
    private final Lock lock = new ReentrantLock();

    public BankAccount(int initialBalance) {
        this.balance = initialBalance;
    }

    public void deposit(int amount) {
        lock.lock();
        try {
            balance += amount;
            System.out.println(Thread.currentThread().getName() + " deposited " + amount);
        } finally {
            lock.unlock();
        }
    }

    public void withdraw(int amount) {
        lock.lock();
        try {
            if (balance >= amount) {
                balance -= amount;
                System.out.println(Thread.currentThread().getName() + " withdrew " + amount);
            } else {
                System.out.println(Thread.currentThread().getName() + " insufficient balance.");
            }
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        BankAccount account = new BankAccount(1000);

        Runnable depositTask = () -> {
            for (int i = 0; i < 3; i++) {
                account.deposit(100);
            }
        };

        Runnable withdrawTask = () -> {
            for (int i = 0; i < 3; i++) {
                account.withdraw(150);
            }
        };

        Thread t1 = new Thread(depositTask, "Thread 1");
        Thread t2 = new Thread(withdrawTask, "Thread 2");

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

Motivation and Explanation:

  • This example demonstrates thread-safe deposit and withdrawal operations using ReentrantLock.

  • The lock() and unlock() methods ensure exclusive access to the critical section.

  • Properly handling locks with try-finally prevents deadlocks in case of exceptions.

ReadWriteLock: Explanation
  • A ReadWriteLock allows multiple threads to read concurrently while ensuring exclusive access for writes.

  • It consists of two locks:

    • Read Lock: Shared lock for multiple readers.

    • Write Lock: Exclusive lock for writers.

When to Use ReadWriteLock
  • Scenarios with a high ratio of reads to writes, such as caching or resource state monitoring.

2. CyclicBarrier

Explanation
  • A CyclicBarrier allows a set of threads to wait for each other at a common barrier point before proceeding.

  • The barrier is cyclic because it can be reused after all threads have crossed it.

Use Cases
  • Dividing a task into subtasks executed by multiple threads, and merging results once all threads complete.
Code Example: Waiting for Threads to Complete a Computation Phase
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierExample {
    public static void main(String[] args) {
        int numWorkers = 3;
        CyclicBarrier barrier = new CyclicBarrier(numWorkers, () -> {
            System.out.println("All threads have reached the barrier. Proceeding...");
        });

        Runnable task = () -> {
            try {
                System.out.println(Thread.currentThread().getName() + " is performing a task...");
                Thread.sleep((long) (Math.random() * 1000));
                System.out.println(Thread.currentThread().getName() + " has reached the barrier.");
                barrier.await();
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
        };

        for (int i = 0; i < numWorkers; i++) {
            new Thread(task).start();
        }
    }
}

Motivation and Explanation:

  • The CyclicBarrier ensures all threads complete their work before proceeding.

  • The optional barrier action (lambda) runs after all threads reach the barrier.

3. Semaphore

Explanation
  • A Semaphore restricts the number of threads that can access a resource concurrently.

  • Useful for managing limited resources, such as database connections or hardware devices.

Code Example: Simulating a Printing Queue with Limited Printers
import java.util.concurrent.Semaphore;

public class PrintingQueue {
    private final Semaphore semaphore;

    public PrintingQueue(int availablePrinters) {
        semaphore = new Semaphore(availablePrinters);
    }

    public void printJob(String document) {
        try {
            semaphore.acquire();
            System.out.println(Thread.currentThread().getName() + " is printing: " + document);
            Thread.sleep((long) (Math.random() * 1000));
            System.out.println(Thread.currentThread().getName() + " has finished printing.");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            semaphore.release();
        }
    }

    public static void main(String[] args) {
        PrintingQueue queue = new PrintingQueue(2);

        Runnable printTask = () -> {
            queue.printJob("Document " + Thread.currentThread().getName());
        };

        for (int i = 0; i < 5; i++) {
            new Thread(printTask, "Thread " + i).start();
        }
    }
}

The Semaphore ensures no more than two threads print simultaneously, simulating limited printer availability.

4. CountDownLatch

Explanation
  • A CountDownLatch waits for a specific number of threads to complete their tasks before continuing.

  • The latch counts down with each countDown() call and releases waiting threads when the count reaches zero.

Use Cases
  • Ensuring all initialization tasks complete before starting a system.
Code Example: Multi-threaded System Initialization
import java.util.concurrent.CountDownLatch;

public class SystemInitialization {
    public static void main(String[] args) throws InterruptedException {
        int numTasks = 3;
        CountDownLatch latch = new CountDownLatch(numTasks);

        Runnable initTask = () -> {
            try {
                System.out.println(Thread.currentThread().getName() + " is initializing...");
                Thread.sleep((long) (Math.random() * 1000));
                System.out.println(Thread.currentThread().getName() + " initialization complete.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                latch.countDown();
            }
        };

        for (int i = 0; i < numTasks; i++) {
            new Thread(initTask).start();
        }

        latch.await();
        System.out.println("All initialization tasks complete. System is starting...");
    }
}

The CountDownLatch ensures the system waits for all initialization tasks to finish before proceeding.

5. Comparison of Advanced Synchronization Primitives

PrimitiveKey FeatureBest Use Case
ReentrantLockExplicit locking with fairnessFine-grained control over locking
ReadWriteLockSeparate locks for reads/writesHigh read-to-write ratio
CyclicBarrierSynchronize multiple threadsCoordinating phases of computation
SemaphoreLimit concurrent resource accessManaging a pool of limited resources
CountDownLatchWait for threads to finishEnsuring all tasks complete before proceeding

Thread Pools and Executor Services in Java

Thread pools and executor services are essential tools for managing multithreading efficiently in Java. The Executor Framework was introduced in Java 5 to simplify thread management and address the limitations of manually managing threads. By abstracting thread creation, lifecycle management, and scheduling, thread pools allow developers to focus on application logic.

1. Introduction to the Executor Framework

The Executor Framework is part of the java.util.concurrent package. It provides a high-level API for managing and controlling threads, offering a flexible alternative to manually creating and starting threads. At its core, it decouples task submission from the mechanics of thread use.

Why Use Thread Pools?
  1. Performance Improvement:

    • Reduces overhead associated with creating and destroying threads repeatedly.

    • Threads are reused from a pool instead of being created every time.

  2. Resource Management:

    • Prevents exhaustion of system resources by limiting the number of concurrent threads.
  3. Simplified Error Handling:

    • Built-in mechanisms to handle exceptions and thread termination.
  4. Scalability:

    • Optimized for handling many short-lived tasks in parallel.

2. Types of Thread Pools

The Executors utility class provides factory methods to create different types of thread pools tailored to specific use cases.

Fixed Thread Pool

A fixed thread pool has a predefined number of threads. If all threads are busy, new tasks are queued until a thread becomes available.

Code Example:

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

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

        Runnable task = () -> {
            System.out.println(Thread.currentThread().getName() + " is executing a task.");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        for (int i = 1; i <= 6; i++) {
            executor.execute(task);
        }

        executor.shutdown();
    }
}

Motivation and Use Case:

  • Useful when the number of tasks is predictable.

  • Ideal for applications with a fixed number of threads, such as handling database connections.

Cached Thread Pool

A cached thread pool creates new threads as needed and reuses previously constructed threads when available. Threads that remain idle for a certain period are terminated.

Code Example:

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

public class CachedThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newCachedThreadPool();

        Runnable task = () -> {
            System.out.println(Thread.currentThread().getName() + " is executing a task.");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        for (int i = 1; i <= 10; i++) {
            executor.execute(task);
        }

        executor.shutdown();
    }
}

Motivation and Use Case:

  • Suitable for applications with many short-lived asynchronous tasks.

  • Examples include serving HTTP requests or processing messages in a queue.

Scheduled Thread Pool

A scheduled thread pool allows scheduling tasks with a fixed delay or periodically.

Code Example:

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class ScheduledThreadPoolExample {
    public static void main(String[] args) {
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);

        Runnable task = () -> {
            System.out.println(Thread.currentThread().getName() + " is executing a scheduled task.");
        };

        scheduler.schedule(task, 3, TimeUnit.SECONDS); // Schedule after 3 seconds
        scheduler.scheduleAtFixedRate(task, 1, 2, TimeUnit.SECONDS); // Periodic execution

        // Uncomment the following line to stop periodic tasks after some time
        // scheduler.shutdown();
    }
}

Motivation and Use Case:

  • Useful for tasks that must run periodically or after a delay.

  • Examples include scheduled reporting or periodic cache cleanup.

Single Thread Executor

A single thread executor uses a single thread to execute tasks sequentially. If a task fails, it ensures subsequent tasks are not affected.

Code Example:

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

public class SingleThreadExecutorExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newSingleThreadExecutor();

        Runnable task = () -> {
            System.out.println(Thread.currentThread().getName() + " is executing a task.");
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        for (int i = 1; i <= 5; i++) {
            executor.execute(task);
        }

        executor.shutdown();
    }
}

Motivation and Use Case:

  • Ensures tasks are executed one at a time in order.

  • Useful for logging, event handling, or single-threaded UI tasks.

3. Practical Examples

Running Multiple Tasks Concurrently

Code Example:

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

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

        for (int i = 1; i <= 8; i++) {
            final int taskId = i;
            executor.execute(() -> {
                System.out.println("Task " + taskId + " is executed by " + Thread.currentThread().getName());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }

        executor.shutdown();
    }
}

Explanation:

  • Four threads execute tasks concurrently, reducing execution time for a batch of tasks.
Scheduled Task Execution

Code Example:

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class PeriodicTaskExample {
    public static void main(String[] args) {
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);

        Runnable task = () -> {
            System.out.println("Periodic task executed by " + Thread.currentThread().getName());
        };

        scheduler.scheduleAtFixedRate(task, 2, 5, TimeUnit.SECONDS); // Run every 5 seconds after 2 seconds
    }
}

Use Case:

  • Suitable for tasks like sending heartbeat signals or periodic system monitoring.

4. Best Practices

  1. Managing Thread Pool Size:

    • Use appropriate thread pool sizes based on the system's resources and workload.

    • Rule of thumb:

      • CPU-bound tasks: Use a fixed thread pool with size equal to the number of CPU cores.

      • I/O-bound tasks: Use a cached thread pool or a size larger than the number of cores.

  2. Handling Exceptions in Thread Pools:

    • Exceptions thrown by tasks can terminate threads in the pool if not handled.

    • Wrap tasks in try-catch blocks or use custom ThreadFactory implementations.

Example: Exception Handling

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

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

        Runnable task = () -> {
            try {
                if (Math.random() > 0.7) {
                    throw new RuntimeException("Task failure!");
                }
                System.out.println(Thread.currentThread().getName() + " completed successfully.");
            } catch (Exception e) {
                System.err.println(Thread.currentThread().getName() + " encountered an error: " + e.getMessage());
            }
        };

        for (int i = 0; i < 5; i++) {
            executor.execute(task);
        }

        executor.shutdown();
    }
}

Virtual Threads in Java

Virtual Threads, introduced as part of Project Loom, revolutionize Java's concurrency model by offering lightweight threads that run on the JVM. These threads aim to make high-concurrency applications more scalable and simpler to write by addressing the limitations of traditional platform threads.

1. What Are Virtual Threads?

Introduction to Lightweight Threading in Project Loom

Virtual threads are user-mode threads managed by the JVM rather than the operating system (OS). Unlike traditional threads (often referred to as platform threads), which are tied to OS threads, virtual threads decouple the execution from the underlying OS resources, allowing the JVM to manage them more efficiently.

Key Characteristics:

  1. Lightweight:

    • Virtual threads are much lighter in memory and CPU usage than platform threads.

    • They are created and destroyed at a fraction of the cost of traditional threads.

  2. Scalable:

    • Thousands or even millions of virtual threads can coexist without overwhelming system resources.
  3. Managed by the JVM:

    • Unlike OS threads, the JVM scheduler determines when and how to execute virtual threads.
Benefits Over Traditional Threads
  1. High Scalability:

    • Platform threads are resource-intensive, limiting scalability in high-concurrency applications. Virtual threads eliminate this bottleneck by using fewer resources per thread.
  2. Simpler Concurrency Model:

    • With virtual threads, developers can write code using a thread-per-task model without worrying about resource limits.
  3. Compatibility with Blocking Code:

    • Virtual threads can handle blocking operations like file I/O or network requests efficiently, as they don’t block OS threads. Instead, the JVM suspends and resumes them as needed.

2. How to Use Virtual Threads

Using virtual threads is straightforward and integrates seamlessly with the existing Java concurrency model.

Code Example: Simple Virtual Thread Usage
public class VirtualThreadsExample {
    public static void main(String[] args) {
        Runnable task = () -> {
            System.out.println("Running in: " + Thread.currentThread());
        };

        // Creating and starting a virtual thread
        Thread vThread = Thread.ofVirtual().start(task);

        // Creating multiple virtual threads in a loop
        for (int i = 0; i < 10; i++) {
            Thread.ofVirtual().start(() -> {
                System.out.println("Virtual Thread: " + Thread.currentThread().getName());
                try {
                    Thread.sleep(500); // Simulating a blocking operation
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
    }
}

Output Example:

Running in: Thread[#VirtualThread-1]
Virtual Thread: #VirtualThread-2
Virtual Thread: #VirtualThread-3
...

Explanation:

  • Virtual threads are created using Thread.ofVirtual().

  • Tasks run as though they are traditional threads, with no special changes to the code.

3. Comparison of Virtual Threads and Traditional Threads

FeatureTraditional ThreadsVirtual Threads
Creation CostHigh (OS-managed)Low (JVM-managed)
Memory Consumption~1 MB per thread~1 KB per thread
Blocking OperationsBlocks an OS threadBlocks the virtual thread only
SchedulingOS SchedulerJVM Scheduler
ScalabilityLimited by OS threadsMillions of threads possible
Use CasesCPU-intensive tasksHigh-concurrency tasks (e.g., servers)

4. Performance Considerations

Scalability of Virtual Threads

Virtual threads shine in scenarios with high concurrency but low computational demand per task. Benchmarks show they can handle millions of concurrent threads with minimal impact on performance.

Why This Matters:

  • Traditional threads exhaust resources (CPU, memory) at higher concurrency levels.

  • Virtual threads allow each task to execute independently without creating bottlenecks.

Typical Benchmark:

Concurrency LevelPlatform Threads (Memory)Virtual Threads (Memory)
10,000~10 GB~10 MB
1,000,000N/A (Out of Memory)~1 GB

Internal Mechanics: OS Scheduling vs. JVM Scheduling
  1. Traditional Threads (OS Scheduling):

    • Each thread is mapped to an OS thread.

    • OS threads are heavyweight and managed by the kernel.

    • Context switching is costly and impacts scalability.

  2. Virtual Threads (JVM Scheduling):

    • Virtual threads are decoupled from OS threads.

    • JVM uses a small pool of worker OS threads to execute virtual threads.

    • When a virtual thread performs a blocking operation, the JVM parks the virtual thread and reassigns the OS thread to another virtual thread.

Diagram:

[ Virtual Thread ] ---> [ JVM Scheduler ] ---> [ OS Worker Threads ] ---> [ CPU ]

Advantages:

  • Reduces OS thread contention.

  • Eliminates unnecessary context switching.

  • Provides better control over scheduling.

5. When to Use Virtual Threads

Ideal Use Cases:

  1. High-Concurrency Applications:

    • Web servers and microservices handling thousands of simultaneous requests.

    • Examples: Chat applications, real-time event processing.

  2. I/O-Bound Operations:

    • Applications that spend most of their time waiting for I/O.

    • Examples: File processing, network requests.

  3. Server-Side Development:

    • Virtual threads simplify asynchronous code by making it look synchronous.

When Not to Use:

  • CPU-Bound Tasks:

    • For tasks that require constant CPU usage, traditional thread pools may suffice.
  • Low-Concurrency Applications:

    • If concurrency needs are minimal, traditional threads may be simpler.

6. Common Pitfalls and Limitations

  1. Understanding Blocking Operations:

    • Ensure libraries and APIs used are compatible with virtual threads.

    • Blocking OS calls not managed by the JVM can hinder performance.

  2. Debugging Challenges:

    • Debugging millions of virtual threads can become complex.
  3. Garbage Collection Impact:

    • High thread counts may increase the load on the garbage collector, requiring tuning.

Virtual threads mark a paradigm shift in Java's concurrency model. They combine the simplicity of synchronous code with the scalability of asynchronous models, empowering developers to build scalable, high-performance applications with ease. By understanding their internal mechanics, performance trade-offs, and appropriate use cases, developers can fully harness the potential of virtual threads in their applications.

What we learnt

Multithreading is a foundational concept in Java that enables concurrent execution of tasks, improving application responsiveness, resource utilization, and throughput. This blog explored multithreading comprehensively, from the basics of synchronized to the advanced primitives and the cutting-edge virtual threads introduced in Project Loom.

We covered

  1. Locks and Synchronization:

    • The synchronized keyword ensures thread safety by acquiring intrinsic locks (object or class locks). We discussed the difference between synchronized methods and blocks, pitfalls like deadlocks, and how locks integrate with wait() and notify() for thread communication.

    • Practical examples included managing a shared counter and implementing a producer-consumer scenario with wait() and notify().

  2. Advanced Synchronization Primitives:

    • ReentrantLock provides fine-grained control over locking, fairness, and try-lock mechanisms, while ReadWriteLock optimizes for high read-to-write ratios.

    • Coordination mechanisms like CyclicBarrier, Semaphore, and CountDownLatch simplify complex threading scenarios, such as waiting for phases, managing limited resources, or ensuring thread completion.

    • Each primitive was explained with real-world examples like bank operations, printing queues, and multi-threaded initialization.

  3. Thread Pools and Executor Services:

    • The Executor Framework abstracts thread management, offering thread pools like fixed, cached, scheduled, and single-thread executors.

    • Benefits include better resource management, exception handling, and simplified concurrency models.

    • Practical examples demonstrated how to run concurrent tasks, execute periodic jobs, and handle exceptions effectively.

  4. Virtual Threads in Project Loom:

    • Virtual threads redefine concurrency in Java by decoupling JVM threads from OS threads, enabling millions of lightweight threads to run efficiently.

    • We explored the internals, comparing JVM scheduling with OS scheduling, and highlighted scenarios like high-concurrency and I/O-bound tasks where virtual threads shine.

    • Benchmarks revealed dramatic improvements in scalability and resource utilization, but we also discussed potential pitfalls like debugging challenges and garbage collection overhead.

Multithreading in Java is a vast and evolving domain, encompassing everything from fundamental thread safety mechanisms to cutting-edge features like virtual threads. By mastering these concepts:

  • Developers can write applications that efficiently manage concurrency, whether it’s a simple producer-consumer system or a high-concurrency web server.

  • Advanced synchronization primitives and executor services allow tackling complex, real-world problems with elegance and scalability.

  • Virtual threads offer a glimpse into the future of Java concurrency, making high-concurrency applications simpler to build and maintain.

Java's multithreading capabilities empower developers to harness the full potential of modern multicore systems, paving the way for building scalable, robust, and efficient applications for diverse domains. With these tools and techniques, the challenges of concurrency transform into opportunities for innovation and performance optimization.

0
Subscribe to my newsletter

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

Written by

Jyotiprakash Mishra
Jyotiprakash Mishra

I am Jyotiprakash, a deeply driven computer systems engineer, software developer, teacher, and philosopher. With a decade of professional experience, I have contributed to various cutting-edge software products in network security, mobile apps, and healthcare software at renowned companies like Oracle, Yahoo, and Epic. My academic journey has taken me to prestigious institutions such as the University of Wisconsin-Madison and BITS Pilani in India, where I consistently ranked among the top of my class. At my core, I am a computer enthusiast with a profound interest in understanding the intricacies of computer programming. My skills are not limited to application programming in Java; I have also delved deeply into computer hardware, learning about various architectures, low-level assembly programming, Linux kernel implementation, and writing device drivers. The contributions of Linus Torvalds, Ken Thompson, and Dennis Ritchie—who revolutionized the computer industry—inspire me. I believe that real contributions to computer science are made by mastering all levels of abstraction and understanding systems inside out. In addition to my professional pursuits, I am passionate about teaching and sharing knowledge. I have spent two years as a teaching assistant at UW Madison, where I taught complex concepts in operating systems, computer graphics, and data structures to both graduate and undergraduate students. Currently, I am an assistant professor at KIIT, Bhubaneswar, where I continue to teach computer science to undergraduate and graduate students. I am also working on writing a few free books on systems programming, as I believe in freely sharing knowledge to empower others.