Articles Java News UK Technology

Async Programming and CompletableFuture in Java

Published on May 23, 2024
Tech Insights_Ivan_Mihov Async Programming and CompletableFuture in Java

Today is an important day in the tech world as we celebrate the anniversary of the first release of Java. For the past 29 years, this programming language has profoundly influenced the software development landscape. Java's versatility has enabled developers to create a wide range of applications, including web, mobile, desktop, and big data apps. 

To mark the occasion, we invited a special guest from the News UK team at Pwrteams Bulgaria – Ivan Mihov, Senior Java Engineer. He has a strong record of accomplishments in a dynamic international environment, consulting customers and designing highly scalable systems.  

In this article, Ivan will share insights about Java and will provide an enlightening overview of the CompletableFuture class, demonstrating its capabilities, best practices and addressing the common pitfalls. 

Introduction

What is asynchronous programming? 

Let's delve into an analogy of asynchronous programming: Imagine waking up in the morning and starting to boil some eggs for breakfast. Instead of waiting idly for them to cook, you start another task, such as making bacon and toast. In this scenario, you're akin to a thread executing I/O operations - you initiate them and let them run independently. Both tasks are non-blocking for you; while the food cooks, you're free to sit at the table, enjoy some YouTube videos, or even take a shower. Once any task is completed (for example, the bacon is ready), you handle it by placing it on your plate. Similarly, when the eggs are done, you take them from the water and peel them. 

Consider another real-world example of asynchronous operations: a waiter at a restaurant taking orders. Upon receiving an order from a customer, he relays it to the kitchen for preparation. Rather than waiting beside the kitchen counter for the dish to be ready - an approach similar to a Java application operating on a single thread,  the waiter moves on to attend to another customer's order. As soon as a meal is prepared, he retrieves it from the kitchen and serves it to the respective customer. 

This process mirrors how an application thread behaves during an API call, initiating a time-consuming operation on an external system or executing a complex database query that takes several seconds or more. 

Real-life scenarios also require exception handling, akin to software development. For instance, if the waiter takes an order for pasta Bolognese but finds out that the kitchen has run out of beef, it poses a resource synchronisation issue typical in asynchronous operations. 

Modern web applications, particularly those hosted in cloud environments, need to accommodate thousands of simultaneous users. This scalability is achieved not only through service replication, such as pod replication in EKS, but also by making efficient use of threads within each pod at the application level. Asynchronous operations facilitate non-blocking I/O, leading to more efficient use of resources. 

Async vs parallel operations: Asynchronous programming focuses on non-blocking tasks, while parallel programming involves executing multiple computations simultaneously, leveraging multi-core processors. Both approaches improve performance but are used for different purposes. 

Async programming in other languages 

C# in the .NET framework utilises the Task<T> class and async and await keywords, making asynchronous programming more straightforward and cleaner. An async method returns a Task or Task<T>, which represents ongoing work. C#, similar to Java, utilises a thread pool for executing asynchronous tasks. When an async method awaits an asynchronous operation, the current thread is returned to the thread pool until the awaited operation completes. 

JavaScript handles asynchronous operations through Promises and async/await syntax, fitting its event-driven nature. A Promise represents an operation that hasn't been completed yet but is expected in the future. JavaScript, particularly in the Node.js environment, operates on a single-threaded event loop for handling asynchronous operations. 

Understanding CompletableFuture 

Java's future interface 

The Future interface in Java represents the result of an asynchronous computation. Tasks executed in a separate thread can return a Future object, which can be used to check if the computation is complete, wait for its completion, and retrieve the result. 

Limitations: The main limitation of the Future interface is its lack of ability to manually complete the computation, combine multiple futures, or chain actions that rely upon the future's completion. These operations either block or require additional mechanisms to handle, making the Future interface less flexible compared to CompletableFuture. 

 

 

Java's CompletableFuture class 

Java's CompletableFuture class was introduced in Java 8. CompletableFuture is part of Java's java.util.concurrent package and provides a way to write asynchronous code by representing a future result that will eventually appear. It lets us perform operations like calculation, transformation, and action on the result without blocking the main thread. This approach helps in writing non-blocking code where the computation can be completed by a different thread at a later time. 

