Blog

Overcoming the Limitations of Future in Java: A Comprehensive Guide

In the realm of Java concurrency, understanding and managing asynchronous operations is crucial for building efficient and responsive applications. The Future interface was a step forward in this direction, allowing developers to work with the results of asynchronous operations. However, as developers started pushing the boundaries of concurrent programming, the limitations of Future became apparent. This blog explores these limitations and delves into modern solutions that Java offers, primarily focusing on CompletableFuture, those that address these constraints effectively.

The Limitations of Future

1. Blocking Nature of get()

One of the primary limitations of Future is the blocking behaviour of its get() method. When you call future.get(), it pauses the current thread until the result is available, which is inefficient and can lead to deadlocks in complex applications.

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class AsyncDataFetcher {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executorService = Executors.newSingleThreadExecutor();
        long start = System.currentTimeMillis();
        Future<Integer> future = executorService.submit(() ->{
            System.out.println("Asynchronous task started.");
            Thread.sleep(4000);
            System.out.println("Asynchronous task completed.");
            return 42;
        });
        System.out.println("Main thread continues to run. It will be blocked soon on future.get().");
        long beforeBlock = System.currentTimeMillis();
        Integer result = future.get();
        long afterBlock = System.currentTimeMillis();
        System.out.println("Got result from asynchronous task: " + result + ". Time: " + (afterBlock - start) + "ms");
        System.out.println("Main thread was blocked for approximately " + (afterBlock - beforeBlock) + "ms.");
        executorService.shutdown();
    }
}

Here is a simplified version of what happens when the program runs:

  1. The main thread initiates an asynchronous task and continues its execution without waiting for the task to complete.
  2. The main thread eventually needs the result of the asynchronous task and waits (blocks) until the task is completed.
  3. Once the task is finished, the main thread resumes, using the result of the asynchronous operation.

Expected Output

When running the program, you’ll observe the following sequence in the console output:

Main thread continues to run. It will be blocked soon on future.get().
Asynchronous task started.

After a 4-second pause (simulating the asynchronous task execution time), the output will continue:

Asynchronous task completed. 
Got result from asynchronous task: 42. Time: 400Xms 
Main thread was blocked for approximately 400Xms.

In this output, 400Xms represents the time in milliseconds, which will be slightly more than 4000ms due to the overhead of task initiation and completion. The exact number varies based on the system and environment running the program

2. Lack of Completion Callbacks

The Future interface doesn’t provide a built-in mechanism to execute a callback method once the computation is complete. This limitation makes it difficult to perform further actions without either blocking the calling thread or periodically checking if the Future is done, which can be inefficient. This means you cannot directly attach a piece of code that runs automatically when the asynchronous task finishes. Instead, you have to either call future.get(), which blocks the current thread until the result is available, or continuously check if the future is done, which can waste resources.

Example without Completion Callback:

Consider a scenario where you submit a task to an ExecutorService to perform some computation, and you want to print a message or notify another part of your system once this task is completed.

ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> {
    // Simulate long-running computation
    Thread.sleep(5000);
    return 123;
});

// To check if the computation is done and get the result, you have to block the thread
try {
    Integer result = future.get(); // Blocks the thread
    System.out.println("Computation finished, result: " + result);
} catch (InterruptedException | ExecutionException e) {
    e.printStackTrace();
}

executor.shutdown();

In this example, the main thread is blocked until the future completes, which is not ideal for achieving true non-blocking behaviour.

3. Inability to Chain Futures

Chaining futures or performing a series of dependent asynchronous operations with Future is not straightforward. You often end up with a sequence of get() calls, each blocking until the previous operation completes, which makes the code harder to read and less efficient.

Example of Cumbersome Future Chaining:

Imagine you have two asynchronous operations where the second depends on the result of the first. With Future, chaining these operations would look something like this:

ExecutorService executor = Executors.newSingleThreadExecutor();

// First asynchronous operation
Future<Integer> firstOperation = executor.submit(() -> {
    Thread.sleep(2000); // Simulate computation
    return 10;
});

// Chaining a second operation dependent on the first
Future<Integer> secondOperation = executor.submit(() -> {
    try {
        Integer firstResult = firstOperation.get(); // Blocks until the first operation is complete
        Thread.sleep(2000); // Simulate another computation
        return firstResult * 2;
    } catch (InterruptedException | ExecutionException e) {
        throw new RuntimeException(e);
    }
});

try {
    System.out.println("Result of second operation: " + secondOperation.get());
} catch (InterruptedException | ExecutionException e) {
    e.printStackTrace();
}

executor.shutdown();

This example demonstrates how chaining futures requires blocking calls, which can lead to inefficient use of resources and complicated error handling.

4. Limited Error Handling

Handling exceptions Future is not straightforward. When an operation executed by a Future throws an exception, it is wrapped in an ExecutionException. Unwrapping these exceptions to handle errors gracefully adds boilerplate code.

5. Single-use Nature

A Future instance is single-use only; once a result is retrieved or an exception is handled, it cannot be reused for another operation. This limitation necessitates creating new Future instances for each asynchronous operation, which can be inefficient.

Solutions

To address these limitations, Java 8 introduced the CompletableFuture class, which supports completion callbacks, easy chaining of asynchronous operations, and combines the features of Future and CompletionStage. It provides methods like thenApply, thenAccept, and thenCompose to facilitate easy chaining and processing of results without blocking, thus offering a more efficient and readable way to handle complex asynchronous logic.

Embracing CompletableFuture

CompletableFuture was introduced in Java 8 to overcome these limitations, offering a rich API that supports non-blocking operations, callbacks, and the composition of multiple futures. Here’s how CompletableFuture to address the limitations of Future:

1. Non-blocking Operations

