1. Introduction
In this tutorial, we’ll explore two important Java classes for handling tasks that need to run concurrently: ExecutorService and CompletableFuture. We’ll learn their functionalities and how to use them effectively, and we’ll understand the key differences between them.
2. Overview of ExecutorService
The ExecutorService is a powerful interface in Java’s java.util.concurrent package that simplifies managing tasks that need to run concurrently. It abstracts away the complexities of thread creation, management, and scheduling, allowing us to focus on the actual work that needs to be done.
ExecutorService provides methods like submit() and execute() to submit tasks we want to run concurrently. These tasks are then queued and assigned to available threads within the thread pool. If the task returns results, we can use Future objects to retrieve them. However, retrieving results using methods like get() on a Future can block the calling thread until the task is completed.
3. Overview of CompletableFuture
The CompletableFuture was introduced in Java 8. It focuses on composing asynchronous operations and handling their eventual results in a more declarative way. A CompletableFuture acts as a container that holds the eventual result of an asynchronous operation. It might not have a result immediately, but it provides methods to define what to do when the result becomes available.
Unlike ExecutorService, where retrieving results can block the thread, CompletableFuture operates in a non-blocking manner.
4. Focus and Responsibilities
While both ExecutorService and CompletableFuture tackle asynchronous programming in Java, they serve distinct purposes. Let’s explore their respective focus and responsibilities.
4.1. ExecutorService
ExecutorService focuses on managing thread pools and executing tasks concurrently. It offers methods for creating thread pools with different configurations, such as fixed-size, cached, and scheduled.
Let’s see an example that creates and maintains exactly three threads using ExecutorService:
ExecutorService executor = Executors.newFixedThreadPool(3);
Future<Integer> future = executor.submit(() -> {
// Task execution logic
return 42;
});
The newFixedThreadPool(3) method call creates a thread pool with three threads, ensuring that no more than three tasks will be executed concurrently. The submit() method is then used to submit a task for execution in the thread pool, returning a Future object representing the result of the computation.
4.2. CompletableFuture
In contrast, CompletableFuture provides a higher-level abstraction for composing asynchronous operations. It focuses on defining the workflow and handling the eventual results of asynchronous tasks.
Here’s an example that uses supplyAsync() to initiate an asynchronous task that returns a string:
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
return 42;
});
In this example, supplyAsync() initiates an asynchronous task that returns a result of 42.
5. Chaining Asynchronous Tasks
Both ExecutorService and CompletableFuture offer mechanisms for chaining asynchronous tasks, but they take different approaches.
5.1. ExecutorService
In ExecutorService, we typically submit tasks for execution and then use the Future objects returned by these tasks to handle dependencies and chain subsequent tasks. However, this involves blocking and waiting for the completion of each task before proceeding to the next, which can lead to inefficiencies in handling asynchronous workflows.
Consider the case where we submit two tasks to an ExecutorService and then chain them together using Future objects:
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<Integer> firstTask = executor.submit(() -> {
return 42;
});
Future<String> secondTask = executor.submit(() -> {
try {
Integer result = firstTask.get();
return "Result based on Task 1: " + result;
} catch (InterruptedException | ExecutionException e) {
// Handle exception
}
return null;
});
executor.shutdown();
In this example, the second task depends on the result of the first task. However, ExecutorService doesn’t offer built-in chaining, so we need to explicitly manage the dependency by waiting for the first task to complete using get() — which blocks the thread — before submitting the second task.
5.2. CompletableFuture
On the other hand, CompletableFuture offers a more streamlined and expressive way to chain asynchronous tasks. It simplifies task chaining with built-in methods like thenApply(). These methods allow you to define a sequence of asynchronous tasks where the output of one task becomes the input for the next.
Here’s an equivalent example using CompletableFuture:
CompletableFuture<Integer> firstTask = CompletableFuture.supplyAsync(() -> {
return 42;
});
CompletableFuture<String> secondTask = firstTask.thenApply(result -> {
return "Result based on Task 1: " + result;
});
In this example, the thenApply() method is used to define the second task, which depends on the result of the first task. When we use thenApply() to chain a task to a CompletableFuture, the main thread doesn’t wait for the first task to complete before proceeding. It continues executing other parts of our code.
6. Error Handling
In this section, we’ll examine how both ExecutorService and CompletableFuture manage errors and exceptional scenarios.
6.1. ExecutorService
When using ExecutorService, errors can manifest in two ways:
- Exceptions thrown within the submitted tasks: These exceptions propagate back to the main thread when we attempt to retrieve the result using methods like get() on the returned Future object. This can lead to unexpected behavior if not handled appropriately.
- Unchecked exceptions during thread pool management: If an unchecked exception occurs during thread pool creation or shutdown, it’s typically thrown from the ExecutorService methods themselves. We need to catch and handle these exceptions in our code.
Let’s look at an example, highlighting the potential issues:
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<String> future = executor.submit(() -> {
if (someCondition) {
throw new RuntimeException("Something went wrong!");
}
return "Success";
});
try {
String result = future.get();
System.out.println("Result: " + result);
} catch (InterruptedException | ExecutionException e) {
// Handle exception
} finally {
executor.shutdown();
}
In this example, the submitted task throws an exception if a specific condition is met. However, we need to use a try-catch block around future.get() to catch exceptions thrown by the task or during retrieval using get(). This approach can become tedious for managing errors across multiple tasks.
6.2. CompletableFuture
In contrast, CompletableFuture offers a more robust approach to error handling with methods like exceptionally() and handling exceptions within the chaining methods themselves. These methods allow us to define how to handle errors at different stages of the asynchronous workflow, without the need for explicit try-catch blocks.
Here’s an equivalent example using CompletableFuture with error handling:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (someCondition) {
throw new RuntimeException("Something went wrong!");
}
return "Success";
})
.exceptionally(ex -> {
System.err.println("Error in task: " + ex.getMessage());
return "Error occurred"; // Can optionally return a default value
});
future.thenAccept(result -> System.out.println("Result: " + result));
In this example, the asynchronous task throws an exception and the error is caught and handled within the exceptionally() callback. It provides a default value (“Error occurred”) in case of an exception.
7. Timeout Management
Timeout management is crucial in asynchronous programming to ensure that tasks are completed within a specified timeframe. Let’s explore how ExecutorService and CompletableFuture handle timeouts differently.
7.1. ExecutorService
ExecutorService doesn’t offer built-in timeout functionality. To implement timeouts, we need to work with Future objects and potentially interrupt tasks exceeding the deadline. This approach involves manual coordination:
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<String> future = executor.submit(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
System.err.println("Error occured: " + e.getMessage());
}
return "Task completed";
});
try {
String result = future.get(2, TimeUnit.SECONDS);
System.out.println("Result: " + result);
} catch (TimeoutException e) {
System.err.println("Task execution timed out!");
future.cancel(true); // Manually interrupt the task.
} catch (Exception e) {
// Handle exception
} finally {
executor.shutdown();
}
In this example, we submit a task to the ExecutorService and specify a timeout of two seconds when retrieving the result using the get() method. If the task takes longer than the specified timeout to complete, a TimeoutException is thrown. This approach can be error-prone and requires careful handling.
It’s important to note that while the timeout mechanism interrupts the waiting for the task result, the task itself will continue running in the background until it either completes or is interrupted. For instance, to interrupt a task running within an ExecutorService, we need to use the Future.cancel(true) method.
7.2. CompletableFuture
In Java 9, CompletableFuture offers a more streamlined approach to timeouts with methods like completeOnTimeout(). The completeOnTimeout() method will complete the CompletableFuture with a specified value if the original task isn’t complete within the specified timeout duration.
Let’s look at an example that illustrates how completeOnTimeout() works:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
// Handle exception
}
return "Task completed";
});
CompletableFuture<String> timeoutFuture = future.completeOnTimeout("Timed out!", 2, TimeUnit.SECONDS);
String result = timeoutFuture.join();
System.out.println("Result: " + result);
In this example, the supplyAsync() method initiates an asynchronous task that simulates a long-running operation, taking five seconds to complete. However, we specify a timeout of two seconds using completeOnTimeout(). If the task isn’t completed within two seconds, the CompletableFuture will be automatically completed with the value “Timed out!”.
8. Summary
Here’s the comparison table summarizing the key differences between ExecutorService and CompletableFuture:
Feature
ExecutorService
CompletableFuture
Focus
Thread pool management and task execution
Composing asynchronous operations and handling eventual results
Chaining
Manual coordination with Future objects
Built-in methods like thenApply()
Error Handling
Manual try-catch blocks around Future.get()
exceptionally(), whenComplete(), handling within chaining methods
Timeout Management
Manual coordination with Future.get(timeout) and potential interruption
Built-in methods like completeOnTimeout()
Blocking vs. Non-Blocking
Blocking (often waits for Future.get() to retrieve results)
Non-blocking (chains tasks without blocking the main thread)
9. Conclusion
In this article, we’ve explored two essential classes for handling asynchronous tasks: ExecutorService and CompletableFuture. ExecutorService simplifies the management of thread pools and concurrent task execution, while CompletableFuture provides a higher-level abstraction for composing asynchronous operations and handling their results.
We’ve also examined their functionalities, differences, and how they handle error handling, timeout management, and chaining of asynchronous tasks.
As always, the source code for the examples is available over on GitHub.