Class 38: Introduction to CompletableFuture
Part 7 — Asynchronous Programming and Modern Java
Welcome to Part 7 of our Java Multithreading course!
So far, we have learned how to create threads, manage them using the Executor Framework, protect shared data, and coordinate multiple threads.
We have also learned about Future, which allows us to receive the result of a task that may finish later.
But there is one limitation with Future.
When we call future.get(), the calling thread
waits until the result becomes available.
What if we want to perform another operation when a task finishes, without blocking the current thread and waiting for the result?
What if we want to combine the results of multiple tasks?
This is where CompletableFuture becomes useful.
1. First, Understand Asynchronous Programming
Let's understand this with a simple everyday example.
Imagine that you order food at a restaurant.
After placing your order, you do not stand in the kitchen waiting for the food to be prepared.
Instead, you sit at your table, talk to your friends, or do something else.
When your food is ready, the restaurant serves it to you.
This is similar to asynchronous programming.
You start a task and allow it to execute independently. Meanwhile, the current thread can continue doing other work.
When the task completes, you can perform another action using its result.
The important idea is:
Start a task, continue with other work, and handle the result when it becomes available.
2. What Is CompletableFuture?
CompletableFuture is a class available in the
java.util.concurrent package.
It represents a result that may become available in the future.
It implements the Future interface and also provides additional methods to perform actions when a task completes.
For example, we can:
- Start a task asynchronously.
- Perform another action when the task completes.
- Transform the result of a task.
- Combine the results of multiple tasks.
- Handle exceptions when a task fails.
We will explore these capabilities gradually in this part of the course.
3. Creating a CompletableFuture with runAsync()
Let's start with a simple example.
Suppose we want to execute a task asynchronously, but we do not need any result from it.
We can use CompletableFuture.runAsync().
import java.util.concurrent.CompletableFuture;
public class CompletableFutureExample {
public static void main(String[] args) {
CompletableFuture<Void> future =
CompletableFuture.runAsync(() -> {
System.out.println(
"Task is running on: "
+ Thread.currentThread().getName()
);
});
System.out.println("Main thread continues");
future.join();
}
}
Let's understand what is happening.
CompletableFuture.runAsync(() -> {
System.out.println("Task is running");
});
We are asking CompletableFuture to execute the supplied task asynchronously.
By default, runAsync() uses the common ForkJoinPool to execute the task.
We will learn about the Fork/Join Framework later. For now, simply understand that Java manages the thread that executes this task.
Also notice the return type:
CompletableFuture<Void>
Void indicates that this task does not produce a result.
4. Understanding join()
You might have noticed this statement:
future.join();
The join() method waits until the CompletableFuture completes.
In this example, we use it to make sure that the asynchronous task finishes before the main method exits.
It is important to understand that join() is a blocking operation.
If the task has not completed, the calling thread waits.
You may remember future.get() from our earlier class. Both get() and join() wait for completion and return the result when one is available.
There is one important difference:
get()can throw checked exceptions such as InterruptedException and ExecutionException.join()throws an unchecked CompletionException when the task completes exceptionally.
We will explore exception handling in a later class.
5. Creating a CompletableFuture with supplyAsync()
What if our asynchronous task needs to return a result?
For example, suppose we want to calculate the total price of an order.
We can use CompletableFuture.supplyAsync().
import java.util.concurrent.CompletableFuture;
public class SupplyAsyncExample {
public static void main(String[] args) {
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> {
System.out.println(
"Calculating on: "
+ Thread.currentThread().getName()
);
return 500 + 200;
});
System.out.println("Main thread continues");
Integer result = future.join();
System.out.println("Total price: " + result);
}
}
Here, our task returns an integer value.
Therefore, the type of our CompletableFuture is:
CompletableFuture<Integer>
The Integer represents the type of result produced by the asynchronous task.
The main thread can continue doing other work while the calculation is being performed.
When we call join(), the main thread waits if the result is not yet available.
6. runAsync() vs. supplyAsync()
Both methods start asynchronous tasks, but there is one important difference.
| runAsync() | supplyAsync() |
|---|---|
| Used when we do not need a result. | Used when we need a result. |
| Accepts a Runnable task. | Accepts a Supplier task. |
| Returns CompletableFuture<Void>. | Returns CompletableFuture<T>. |
Let's remember this with two simple examples.
runAsync(): Send an email notification. We do not need a result from the task.
supplyAsync(): Calculate the total price of an order. We need the calculated amount.
7. Supplying a Custom Executor
So far, we have used the default execution mechanism provided by CompletableFuture.
But what if we want to control which Executor executes our task?
Both runAsync() and supplyAsync() provide overloaded versions that accept an Executor.
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CustomExecutorExample {
public static void main(String[] args) {
ExecutorService executor =
Executors.newFixedThreadPool(2);
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> {
System.out.println(
"Task running on: "
+ Thread.currentThread().getName()
);
return 100;
}, executor);
Integer result = future.join();
System.out.println("Result: " + result);
executor.shutdown();
}
}
Notice that we pass the executor as the second argument:
CompletableFuture.supplyAsync(task, executor);
This tells CompletableFuture to use the supplied Executor for the asynchronous task instead of its default execution mechanism.
In this example, we created a fixed thread pool containing two worker threads.
We are responsible for shutting down this ExecutorService when it is no longer needed.
8. CompletableFuture vs. Future
We have already studied Future in Class 31. Let's understand how CompletableFuture extends that idea.
| Future | CompletableFuture |
|---|---|
| Represents a result that may become available later. | Also represents a result that may become available later. |
| get() is commonly used to retrieve the result. | Provides get() and join() to retrieve the result. |
| Does not directly provide a fluent API for chaining actions. | Provides methods to chain and combine asynchronous tasks. |
| Limited support for composing asynchronous workflows. | Designed to build more flexible asynchronous workflows. |
The biggest advantage of CompletableFuture is that we can build a sequence of operations without manually waiting for every intermediate result.
We will explore that in our next class.
9. A Few Important Things to Remember
- CompletableFuture is part of java.util.concurrent.
- runAsync() executes a task that does not return a result.
- supplyAsync() executes a task that returns a result.
- By default, these methods use the common ForkJoinPool.
- We can provide a custom Executor when needed.
- join() waits for the task to complete and is therefore blocking.
One final point: asynchronous execution does not mean that a task is guaranteed to run faster. It means that we can organize work so that the calling thread does not have to perform every task itself or wait immediately for every result.
Test Your Understanding
Let's see how well you understood this class!
Question 1: What is CompletableFuture used for?
Question 2: Which method should we use when an asynchronous task does not need to return a result?
Question 3: Which method should we use when an asynchronous task needs to return a result?
Question 4: What happens when we call join() before a CompletableFuture has completed?
Question 5: What is the purpose of passing an Executor to supplyAsync()?
Class Summary
Let's quickly revise what we learned today.
- Asynchronous programming allows a task to execute independently while the calling thread continues other work.
- CompletableFuture represents a result that may become available later.
- runAsync() is used when a task does not need to return a result.
- supplyAsync() is used when a task needs to return a result.
- join() waits for completion and returns the result.
- By default, asynchronous tasks use the common ForkJoinPool.
- We can provide a custom Executor to control task execution.
- CompletableFuture provides methods to chain and combine asynchronous operations.
Remember: runAsync() is for tasks without a result. supplyAsync() is for tasks that produce a result.
You have completed 38 out of 46 classes.