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.
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.
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.
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 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.
Let's consider a scenario where we need to perform a series of dependent and independent asynchronous operations:
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:
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
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:
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>.
Here's an example that demonstrates using runAsync to execute a simple asynchronous task:
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.
Example usage
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.
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():
CompletableFuture when it completes.
thenAccept is synchronous, while thenAcceptAsync is asynchronous.
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.
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.
Check out our vacancies here.