CompletableFuture and the broader Java Concurrency API make use of thread pools (like the ForkJoinPool) for executing asynchronous operations. This allows Java applications to handle multiple asynchronous tasks efficiently by leveraging multiple threads. 

 

In Java, when a CompletableFuture operation is waiting on a dependent future or an asynchronous computation, it doesn't block the waiting thread. Instead, the completion of the operation triggers the execution of dependent stages in the CompletableFuture chain, potentially on a different thread from the thread pool. 

Example scenario with CompletableFuture 

Let's consider a scenario where we need to perform a series of dependent and independent asynchronous operations: 

  1. Fetch user details: Given a userID, we first retrieve the user's details asynchronously. 
  2. Fetch credit score: Once we have the user's details, we fetch their credit score. 
  3. Calculate account balance: Independently, we also calculate the user's account balance from a different source. 
  4. Make a decision: Finally, we combine the credit score and account balance to make a financial decision. 
  5. Handle potential errors 

Step 1: Fetching user details asynchronously 

We start by simulating an asynchronous operation to fetch user details using supplyAsync. This returns a CompletableFuture that will complete with the user details: 

Step 2: Transforming and fetching credit score 

Next, we use thenApply to transform the result (e.g., formatting user details) and thenCompose to fetch the credit score, demonstrating the chaining of asynchronous operations: 

thenApply is for synchronous transformations, while thenCompose allows for chaining another asynchronous operation that returns a CompletableFuture. 

Step 3: Calculating account balance in parallel 

We calculate the account balance using another asynchronous operation, showcasing how independent futures can run in parallel: 

Step 4: Combining results and making a decision. 

With thenCombine we merge the results of two independent CompletableFuture - credit score and account balance - to make a decision:  

Step 5: Error handling 

Error handling is crucial in asynchronous programming. We use exceptionally to handle any exceptions that may occur during the asynchronous computations, providing a way to recover or log errors: 

Using CompletableFutures effectively 

Most important methods 

1. supplyAsync 

The supplyAsync method is part of the CompletableFuture class introduced in Java 8, residing in the java.util.concurrent package. It's designed to run a piece of code asynchronously and return a CompletableFuture that will be completed with the value obtained from that code. Essentially, it allows you to execute a Supplier<T> asynchronously, where T is the type of value returned by the Supplier. 

syntax and usage

  • U: The type of value obtained from the Supplier<U> 
  • supplier: A Supplier<U> that provides the value to be used in the completion of the returned CompletableFuture<U> 

Here's a simple example: 

When you invoke supplyAsync, it executes the given Supplier asynchronously (usually in a different thread). The method immediately returns a CompletableFuture object. This CompletableFuture will be completed in the future when the Supplier finishes its execution, with the result being the value provided by the Supplier. 

It allows the main thread to continue its operations without waiting for the task to be completed. This is particularly useful in web applications or any I/O-bound applications where you don't want to block the current thread. 

By default, tasks submitted via supplyAsync without specifying an executor are executed in the common fork-join pool (ForkJoinPool.commonPool()). However, you can also specify a custom Executor if you need more control over the execution environment: 

2. runAsync 

CompletableFuture.runAsync is akin to CompletableFuture.supplyAsync but for scenarios where you don't need to return a value from the asynchronous operation. Both methods are intended for executing tasks asynchronously, but they differ in their return types and the type of tasks they're suited for. 

runAsync is used to execute a Runnable task asynchronously, which does not return a result. Since Runnable does not produce a return value, runAsync returns a CompletableFuture<Void>. 

  • Asynchronous execution: Executes the given Runnable task in a separate thread, allowing the calling thread to proceed without waiting for the task to complete. 
  • No return value: Suitable for asynchronous tasks that perform actions without needing to return a result, such as logging, sending notifications, or other side effects. 
  • Custom executor support: Allows specifying a custom Executor for more control over task execution, such as using a dedicated thread pool. 

Here's an example that demonstrates using runAsync to execute a simple asynchronous task: 

3. get() 