With CompletableFuture, you can perform non-blocking operations using methods like thenApply, thenAccept, and thenCompose, which allows you to process results, execute actions upon completion, and chain futures without blocking the current thread.

2. Completion Callbacks

CompletableFuture supports completion callbacks through methods like whenComplete and thenRun, enabling you to execute code once the future completes, either successfully or exceptionally, without blocking.

3. Chaining and Combining Futures

CompletableFuture makes it easy to chain multiple futures and combine their results without resorting to blocking calls. Methods like thenCompose and thenCombine facilitate sequencing and aggregating asynchronous operations.

4. Improved Error Handling

CompletableFuture provides better error handling with methods like exceptionally, allowing you to handle exceptions in a concise and readable manner, without having to deal with ExecutionException wrappers.

5. Reusability and Flexibility

Although CompletableFuture instances themselves are not reusable in the same way that a connection pool might be, their ability to chain, combine, and handle errors more flexibly makes them a powerful tool for composing complex asynchronous logic in a reusable manner.

Example: Moving from Future to CompletableFuture

Let’s revisit the initial Future example and transform it using CompletableFuture to demonstrate non-blocking behavior and error handling:

ExecutorService executorService = Executors.newFixedThreadPool(10);

CompletableFuture.supplyAsync(() -> {
    System.out.println("Task starts");
    Thread.sleep(1000); // Simulate long computation
    System.out.println("Task completed");
    return 123;
}, executorService)
.thenAccept(result -> System.out.println("Result: " + result))
.exceptionally(e -> {
    System.out.println("An error occurred: " + e.getMessage());
    return null;
});

In this example, the asynchronous task is executed without blocking the main thread, and upon completion, the result is processed, and any exceptions are handled gracefully.

Scenario: User Dashboard in a Web Application

Imagine a web application for a financial dashboard that aggregates and displays information from various sources: user’s bank transactions, investment portfolio, and real-time stock market data. Fetching this data involves calling multiple external APIs, which can be slow and unpredictable due to network latency or service availability.

Traditional Approach Challenges

Using the traditional Future and synchronous calls might lead to:

  • Blocking I/O Operations: Each API call blocks a thread until it completes, leading to inefficient use of system resources, especially under high load.
  • Complicated Error Handling: Managing errors in a chain of dependent calls becomes cumbersome.
  • Poor User Experience: The user has to wait for all operations to complete before any data is displayed, leading to longer wait times.

CompletableFuture Solution

With CompletableFuture, each data-fetch operation is initiated asynchronously, allowing other tasks to run in parallel without waiting for all operations to complete. Here’s how CompletableFuture can be leveraged:

  1. Asynchronous Operations with Non-blocking I/O: Each API call is made asynchronously, enabling the system to handle other tasks or more user requests. This is crucial for maintaining the responsiveness and scalability of the web application.
  2. Combining Results from Independent Sources: CompletableFuture allows for the combination of results from independent futures using methods like CompletableFuture.allOf(). Once all futures are complete, the dashboard can aggregate the results and display the comprehensive data to the user.
  3. Applying Transformations and Handling Errors Gracefully: You can apply transformations to the results of asynchronous operations (e.g., converting raw data into user-friendly formats) and handle errors without complicating the code, using methods like thenApply(), exceptionally(), etc.

Example Code Snippet

CompletableFuture<BankDetails> bankDetailsFuture = CompletableFuture.supplyAsync(() -> fetchBankDetails(userId));
CompletableFuture<InvestmentPortfolio> portfolioFuture = CompletableFuture.supplyAsync(() -> fetchInvestmentPortfolio(userId));
CompletableFuture<StockMarketData> stockMarketDataFuture = CompletableFuture.supplyAsync(() -> fetchStockMarketData());

CompletableFuture<Void> allFutures = CompletableFuture.allOf(
        bankDetailsFuture,
        portfolioFuture,
        stockMarketDataFuture);

allFutures.thenAccept(v -> {
    try {
        BankDetails bankDetails = bankDetailsFuture.get();
        InvestmentPortfolio portfolio = portfolioFuture.get();
        StockMarketData stockMarketData = stockMarketDataFuture.get();
        // Combine and display the data in the user's dashboard
    } catch (InterruptedException | ExecutionException e) {
        // Handle exceptions
    }
}).exceptionally(ex -> {
    // Handle any exceptions occurred during the fetch operations
    return null;
});

Conclusion

In this scenario, CompletableFuture significantly improves the application’s performance by enabling asynchronous data fetching, efficient resource utilization, and a more responsive user interface. By facilitating non-blocking operations, easy result combination, and straightforward error handling, CompletableFuture addresses the real-world needs of modern, high-performance web applications.

Conclusion

While Future laid the groundwork for asynchronous programming in Java, CompletableFuture significantly enhances this model by addressing its limitations. By offering non-blocking operations, completion callbacks, and improved error handling, CompletableFuture enables more efficient and readable concurrent code. As developers continue to explore the possibilities of concurrent and asynchronous programming, understanding and leveraging the capabilities of CompletableFuture will be essential for building robust, scalable Java applications.

Avatar

Neelabh

About Author

As Neelabh Singh, I am a Senior Software Engineer with 6.6 years of experience, specializing in Java technologies, Microservices, AWS, Algorithms, and Data Structures. I am also a technology blogger and an active participant in several online coding communities.

You may also like

Blog Design Pattern

Understanding the Builder Design Pattern in Java | Creational Design Patterns | CodeTechSummit

Overview The Builder design pattern is a creational pattern used to construct a complex object step by step. It separates
Blog Tech Toolkit

Base64 Decode

Base64 encoding is a technique used to encode binary data into ASCII characters, making it easier to transmit data over