Executor Service in Java
Introduction
Managing threads manually in Java using new Thread(Runnable).start() is an anti-pattern in modern enterprise development. Thread creation is a heavyweight operation that involves system calls, memory allocation for thread stacks, and OS-level context switching.
Introduced in Java 5 as part of the java.util.concurrent package, the Executor Framework provides a high-level API to decouple task submission from task execution. At the heart of this framework is the ExecutorService interface, which manages a pool of worker threads, queues up tasks, and handles their execution efficiently.
Core Concepts and Architecture
What is an ExecutorService?
An ExecutorService is an interface extending Executor. While Executor only provides a single execute(Runnable) method, ExecutorService adds lifecycle management capabilities (shutting down, checking status) and the ability to return Future objects for tracking the progress and result of asynchronous tasks.
The Thread Pool Pattern
Instead of spinning up a new thread for every task, an ExecutorService typically utilizes a Thread Pool. The pool maintains a set of worker threads that continuously pull tasks from a concurrent queue (like LinkedBlockingQueue or SynchronousQueue).
This architecture provides several benefits:
- Resource Bounding: Limits the maximum number of concurrent threads, preventing the application from crashing due to
OutOfMemoryError. - Reduced Latency: Threads are kept alive and reused, avoiding the overhead of thread creation.
- Graceful Degradation: When the system is under heavy load, tasks are queued rather than immediately rejected or processed at the cost of overall system stability.
Creating an Executor Service
The Executors utility class provides factory methods to create various types of thread pools:
1. Fixed Thread Pool
Creates a thread pool that reuses a fixed number of threads operating off a shared unbounded queue.
ExecutorService fixedPool = Executors.newFixedThreadPool(4);
Best for: Applications with predictable, bounded workloads where you want to limit CPU and memory usage.
2. Cached Thread Pool
Creates a thread pool that creates new threads as needed, but will reuse previously constructed threads when they are available. Threads that have been idle for sixty seconds are terminated and removed from the cache.
ExecutorService cachedPool = Executors.newCachedThreadPool();
Best for: Applications executing many short-lived asynchronous tasks.
3. Single Thread Executor
Creates an Executor that uses a single worker thread operating off an unbounded queue.
ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();
Best for: Ensuring tasks are executed sequentially without concurrency issues.
4. Scheduled Thread Pool
Supports future and/or periodic execution of tasks.
ScheduledExecutorService scheduledPool = Executors.newScheduledThreadPool(2);
scheduledPool.scheduleAtFixedRate(() -> System.out.println("Ping"), 0, 1, TimeUnit.SECONDS);
Submitting Tasks: Runnable vs Callable
Tasks can be submitted to an ExecutorService as either a Runnable or a Callable.
Runnable
Represents a task that does not return a result and cannot throw a checked exception.
Runnable task = () -> {
System.out.println("Executing Runnable task inside: " + Thread.currentThread().getName());
};
executorService.execute(task); // Fire and forget
Callable
Represents a task that returns a result and may throw an exception.
Callable<Integer> task = () -> {
// Perform complex calculation
return 42;
};
Future<Integer> futureResult = executorService.submit(task);
The Future API
When you submit a Callable (or a Runnable), the ExecutorService returns a Future. A Future acts as a placeholder for the result of an asynchronous computation.
Future<String> future = executorService.submit(() -> {
Thread.sleep(2000);
return "Data fetched successfully";
});
// Do some other work concurrently...
try {
// Blocks the current thread until the result is available
String result = future.get();
System.out.println(result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
Important Future methods:
isDone(): Checks if the task has completed.cancel(boolean mayInterruptIfRunning): Attempts to cancel the task.get(long timeout, TimeUnit unit): Waits for a specified time for the result.
Lifecycle Management: Shutting Down
An ExecutorService will keep the JVM running even if the main thread completes, because its worker threads are non-daemon by default. You must explicitly shut it down.
shutdown()
Initiates an orderly shutdown where previously submitted tasks are executed, but no new tasks will be accepted.
shutdownNow()
Attempts to stop all actively executing tasks, halts the processing of waiting tasks, and returns a list of the tasks that were awaiting execution.
The Standard Shutdown Pattern
executorService.shutdown();
try {
if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) {
executorService.shutdownNow(); // Cancel currently executing tasks
if (!executorService.awaitTermination(60, TimeUnit.SECONDS))
System.err.println("Pool did not terminate");
}
} catch (InterruptedException ie) {
executorService.shutdownNow();
Thread.currentThread().interrupt(); // Preserve interrupt status
}
Advanced Implementations
Custom ThreadPoolExecutor
For fine-grained control over core pool size, maximum pool size, keep-alive time, and the underlying queue mechanism, you can instantiate a ThreadPoolExecutor directly:
int corePoolSize = 2;
int maximumPoolSize = 4;
long keepAliveTime = 10;
ThreadPoolExecutor customExecutor = new ThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100), // Bounded queue
new ThreadPoolExecutor.CallerRunsPolicy() // Rejection policy
);
Rejection Policies
When the thread pool and the queue are both full, the ExecutorService must reject new tasks. Java provides standard rejection policies:
AbortPolicy(default): ThrowsRejectedExecutionException.CallerRunsPolicy: The thread attempting to submit the task executes it directly. Provides a simple feedback control mechanism to slow down the rate of submission.DiscardPolicy: Silently drops the task.DiscardOldestPolicy: Discards the oldest unhandled request and attempts to execute the new task.
Summary
The ExecutorService framework is a robust, flexible, and powerful abstraction for managing concurrency in Java. By abstracting thread lifecycle management, decoupling task submission from execution, and providing comprehensive APIs like Future and Callable, it enables developers to build highly scalable, safe, and maintainable multithreaded applications.