Learnitweb

CompletableFuture in Java

Introduction to CompletableFuture

CompletableFuture is a class in the java.util.concurrent package that extends the Future interface and implements the CompletionStage interface. It represents a future result of an asynchronous computation and allows you to write non-blocking, callback-based, and event-driven code.

The key features that make CompletableFuture powerful include:

  1. Ability to run tasks asynchronously without blocking the main thread.
  2. Support for chaining multiple tasks together (dependent completion).
  3. Built-in support for handling success and failure.
  4. Ability to combine multiple asynchronous tasks.
  5. Support for both blocking (get()) and non-blocking (callback) result retrieval.

Why CompletableFuture?

Before Java 8, asynchronous programming in Java was limited to the Future interface. However, Future has several limitations:

  1. It is blocking. You have to call get() and wait for the result.
  2. It does not support chaining or dependent tasks.
  3. It does not provide a simple way to handle exceptions or compose multiple futures.

CompletableFuture solves these problems by offering a rich, fluent API for writing asynchronous, event-driven code in a clean and readable way.

Creating a CompletableFuture

There are several ways to create a CompletableFuture:

Using supplyAsync()
This method runs a task asynchronously and returns a CompletableFuture that will complete with the result.

    CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello");
    

    Using runAsync()
    This method runs a task asynchronously that doesn’t return a result (i.e., Runnable).

    CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
        System.out.println("Running a background task");
    });
    

    Creating an incomplete future and completing it later
    You can create a future using the constructor and complete it manually using the complete() method.

    CompletableFuture<String> future = new CompletableFuture<>();
    future.complete("Manually completed");
    

    This is useful when you’re working with callback-based APIs and need to integrate them with CompletableFuture.

    Running Tasks Asynchronously

    There are two primary static methods to start asynchronous tasks:

    supplyAsync(Supplier<U>)
    Runs a task asynchronously that returns a value.

    CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
        return 10 + 20;
    });
    

      runAsync(Runnable)
      Runs a task asynchronously that doesn’t return a result.

      CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
          System.out.println("Running task");
      });
      

      Both methods can also take an Executor as a second parameter if you want to control the thread pool.

      Chaining Tasks

      One of the most powerful features of CompletableFuture is the ability to chain tasks. You can run another task after the first one completes using methods like:

      thenApply()
      Transforms the result and returns a new CompletableFuture.

        CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 5)
            .thenApply(result -> result * 2); // returns 10
        

        thenAccept()
        Consumes the result and returns a CompletableFuture<Void>.

        CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> "Hello")
            .thenAccept(msg -> System.out.println(msg));
        

        thenRun()
        Runs a task after the previous one but does not consume the result.

        CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> "Hello")
            .thenRun(() -> System.out.println("Task completed"));
        

        Combining Multiple CompletableFutures

        CompletableFuture provides methods to combine multiple futures:

        thenCombine()
        Combines two futures and uses their results together.

        CompletableFuture<Integer> f1 = CompletableFuture.supplyAsync(() -> 10);
        CompletableFuture<Integer> f2 = CompletableFuture.supplyAsync(() -> 20);
        
        CompletableFuture<Integer> result = f1.thenCombine(f2, (a, b) -> a + b); // returns 30
        

          thenCompose()
          Flattens nested CompletableFuture by combining them sequentially.

          CompletableFuture<String> fetchUser = CompletableFuture.supplyAsync(() -> "user123");
          
          CompletableFuture<String> userDetails = fetchUser.thenCompose(userId ->
              CompletableFuture.supplyAsync(() -> "Details for " + userId)
          );
          

          allOf()
          Waits for all given futures to complete.

          CompletableFuture<Void> all = CompletableFuture.allOf(future1, future2, future3);
          

          After allOf() completes, you can retrieve individual results by calling get() on each original future.

          anyOf()
          Completes when any one of the given futures completes.

          CompletableFuture<Object> any = CompletableFuture.anyOf(future1, future2, future3);
          

          Blocking and Non-Blocking Result Retrieval

          get()
          Blocks the current thread until the future completes.

          String result = future.get();
          

            join()
            Similar to get() but throws unchecked exceptions (RuntimeException). Useful in streams or when you want to avoid checked exceptions.

            String result = future.join();
            

            Exception Handling in CompletableFuture

            CompletableFuture provides powerful ways to handle exceptions that occur during asynchronous processing.

            There are three main methods used for exception handling:

            exceptionally(Function<Throwable, T>)
            Catches exceptions and returns a fallback result.

            CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
                if (true) throw new RuntimeException("Something went wrong");
                return "Success";
            }).exceptionally(ex -> {
                System.out.println("Exception: " + ex.getMessage());
                return "Fallback result";
            });
            

              handle(BiFunction<T, Throwable, R>)
              Handles both success and failure in the same place. The Throwable is null if the task succeeded.

              CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
                  if (true) throw new RuntimeException("Error");
                  return "Success";
              }).handle((result, ex) -> {
                  if (ex != null) {
                      return "Recovered from: " + ex.getMessage();
                  }
                  return result;
              });
              

              whenComplete(BiConsumer<T, Throwable>)
              Executes after the computation completes, regardless of outcome, and does not modify the result.

              CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
                  throw new RuntimeException("Boom");
              }).whenComplete((result, ex) -> {
                  if (ex != null) {
                      System.out.println("Error occurred: " + ex.getMessage());
                  }
              });
              

              These methods help you build resilient applications by allowing graceful recovery from errors.

              Real-World Use Case Example

              Imagine you are building a travel booking system that fetches flights and hotel availability in parallel and then combines both to make an offer.

              CompletableFuture<String> flightFuture = CompletableFuture.supplyAsync(() -> {
                  sleep(2000);
                  return "Flight Available";
              });
              
              CompletableFuture<String> hotelFuture = CompletableFuture.supplyAsync(() -> {
                  sleep(3000);
                  return "Hotel Available";
              });
              
              CompletableFuture<String> combined = flightFuture.thenCombine(hotelFuture, (flight, hotel) ->
                  flight + " and " + hotel
              );
              
              System.out.println(combined.join());
              

              This allows you to run both operations in parallel and wait for both to finish before combining the results.

              Best Practices

              1. Always prefer thenApply, thenAccept, or thenRun over manually blocking with get() or join().
              2. Use handle or exceptionally to provide fallback logic for failures.
              3. Use custom ExecutorService if you want to control threading behavior.
              4. Use allOf or anyOf when working with collections of asynchronous tasks.
              5. Avoid excessive chaining that makes code hard to read. Break down long chains into readable blocks.