The get() method blocks the current thread until the CompletableFuture completes, either normally or exceptionally. Once the future completes, get() returns the result of the computation if it completed normally, or throws an exception if the computation completed exceptionally. 

  1. Blocking behaviour: Like join() ,get() is a blocking call. It makes the caller thread wait until the CompletableFuture's task is completed.
  2. Checked exceptions: get() can throw checked exceptions, including:
    1. InterruptedException: If the current thread was interrupted while waiting. 
    2. ExecutionException: If the computation threw an exception. This exception wraps the actual exception thrown by the computation, which can be obtained by calling getCause() on the ExecutionException. 
  3. Timeout: The overloaded version of get(long timeout, TimeUnit unit) allows specifying a maximum wait time. If the timeout is reached before the future completes, it throws a TimeoutException, providing a mechanism to avoid indefinite blocking. 
  4. Use case: Use get() when you need to handle checked exceptions explicitly, or when you need to retrieve the result of the computation within a certain timeframe. 

Example usage

 

4. join() 

The join method on a CompletableFuture is a blocking call that causes the current thread to wait until the CompletableFuture is completed. During this waiting period, the current thread is inactive, essentially "joining" the completion of the task represented by the CompletableFuture. 

  • Blocking behaviour: join() blocks until the CompletableFuture upon which it is called completes, either normally or exceptionally. It makes the asynchronous operation synchronous for the calling thread, as the thread will not proceed until the future is completed. 
  • Exception handling: Unlike get(), which throws checked exceptions (such as InterruptedException and ExecutionException), join() is designed to throw an unchecked exception (CompletionException) if the CompletableFuture completes exceptionally. This can simplify error handling in certain contexts where checked exceptions are undesirable. 
  • Usage: It's typically used when you need to synchronise asynchronous computation at some point, for example, when the result of the asynchronous computation is required for subsequent operations, or at the end of a program to ensure that all asynchronous tasks have completed. 

Example scenario 

If you have a main application thread that kicks off an asynchronous task using CompletableFuture.runAsync() or CompletableFuture.supplyAsync(), and later in the program you need the result of that task or need to ensure that the task has completed before proceeding, you might call join(): 

5. thenApply(Function<? super T,? extends U> fn) 
  • Purpose: Applies a synchronous transformation function to the result of the 

CompletableFuture when it completes. 

  • Behaviour: Executes on the same thread that completed the previous stage, or in the thread that calls get() or join() if the future has already completed. 
  • Return type: CompletableFuture<U> where U is the type returned by the function. 

6. thenApplyAsync(Function<? super T,? extends U> fn) 
  • Purpose: Similar to thenApply, but the transformation function is executed asynchronously, typically using the default executor. 
  • Behaviour: Can execute the function in a different thread, providing better responsiveness and throughput for tasks that are CPU-intensive or involve blocking. 
  • Return type: CompletableFuture<U> 

7. thenCombine(CompletionStage<? extends V> other, BiFunction<? super T,? super V,? extends U> fn) 
  • Purpose: Combines the result of this CompletableFuture with another asynchronously computed value. The combination is done when both futures complete. 
  • Behaviour: The BiFunction provided is executed synchronously, using the thread that completes the second future. 
  • Return type: CompletableFuture<U> 

8. thenCombineAsync(CompletionStage<? extends V> other, BiFunction<? super T,? super V,? extends U> fn) 
  • Purpose: Similar to thenCombine, but the BiFunction is executed asynchronously. 
  • Behaviour: Useful when the combination function is computationally expensive or involves blocking. 
  • Return type: CompletableFuture<U> 

9. thenAccept and thenAcceptAsync 
  • Purpose: Consumes the result of the CompletableFuture without returning a result. 

thenAccept is synchronous, while thenAcceptAsync is asynchronous. 

  • Use case: Useful for executing side-effects, such as logging or updating a user interface, with the result of the CompletableFuture. 

10. thenRun and thenRunAsync 
  • Purpose: Executes a Runnable action when the CompletableFuture completes, without using the result of the future. thenRun is synchronous, while thenRunAsync is asynchronous. 
  • Use case: Useful for triggering actions that do not depend on the future's result, such as signaling completion or cleaning up resources. 

11. exceptionally(Function<Throwable,? extends T> fn) 
  • Purpose: Handles exceptions arising from the CompletableFuture computation, allowing for a fallback value to be provided or a new exception to be thrown. 
  • Use case: Essential for robust error handling in asynchronous programming, allowing for recovery or logging of failures. 

