Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Advanced Asynchronous Programming with Java CompletableFuture

Tech May 7 5

Serial vs. Parallel Execution Logic

In standard application flows, logic is often executed serially. While this is sufficient for simple operations, it creates bottlenecks when facing time-consuming tasks, such as network I/O or complex calculations. When a request requires multiple independent data fetches—say, fetching User details, Order history, and Product recommendations—serial execution results in a total latency equal to the sum of all individual latencies (T = A + B + C).

By shifting to parallel execution, the total latency is reduced to the duration of the slowest individual task (T = max(A, B, C)). This optimization is critical for improving system throughput and user experience.

Overview of CompletableFuture

Introduced in Java 8, the CompletableFuture class implements both Future and CompletionStage interfaces. It provides extensive capabilities for asynchronous computation and task orchestration.

public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {
    // ...
}

A critical implementation detail involves the thread pool. If an explicit Executor is not provided, CompletableFuture defaults to ForkJoinPool.commonPool(). Relying on this common pool is risky in production environments; blocking I/O operations in this pool can starve other system tasks. Therefore, defining a custom thread pool is a best practice to isolate business logic and ensure system stability.

Environment Configuration

Before implementing asynchronous logic, a custom thread pool should be configured. This allows for control over thread names, queue capacities, and rejection policies.

@Bean("asyncTaskPool")
public Executor asyncTaskPool() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(10);
    executor.setQueueCapacity(100);
    executor.setThreadNamePrefix("async-exec-");
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    executor.initialize();
    return executor;
}

Creating Asynchronous Tasks

SupplyAsync vs RunAsync

The CompletableFuture API provides two primary static methods for initiating async tasks:

  • runAsync(Runnable): Executes a task that does not return a value.
  • supplyAsync(Supplier): Executes a task that produces a result.

Both methods offer overloaded versions that accept a custom Executor. Using the custom executor defined above is highly recommended.

// Task with return value
CompletableFuture<String> fetchFuture = CompletableFuture.supplyAsync(() -> {
    return "Data loaded on: " + Thread.currentThread().getName();
}, asyncTaskPool);

// Task without return value
CompletableFuture<Void> logFuture = CompletableFuture.runAsync(() -> {
    System.out.println("Logging on: " + Thread.currentThread().getName());
}, asyncTaskPool);

// Retrieving results
// join() throws an unchecked exception, get() throws checked exceptions
System.out.println(fetchFuture.join());

Chaining and Transforming Results

Once an asynchronous task completes, its result often needs to be processed or transformed.

Transforming with thenApply

The thenApply method accepts a Function. It takes the result of the previous stage, applies a function, and returns a new CompletableFuture containing the transformed result.

CompletableFuture<Integer> calculation = CompletableFuture.supplyAsync(() -> 10)
    .thenApply(input -> {
        System.out.println("Processing input: " + input);
        return input * 2;
    });

System.out.println("Final Result: " + calculation.join()); // Outputs 20

Consuming with thenAccept

If you need to perform an action with the result but do not need to pass a value down the chain, use thenAccept. It accepts a Consumer.

CompletableFuture.supplyAsync(() -> "Hello World")
    .thenAccept(message -> System.out.println("Received: " + message));

Running with thenRun

When the completion of the previous stage triggers an action, but that action does not require the result, thenRun is used. It accepts a Runnable.

CompletableFuture.supplyAsync(() -> 42)
    .thenRun(() -> System.out.println("Computation finished."));

Handling Completion and Exceptions

Completion Action with whenComplete

The whenComplete method executes a BiConsumer when the task completes, regardless of whether it succeeded or failed. It receives both the result and the throwable (if any).

CompletableFuture<Integer> safeFuture = CompletableFuture.supplyAsync(() -> {
    if (Math.random() > 0.5) throw new RuntimeException("Random Failure");
    return 100;
}).whenComplete((res, ex) -> {
    if (ex != null) {
        System.out.println("Exception occurred: " + ex.getMessage());
    } else {
        System.out.println("Success with result: " + res);
    }
});

Recovering from Errors

To handle exceptions and provide a fallback value, exceptionally is used. It functions like a catch block.

CompletableFuture<String> resilientFuture = CompletableFuture.supplyAsync(() -> {
    throw new IllegalStateException("Failed to load");
}).exceptionally(ex -> {
    System.out.println("Fallback triggered for: " + ex.getMessage());
    return "Default Value";
});

System.out.println(resilientFuture.join()); // Outputs "Default Value"

Combining Multiple Futures

Combining Independent Results (thenCombine)

When two asynchronous tasks run in parallel and you need to combine their results once both are finished, thenCombine is the ideal tool.

CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> "UserA");
CompletableFuture<Integer> scoreFuture = CompletableFuture.supplyAsync(() -> 95);

CompletableFuture<String> combined = userFuture.thenCombine(scoreFuture, (user, score) -> {
    return user + " has a score of " + score;
});

System.out.println(combined.join());

Composing Dependent Tasks (thenCompose)

If the second asynchronous task depends on the result of the first, use thenCompose. This allows for flat-mapping futures (similar to flatMap in Streams).

CompletableFuture<String> detailsFuture = CompletableFuture.supplyAsync(() -> "user_id_123")
    .thenCompose(userId -> CompletableFuture.supplyAsync(() -> "Details for " + userId));

System.out.println(detailsFuture.join());

Parallel Execution with allOf

To wait for the completion of multiple independent futures, CompletableFuture.allOf() is used. This method returns a future that completes when all provided futures finish.

CompletableFuture<String> taskA = CompletableFuture.supplyAsync(() -> "A Complete");
CompletableFuture<String> taskB = CompletableFuture.supplyAsync(() -> "B Complete");
CompletableFuture<String> taskC = CompletableFuture.supplyAsync(() -> "C Complete");

// Waits for all tasks to finish
CompletableFuture<Void> allFutures = CompletableFuture.allOf(taskA, taskB, taskC);

// Block until all are done
allFutures.join();

System.out.println(taskA.join() + ", " + taskB.join() + ", " + taskC.join());
Tags: Java

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.