Getting Started with Java Executors and Thread Pools

In Java, multithreading is a powerful feature that allows applications to perform multiple tasks concurrently. However, managing threads directly can be complex and error - prone. Java provides the Executor framework and thread pools to simplify the process of managing and executing multiple threads. This blog will guide you through the fundamental concepts, usage methods, common practices, and best practices of Java Executors and thread pools.

Table of Contents

  1. Fundamental Concepts
  2. Usage Methods
  3. Common Practices
  4. Best Practices
  5. Conclusion
  6. References

Fundamental Concepts

Executor

The Executor interface is the base of the Java Executor framework. It provides a simple way to decouple task submission from task execution. The Executor interface has only one method: execute(Runnable command), which is used to execute a given task.

import java.util.concurrent.Executor;

class DirectExecutor implements Executor {
    @Override
    public void execute(Runnable command) {
        command.run();
    }
}

ExecutorService

ExecutorService is a sub - interface of Executor that provides more features for managing tasks and the executor itself. It allows you to submit tasks that return results (Callable tasks), shut down the executor gracefully, and check the status of tasks.

Thread Pool

A thread pool is a collection of pre - created threads that are ready to execute tasks. Instead of creating a new thread for each task, the tasks are submitted to the thread pool, and the threads in the pool pick up the tasks and execute them. Thread pools help reduce the overhead of creating and destroying threads, improving the performance of the application.

Usage Methods

Creating a Thread Pool

The Executors class provides several factory methods to create different types of thread pools.

Fixed - Size Thread Pool

A fixed - size thread pool has a specified number of threads. If a task is submitted when all threads are busy, the task is placed in a queue.

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

public class FixedThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(3);
        for (int i = 0; i < 5; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " is running on thread " + Thread.currentThread().getName());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Task " + taskId + " is completed.");
            });
        }
        executor.shutdown();
    }
}

Cached Thread Pool

A cached thread pool creates new threads as needed but reuses previously created threads if they are available. If a thread remains idle for too long, it is terminated.

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

public class CachedThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newCachedThreadPool();
        for (int i = 0; i < 10; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " is running on thread " + Thread.currentThread().getName());
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Task " + taskId + " is completed.");
            });
        }
        executor.shutdown();
    }
}

Submitting Tasks

You can submit tasks to an ExecutorService using the submit() method. It accepts both Runnable and Callable tasks.

import java.util.concurrent.*;

public class TaskSubmissionExample {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executor = Executors.newSingleThreadExecutor();

        // Submitting a Runnable task
        executor.submit(() -> System.out.println("Runnable task is running."));

        // Submitting a Callable task
        Future<Integer> future = executor.submit(() -> {
            Thread.sleep(1000);
            return 42;
        });
        Integer result = future.get();
        System.out.println("Callable task result: " + result);

        executor.shutdown();
    }
}

Common Practices

Graceful Shutdown

When your application is about to exit, it is important to shut down the ExecutorService gracefully. You can use the shutdown() method to initiate a graceful shutdown, which allows previously submitted tasks to execute but does not accept new tasks. If you want to wait for all tasks to complete, you can use awaitTermination().

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

public class GracefulShutdownExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(2);
        for (int i = 0; i < 3; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " is running.");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Task " + taskId + " is completed.");
            });
        }
        executor.shutdown();
        try {
            if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
        }
    }
}

Handling Exceptions

When using Callable tasks, exceptions can be handled through the Future object. If an exception occurs during the execution of a Callable task, it will be wrapped in an ExecutionException when you call Future.get().

import java.util.concurrent.*;

public class ExceptionHandlingExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        Future<Integer> future = executor.submit(() -> {
            throw new RuntimeException("Something went wrong!");
        });
        try {
            Integer result = future.get();
        } catch (InterruptedException | ExecutionException e) {
            System.out.println("Exception: " + e.getCause().getMessage());
        }
        executor.shutdown();
    }
}

Best Practices

Avoid Using Executors Factory Methods in Production

The factory methods provided by the Executors class are convenient but may have some drawbacks. For example, newCachedThreadPool() can create an unbounded number of threads, which may lead to resource exhaustion. In production, it is recommended to use the ThreadPoolExecutor constructor directly to create a thread pool with more fine - grained control.

import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class CustomThreadPoolExample {
    public static void main(String[] args) {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2, // core pool size
                5, // maximum pool size
                60, // keep - alive time
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(10) // work queue
        );
        for (int i = 0; i < 15; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " is running.");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Task " + taskId + " is completed.");
            });
        }
        executor.shutdown();
    }
}

Set Appropriate Thread Pool Sizes

The size of the thread pool should be set according to the nature of the tasks. For CPU - bound tasks, the number of threads should be close to the number of available CPU cores. For I/O - bound tasks, a larger number of threads can be used to keep the CPU busy while waiting for I/O operations to complete.

int cpuCores = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor cpuBoundExecutor = new ThreadPoolExecutor(
        cpuCores,
        cpuCores,
        60,
        TimeUnit.SECONDS,
        new LinkedBlockingQueue<>()
);

Conclusion

Java Executors and thread pools are powerful tools for managing and executing multiple threads in a Java application. By understanding the fundamental concepts, usage methods, common practices, and best practices, you can write more efficient and robust multithreaded applications. Remember to use thread pools carefully, especially in production environments, to avoid resource exhaustion and other issues.

References