Working with asynchronous programming in Java used to be tricky with just Thread
, Runnable
, or ExecutorService
. Since Java 8, CompletableFuture
has become one of the most powerful APIs for handling asynchronous tasks with clean, fluent, and non-blocking code.
In this blog, we’ll explore:
- Basics of
CompletableFuture
- Real-world use cases
- Chaining and combining futures
- Exception handling
- Running multiple tasks in parallel
1. Getting Started with CompletableFuture
A CompletableFuture
represents a future result of an asynchronous computation. It can be manually completed or executed asynchronously.
Example: Running an async task
import java.util.concurrent.*;
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
try {
Thread.sleep(1000);
System.out.println("Task executed in: " + Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
}
});
future.get(); // wait until completion
}
}
📌 Key point: runAsync()
runs a Runnable
(no return value), while supplyAsync()
runs a Supplier
(with return value).
2. Returning Results with supplyAsync
import java.util.concurrent.*;
public class CompletableFutureReturn {
public static void main(String[] args) throws Exception {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) { }
return "Hello from " + Thread.currentThread().getName();
});
System.out.println(future.get()); // blocks until result is available
}
}
3. Chaining with thenApply
, thenAccept
, thenRun
-
thenApply()
→ transform result and return new value -
thenAccept()
→ consume result (no return) -
thenRun()
→ run after completion (ignores result)
import java.util.concurrent.*;
public class CompletableFutureChaining {
public static void main(String[] args) {
CompletableFuture.supplyAsync(() -> "Data")
.thenApply(data -> data + " processed")
.thenAccept(System.out::println)
.thenRun(() -> System.out.println("Pipeline finished!"));
}
}
Output:
Data processed
Pipeline finished!
4. Combining Multiple Futures
Example: thenCombine
(merge two results)
import java.util.concurrent.*;
public class CompletableFutureCombine {
public static void main(String[] args) throws Exception {
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "World");
CompletableFuture<String> combined = future1.thenCombine(future2, (a, b) -> a + " " + b);
System.out.println(combined.get()); // Hello World
}
}
Example: allOf
(wait for multiple tasks)
import java.util.concurrent.*;
public class CompletableFutureAllOf {
public static void main(String[] args) throws Exception {
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "Task1");
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "Task2");
CompletableFuture<String> f3 = CompletableFuture.supplyAsync(() -> "Task3");
CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);
all.join();
System.out.println(f1.get() + ", " + f2.get() + ", " + f3.get());
}
}
5. Handling Exceptions
Using exceptionally
public class CompletableFutureException {
public static void main(String[] args) throws Exception {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (true) throw new RuntimeException("Something went wrong!");
return "OK";
}).exceptionally(ex -> "Recovered from: " + ex.getMessage());
System.out.println(future.get()); // Recovered from: Something went wrong!
}
}
Using handle
public class CompletableFutureHandle {
public static void main(String[] args) throws Exception {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (true) throw new RuntimeException("Error occurred!");
return "Success";
}).handle((result, ex) -> {
if (ex != null) return "Fallback value";
return result;
});
System.out.println(future.get()); // Fallback value
}
}
6. Real-World Use Case: Fetching Data from Multiple APIs
Imagine you want to fetch product details and pricing from different services in parallel.
import java.util.concurrent.*;
public class ProductService {
public static void main(String[] args) throws Exception {
CompletableFuture<String> productFuture = CompletableFuture.supplyAsync(() -> {
sleep(1000);
return "Product details";
});
CompletableFuture<String> priceFuture = CompletableFuture.supplyAsync(() -> {
sleep(1500);
return "Price details";
});
CompletableFuture<String> finalResult =
productFuture.thenCombine(priceFuture, (product, price) -> product + " + " + price);
System.out.println("Final Result: " + finalResult.get());
}
private static void sleep(int ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { }
}
}
7. When to Use CompletableFuture
✅ Best for:
- Running I/O-bound tasks in parallel (API calls, DB queries)
- Building pipelines of transformations
- Handling dependent async tasks
- Aggregating results from multiple computations
❌ Avoid for:
- Purely CPU-bound heavy computations (better use
ForkJoinPool
or parallel streams) - Extremely short tasks (overhead may outweigh benefits)
📝 Conclusion
CompletableFuture
simplifies asynchronous programming in Java by making code:
- Readable (fluent chaining)
- Composable (combine tasks easily)
- Robust (built-in exception handling)
By using patterns like thenCombine
, allOf
, and exceptionally
, you can build efficient non-blocking pipelines for real-world applications.
👉 Do you want me to also include advanced use cases like timeouts (orTimeout
), cancellation, and using a custom ExecutorService in this blog?
Top comments (0)