12. handle and handleAsync 
  • Purpose: Applies a function to the result or exception of the CompletableFuture. The synchronous variant is handle, and the asynchronous variant is handleAsync. 
  • Use case: Useful when you need to process the result of a computation regardless of its success or failure, such as optionally transforming the result or providing a default in case of an exception. 

Summary 
  • Synchronous vs. Asynchronous: For each operation (thenApply, thenAccept, thenRun, thenCombine) there's an asynchronous variant (thenApplyAsync, thenAcceptAsync, thenRunAsync, thenCombineAsync) that can execute its task in a separate thread, making it suitable for longer-running operations. 
  • Chaining computations: These methods enable chaining multiple stages of computation, transforming results, and combining the outcomes of independent computations in a fluent and readable manner. 
  • Error handling: Methods like exceptionally and handle provide mechanisms for dealing with errors that may occur during the asynchronous computations, ensuring resilience and robustness in asynchronous logic. 

Counter-intuitive behaviours and how to address them 

The CompletableFuture API in Java is a powerful mechanism for managing asynchronous operations. However, its flexibility can sometimes lead to counterintuitive behaviours, subtle bugs, and performance issues. Understanding these aspects is crucial for developers to effectively use and debug CompletableFuture. Let's dive into each point. 

Misuse of CompletableFuture leading to subtle bugs and performance issues 
  • Blocking calls inside CompletableFuture: Using get() or join() within a CompletableFuture's chain can block the asynchronous execution, negating the benefits of non-blocking code.
    Solution: Replace blocking calls with non-blocking constructs like thenCompose for chaining futures or thenAccept for handling results. 
  • Ignoring returned futures: Not handling the CompletableFuture returned by methods like thenApplyAsync can lead to unobserved exceptions and behaviour that does not execute as expected.
    Solution: Always chain subsequent operations or attach error handling (e.g., exceptionally or handle) to every CompletableFuture. 
Debugging Challenges in Asynchronous Code 
  • Stack traces lack context: Exceptions in asynchronous code can have stack traces that don't easily lead back to the point where the async operation was initiated.  
  • Strategies: 
    • Use handle or exceptionally to catch exceptions within the future chain and add logging or breakpoints.
    • Consider wrapping asynchronous operations in higher-level methods that catch and log exceptions, providing more context. 
Strategies to identify and fix common issues 
  • Consistent error handling: Attach an exceptionally or handle stage to each CompletableFuture to manage exceptions explicitly. 
  • Avoid common pitfalls: For example - executing long-running or blocking operations in supplyAsync without specifying a custom executor. This can lead to saturation of the common fork-join pool. 
    Solution: Use a custom executor for CPU-bound tasks to prevent interference with the global common fork-join pool. 
  • Debugging Asynchronous Chains: Break down complex chains of CompletableFuture operations into smaller parts. Test each part separately to isolate issues. 
Tools and techniques for debugging CompletableFuture chains 
  • Logging: Insert logging statements within completion stages (e.g., after thenApply, thenAccept) to trace execution flow and data transformation. 
  • Visual debugging tools: Some IDEs and tools offer visual representations of CompletableFuture chains, which can help in understanding the flow and identifying where the execution might be hanging or failing. 
  • Custom executors for monitoring: Use custom executors wrapped with logging or monitoring to track task execution and thread usage. This is particularly useful for identifying tasks that run longer than expected. 
  • Async profiling: Tools like async-profiler can help identify hotspots and thread activity specific to asynchronous operations. 
Summary 

While CompletableFuture provides a robust framework for asynchronous programming in Java, developers need to be mindful of its counterintuitive behaviours and common pitfalls. Proper usage patterns, consistent error handling, and effective debugging strategies are essential to harness the full power of CompletableFuture without introducing subtle bugs and performance issues. Adopting these practices early can save significant time and effort in debugging and maintaining asynchronous Java applications. 

Check out the CompleteFuture demo project here.

Interested in joining Pwrteams? 

Check out our vacancies here 

Let's build your
expert team!

Share your details in the form, tell us about your needs, and we'll get back with the next steps.

  • Build a stable team with a 95.7% retention rate.
  • Boost project agility and scalability with quality intact.
  • Forget lock-ins, exit fees, or volume commitments.

Trusted by:
and 70+ more