CompletableFuture in Java: A Complete Guide with ExecutorService, Future, CountDownLatch, and Real-World Spring Boot Examples
Modern Java applications, especially Spring Boot microservices, are expected to handle thousands of requests efficiently. A common requirement is to execute multiple independent tasks simultaneously instead of oneβ¦
Modern Java applications, especially Spring Boot microservices, are expected to handle thousands of requests efficiently. A common requirement is to execute multiple independent tasks simultaneously instead of one after another.
Consider an e-commerce dashboard API. To prepare the response, the application needs to fetch:
- Products
- Orders
- Notifications
Each service call is independent. If each takes 2 seconds, executing them sequentially will take around 6 seconds. However, if they are executed in parallel, the total response time becomes approximately 2 seconds.
Java provides several concurrency utilities for solving these problems:
- ExecutorService
- Future
- CountDownLatch
- CompletableFuture
Many developers know how to use these classes individually but are often confused about when to choose each one.
This guide explains each concept from beginner to advanced level using real Spring Boot examples.
Problem Statement
Suppose we have the following services.
productService.getProducts();
orderService.getOrders();
notificationService.getNotifications();
Each service takes around 3 seconds.
Sequential execution:
Products 3 sec
Orders 3 sec
Notifications 3 sec
---------------------
Total 9 sec
Instead, we want all three services to execute simultaneously.
Products
ββββββββ 3 sec
Orders
ββββββββ 3 sec
Notifications
ββββββββ 3 sec
---------------------
Total 3 sec
This is where Java concurrency becomes useful.
Step 1: ExecutorService
What is ExecutorService?
ExecutorService is an interface that manages a pool of threads and executes submitted tasks asynchronously.
Instead of creating threads manually,
new Thread(...).start();
we simply submit tasks to the thread pool.
Example
ExecutorService executor =
Executors.newFixedThreadPool(3);
executor.submit(() -> {
System.out.println("Loading Products");
});
executor.submit(() -> {
System.out.println("Loading Orders");
});
executor.shutdown();
The ExecutorService decides which thread executes which task.
Real Spring Boot Example
Suppose your Dashboard API needs
- Products
- Orders
- Notifications
Using ExecutorService
ExecutorService executor =
Executors.newFixedThreadPool(3);
executor.submit(() -> productService.getProducts());
executor.submit(() -> orderService.getOrders());
executor.submit(() -> notificationService.getNotifications());
The three tasks start simultaneously.
Problem with ExecutorService
Although tasks execute concurrently,
there is no simple way to know when all tasks are completed.
This leads us to CountDownLatch.
Step 2: CountDownLatch
What is CountDownLatch?
CountDownLatch is a synchronization utility that allows one or more threads to wait until a specified number of operations complete.
Think of it as a countdown timer.
Count = 3
Task 1 finishes
β
Count = 2
Task 2 finishes
β
Count = 1
Task 3 finishes
β
Count = 0
β
Continue execution
Example
ExecutorService executor =
Executors.newFixedThreadPool(3);
CountDownLatch latch =
new CountDownLatch(3);
executor.submit(() -> {
productService.getProducts();
latch.countDown();
});
executor.submit(() -> {
orderService.getOrders();
latch.countDown();
});
executor.submit(() -> {
notificationService.getNotifications();
latch.countDown();
});
latch.await();
System.out.println("All services completed.");
Execution Flow
ExecutorService
β
βββ Product Task
β β
β βΌ
β countDown()
β
βββ Order Task
β β
β βΌ
β countDown()
β
βββ Notification Task
β
βΌ
countDown()
β
βΌ
Count reaches 0
β
βΌ
latch.await() resumes
Limitation of CountDownLatch
CountDownLatch only tells us
All tasks are finished.
It does not return results.
If you need return values, you must store them separately.
Returning Values with CountDownLatch
AtomicReference<List<Product>> products =
new AtomicReference<>();
ExecutorService executor =
Executors.newFixedThreadPool(1);
CountDownLatch latch =
new CountDownLatch(1);
executor.submit(() -> {
try {
products.set(productService.getProducts());
} finally {
latch.countDown();
}
});
latch.await();
List<Product> result = products.get();
Why AtomicReference?
Normally,
List<Product> products;
executor.submit(() -> {
products = productService.getProducts();
});
This does not compile because lambda expressions can only access local variables that are final or effectively final.
Instead,
AtomicReference<List<Product>> products =
new AtomicReference<>();
creates a thread-safe mutable container.
The variable never changes.
Only its contents change.
products
β
βΌ
AtomicReference
β
βΌ
List<Product>
Later,
products.get();
returns the stored list.
Problem
Although CountDownLatch works,
the code becomes complicated.
Java introduced Future to solve this.
Step 3: Future
What is Future?
Future represents the result of an asynchronous computation.
It allows us to
- execute a task
- wait for completion
- retrieve the result
Example
ExecutorService executor =
Executors.newFixedThreadPool(3);
Future<List<Product>> productFuture =
executor.submit(() -> productService.getProducts());
Future<List<Order>> orderFuture =
executor.submit(() -> orderService.getOrders());
Future<List<Notification>> notificationFuture =
executor.submit(() -> notificationService.getNotifications());
List<Product> products =
productFuture.get();
List<Order> orders =
orderFuture.get();
List<Notification> notifications =
notificationFuture.get();
Does get() Wait Sequentially?
Many developers think
productFuture.get()
β
orderFuture.get()
β
notificationFuture.get()
means
5 + 2 + 3 = 10 seconds
This is incorrect.
Suppose
Products 5 sec
Orders 2 sec
Notifications 3 sec
Timeline
Time
0
β
ββββββββββββββββββββββββββββββΊ
Products
βββββββββββββββββββββ
Orders
ββββββ
Notifications
ββββββββββ
When
productFuture.get();
waits 5 seconds,
the other two tasks have already completed.
Therefore
orderFuture.get();
returns immediately.
So Do We Need CountDownLatch with Future?
No.
Future already waits for completion.
Future<List<Product>> future =
executor.submit(...);
future.get();
The get() method
- waits
- returns the result
Therefore CountDownLatch becomes unnecessary.
Limitations of Future
Although Future is better,
it still has several limitations.
- Blocking API
- Cannot chain tasks
- Cannot combine multiple tasks easily
- Difficult exception handling
Java 8 solved these problems using CompletableFuture.
Step 4: CompletableFuture
What is CompletableFuture?
CompletableFuture is an implementation of Future that supports asynchronous execution, result composition, task chaining, combination of multiple futures, and advanced exception handling.
Think of the evolution like this:
Runnable
β
ExecutorService
β
Future
β
CompletableFuture
supplyAsync()
One of the most important methods.
Definition:
supplyAsync() executes a task asynchronously and returns a CompletableFuture containing the task’s result.
Signature
CompletableFuture<T> future =
CompletableFuture.supplyAsync(
supplier);
or
CompletableFuture<T> future =
CompletableFuture.supplyAsync(
supplier,
executor);
It accepts a Supplier.
Supplier’s job is simple:
Give me a value.
Example
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> {
return "Hello Java";
});
System.out.println(future.join());
Output
Hello Java
Dashboard Example
ExecutorService executor =
Executors.newFixedThreadPool(3);
CompletableFuture<List<Product>> productFuture =
CompletableFuture.supplyAsync(
() -> productService.getProducts(),
executor);
CompletableFuture<List<Order>> orderFuture =
CompletableFuture.supplyAsync(
() -> orderService.getOrders(),
executor);
CompletableFuture<List<Notification>> notificationFuture =
CompletableFuture.supplyAsync(
() -> notificationService.getNotifications(),
executor);
All three tasks start immediately.
Waiting for All Tasks
Instead of
latch.await();
use
CompletableFuture
.allOf(
productFuture,
orderFuture,
notificationFuture)
.join();
This waits until every CompletableFuture is completed.
Getting Results
DashboardResponse response =
new DashboardResponse(
productFuture.join(),
orderFuture.join(),
notificationFuture.join());
The join() method returns the completed result.
Complete Example
ExecutorService executor =
Executors.newFixedThreadPool(3);
CompletableFuture<List<Product>> productFuture =
CompletableFuture.supplyAsync(
() -> productService.getProducts(),
executor);
CompletableFuture<List<Order>> orderFuture =
CompletableFuture.supplyAsync(
() -> orderService.getOrders(),
executor);
CompletableFuture<List<Notification>> notificationFuture =
CompletableFuture.supplyAsync(
() -> notificationService.getNotifications(),
executor);
CompletableFuture
.allOf(
productFuture,
orderFuture,
notificationFuture)
.join();
DashboardResponse response =
new DashboardResponse(
productFuture.join(),
orderFuture.join(),
notificationFuture.join());
executor.shutdown();
return response;
Execution Flow
Dashboard API
β
βΌ
Creates ExecutorService
β
βΌ
Creates Three CompletableFutures
β
βββββββββββββββββ
β β
βΌ βΌ
Products Orders
β β
βΌ βΌ
CompletableFuture CompletableFuture
β β
ββββββββ ββββββ
βΌ βΌ
Notifications
β
βΌ
CompletableFuture
β
βΌ
CompletableFuture.allOf()
β
βΌ
join()
β
βΌ
DashboardResponse
supplyAsync() vs runAsync()
Use
CompletableFuture.supplyAsync()
when the task returns a value.
CompletableFuture<List<Product>> future =
CompletableFuture.supplyAsync(
() -> productService.getProducts());
Use
CompletableFuture.runAsync()
when the task returns nothing.
CompletableFuture.runAsync(
() -> emailService.sendEmail());
Future vs CompletableFuture
| Feature | Future | CompletableFuture |
|---|---|---|
| Execute asynchronously | Yes | Yes |
| Return result | Yes | Yes |
| Wait for completion | get() | join() |
| Chain tasks | No | Yes |
| Combine tasks | No | Yes |
| Exception handling | Basic | Advanced |
| Non-blocking composition | No | Yes |
| Recommended for modern Spring Boot | Limited | Yes |
CountDownLatch vs Future vs CompletableFuture
| Feature | CountDownLatch | Future | CompletableFuture |
|---|---|---|---|
| Executes tasks | No | With ExecutorService | Yes |
| Waits for completion | Yes | Yes | Yes |
| Returns values | No | Yes | Yes |
| Combine multiple tasks | No | Limited | Yes |
| Task chaining | No | No | Yes |
| Exception handling | Manual | Basic | Advanced |
| Best use case | Wait for tasks | Simple async result | Modern asynchronous workflows |
When Should You Use Each?
Use ExecutorService
- Managing thread pools
- Running asynchronous tasks
Use CountDownLatch
- Waiting for multiple tasks
- Tasks do not return results
- Coordinating threads
Use Future
- Need a result from one or more asynchronous tasks
- Simple asynchronous programming
Use CompletableFuture
- Building REST APIs
- Calling multiple microservices
- Parallel execution
- Chaining tasks
- Combining results
- Exception handling
- Modern Spring Boot applications
Best Practices
- Prefer
CompletableFutureoverFuturefor new development. - Always provide a custom
ExecutorServiceinstead of relying on the default common pool in server applications. - Use
CompletableFuture.allOf()to wait for multiple asynchronous tasks. - Use
join()afterallOf()to retrieve results. - Shut down custom executors when they are no longer needed, or define them as Spring-managed beans.
- Handle exceptions using
exceptionally(),handle(), orwhenComplete().
Conclusion
Java’s concurrency utilities have evolved significantly over the years.
ExecutorService manages threads, CountDownLatch coordinates task completion, Future allows retrieval of asynchronous results, and CompletableFuture brings everything together by supporting asynchronous execution, task composition, chaining, exception handling, and parallel processing.
For modern Spring Boot applications, especially those following a microservices architecture, CompletableFuture is the preferred choice. It leads to cleaner code, better scalability, and improved application performance by making asynchronous workflows easier to write and maintain.