Boost Java Performance 4× with CompletableFuture: When and How to Use It
This article explains how to replace synchronous price‑lookup APIs with Java 8's CompletableFuture, compares synchronous and asynchronous performance, introduces the most useful CompletableFuture creation and composition methods, shows practical code examples, and discusses when to prefer it over traditional Future or thread‑pool approaches.
What You Will Learn
How to use CompletableFuture
Performance testing of CompletableFuture in asynchronous and synchronous modes
Why CompletableFuture was added to JDK 1.8 despite the existence of Future
Typical application scenarios for CompletableFuture
Optimizations when using CompletableFuture
Scenario Description
We need to query the price of a product from many shops. The shop provides a synchronous getPrice method, which internally uses Thread.sleep to simulate a time‑consuming operation. When the third‑party API is synchronous and cannot be changed, we can wrap the call with CompletableFuture to improve throughput.
public class Shop {
private Random random = new Random();
/**
* Get price by product name
*/
public double getPrice(String product) {
return calculatePrice(product);
}
/**
* Calculate price
*/
private double calculatePrice(String product) {
delay();
// random.nextDouble() returns a random discount
return random.nextDouble() * product.charAt(0) + product.charAt(1);
}
/**
* Simulate other time‑consuming operations
*/
private void delay() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}The above code demonstrates a typical blocking API that we want to call asynchronously.
Using CompletableFuture
CompletableFuture is an implementation of the Future interface introduced in JDK 1.8. It provides many factory methods and composition APIs.
Creating CompletableFuture
new CompletableFuture<>() – creates an empty future that must be completed manually. CompletableFuture.completedFuture(value) – returns a future already completed with the given value. CompletableFuture.supplyAsync(supplier) – runs the supplier in the default ForkJoinPool and returns a future with the result. CompletableFuture.supplyAsync(supplier, executor) – runs the supplier in a custom executor. CompletableFuture.runAsync(runnable) and its overload with an executor – runs a task that does not return a value.
CompletableFuture<Double> futurePrice = new CompletableFuture<>(); public static <U> CompletableFuture<U> completedFuture(U value) {
return new CompletableFuture<U>(value == null ? NIL : value);
} public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier) {
return asyncSupplyStage(asyncPool, supplier);
}Getting Results
get()– blocks until the result is available, may throw checked exceptions. get(long timeout, TimeUnit unit) – blocks with a timeout. getNow(valueIfAbsent) – returns the result immediately if completed, otherwise returns the supplied default. join() – like get() but wraps exceptions in CompletionException and does not require a try‑catch for checked exceptions.
public T get() throws InterruptedException, ExecutionException {}
public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException {}
public T getNow(T valueIfAbsent) {}
public T join() {}Example demonstrating the different behaviours:
public class AcquireResultTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// getNow test
CompletableFuture<String> cp1 = CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(60 * 1000 * 60); } catch (InterruptedException e) { e.printStackTrace(); }
return "hello world";
});
System.out.println(cp1.getNow("hello h2t"));
// join test (exception wrapped in CompletionException)
CompletableFuture<Integer> cp2 = CompletableFuture.supplyAsync(() -> 1 / 0);
System.out.println(cp2.join());
// get test (exception wrapped in ExecutionException)
CompletableFuture<Integer> cp3 = CompletableFuture.supplyAsync(() -> 1 / 0);
System.out.println(cp3.get());
}
}Key points: getNow returns the default because the task sleeps for a minute. join propagates the exception as CompletionException without requiring a checked‑exception clause. get propagates the exception as ExecutionException.
When a CompletableFuture is created with new, you must complete it manually or call completeExceptionally for error handling.
Synchronous vs Asynchronous Price Query
private static List<String> findPriceSync(String product) {
return shopList.stream()
.map(shop -> String.format("%s price is %.2f", shop.getName(), shop.getPrice(product)))
.collect(Collectors.toList());
} private static List<String> findPriceAsync(String product) {
List<CompletableFuture<String>> futureList = shopList.stream()
.map(shop -> CompletableFuture.supplyAsync(() ->
String.format("%s price is %.2f", shop.getName(), shop.getPrice(product))))
.collect(Collectors.toList());
return futureList.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
}Performance test results:
Find Price Sync Done in 4141
Find Price Async Done in 1033The asynchronous version is roughly four times faster.
Why CompletableFuture Is Still Needed
Before JDK 1.8, developers used ExecutorService.submit to obtain a Future. However, Future provides only get and lacks composability. When multiple asynchronous tasks need to be combined, or when the result of one task feeds another, the API becomes cumbersome and often requires busy‑waiting loops.
while (!future.isDone()) {
result = future.get();
doSomethingWithResult(result);
}CompletableFuture offers declarative composition methods such as thenApply, thenCompose, whenComplete, etc., which make these patterns concise and efficient.
Other CompletableFuture APIs
whenComplete
Handles the result or exception without producing a new value.
public CompletableFuture<T> whenComplete(BiConsumer<? super T, ? super Throwable> action)
public CompletableFuture<T> whenCompleteAsync(BiConsumer<? super T, ? super Throwable> action)
public CompletableFuture<T> whenCompleteAsync(BiConsumer<? super T, ? super Throwable> action, Executor executor)thenApply
Transforms the result of a previous stage.
public <U> CompletableFuture<U> thenApply(Function<? super T, ? extends U> fn)
public <U> CompletableFuture<U> thenApplyAsync(Function<? super T, ? extends U> fn)
public <U> CompletableFuture<U> thenApplyAsync(Function<? super T, ? extends U> fn, Executor executor) public class ThenApplyTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
CompletableFuture<Integer> result = CompletableFuture.supplyAsync(ThenApplyTest::randomInteger)
.thenApply(i -> i * 8);
System.out.println(result.get());
}
public static Integer randomInteger() { return 10; }
}thenAccept
Consumes the result without returning a new value.
public CompletableFuture<Void> thenAccept(Consumer<? super T> action)
public CompletableFuture<Void> thenAcceptAsync(Consumer<? super T> action)
public CompletableFuture<Void> thenAcceptAsync(Consumer<? super T> action, Executor executor) public class ThenAcceptTest {
public static void main(String[] args) {
CompletableFuture.supplyAsync(ThenAcceptTest::getList)
.thenAccept(list -> list.forEach(System.out::println));
}
public static List<String> getList() { return Arrays.asList("a", "b", "c"); }
}thenCompose
Flattens two asynchronous stages, useful for dependent tasks.
public <U> CompletableFuture<U> thenCompose(Function<? super T, ? extends CompletionStage<U>> fn)
public <U> CompletableFuture<U> thenComposeAsync(Function<? super T, ? extends CompletionStage<U>> fn)
public <U> CompletableFuture<U> thenComposeAsync(Function<? super T, ? extends CompletionStage<U>> fn, Executor executor) public class ThenComposeTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
CompletableFuture<Integer> result = CompletableFuture.supplyAsync(ThenComposeTest::getInteger)
.thenCompose(i -> CompletableFuture.supplyAsync(() -> i * 10));
System.out.println(result.get());
}
private static int getInteger() { return 666; }
}thenCombine
Combines two independent futures.
public <U,V> CompletableFuture<V> thenCombine(CompletionStage<? extends U> other,
BiFunction<? super T,? super U,? extends V> fn)
public <U,V> CompletableFuture<V> thenCombineAsync(CompletionStage<? extends U> other,
BiFunction<? super T,? super U,? extends V> fn)
public <U,V> CompletableFuture<V> thenCombineAsync(CompletionStage<? extends U> other,
BiFunction<? super T,? super U,? extends V> fn, Executor executor) public class ThenCombineTest {
private static Random random = new Random();
public static void main(String[] args) throws ExecutionException, InterruptedException {
CompletableFuture<Integer> result = CompletableFuture.supplyAsync(ThenCombineTest::randomInteger)
.thenCombine(CompletableFuture.supplyAsync(ThenCombineTest::randomInteger), (i, j) -> i * j);
System.out.println(result.get());
}
public static Integer randomInteger() { return random.nextInt(100); }
}allOf / anyOf
Utility methods for waiting on multiple futures.
public static CompletableFuture<Void> allOf(CompletableFuture<?>... cfs)
public static CompletableFuture<Object> anyOf(CompletableFuture<?>... cfs)allOf completes when every supplied future finishes; anyOf completes when any one finishes.
Important Notes
Although many methods have asynchronous variants (ending with Async), they introduce context switches and may not always be faster. Always benchmark with realistic data before adopting them.
Application Scenarios
CompletableFuture shines in I/O‑bound workloads where the blocking call can be off‑loaded to another thread. Typical uses include asynchronous logging (Logback, Log4j2), remote service calls, and any situation where multiple independent I/O operations need to be aggregated. For CPU‑bound work, parallel streams are usually a better fit.
Optimization Space
The core of supplyAsync delegates to an executor (by default ForkJoinPool.commonPool()).
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier) {
return asyncSupplyStage(asyncPool, supplier);
}
static final Executor asyncPool = useCommonPool ? ForkJoinPool.commonPool() : new ThreadPerTaskExecutor();For I/O‑heavy tasks, the default pool size (based on CPU cores) may be insufficient. The optimal pool size can be estimated as
CPU cores × CPU utilization × (1 + waitTime/CPUtime). Therefore, creating a custom thread pool tailored to the workload often yields better performance.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Programmer DD
A tinkering programmer and author of "Spring Cloud Microservices in Action"
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
