Article

    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 Nortal 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.

    undefined-1

    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 synchronization 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 utilizes the Task class and async and await keywords, making asynchronous programming more straightforward and cleaner. An async method returns a Task or Task , which represents ongoing work. C#, similar to Java, utilizes 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.

    undefined

    JavaScript handles asynchronous operations through Promises and the async/await syntax, which fits 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.

    undefined-2

    Understanding CompletableFuture

    01 Java's future interface
    02 Java's CompletableFuture class
    03 Example scenario with CompletableFuture

    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.

    undefined-3

    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.

    undefined-4

    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.

    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:

    undefined-May-20-2024-11-58-22-2633-AM

    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:

    undefined-May-20-2024-11-59-23-9864-AM

    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:

    undefined-May-20-2024-12-00-26-5368-PM

    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:

    undefined-May-20-2024-12-00-41-7965-PM

    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:

    undefined-May-20-2024-12-01-07-4307-PM

    Using CompletableFutures effectively

    Most important methods 

    1. supplyAsync

    2. runAsync

    3. get()

    4. join()

    5. thenApply(Function fn)

    6. thenApplyAsync(Function fn)

    7. thenCombine(CompletionStage other, BiFunction fn)

    8. thenCombineAsync(CompletionStage other, BiFunction T,? super V,? extends U> fn)

    9. thenAccept and thenAcceptAsync

    10. thenRun and thenRunAsync

    11. exceptionally(Function <throwable,? extends="" t=""> fn) </throwable,?>

    12. handle and handleAsync

    Summary

    Counter-intuitive behaviors 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 behaviors, 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 behavior 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 exception handler 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 behaviors and common pitfalls. Proper usage patterns, consistent error handling, and effective debugging strategies are essential tf rharnessing the full power of CompletableFuture without introducing subtle bugs or 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.

    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.

    • 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.
    • 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.
    • 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.
    • 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.
    careers_page_v1
    careers_page_v1
    careers_page_v1
    careers_page_v1
    careers_page_v1

    Interested in joining Nortal?

    Check out our vacancies here.

    Get in touch

    Nortal is a strategic innovation and technology company with an unparalleled track-record of delivering successful transformation projects over 20 years.