The Power of Java CompletableFuture in Asynchronous Programming

In modern software development, asynchronous programming has become a crucial technique to improve the performance and responsiveness of applications. Java, a widely - used programming language, offers the CompletableFuture class as a powerful tool for asynchronous programming. CompletableFuture provides a rich set of APIs that allow developers to write asynchronous, non - blocking code easily, enabling applications to handle multiple tasks concurrently without waiting for each task to complete sequentially. This blog will explore the fundamental concepts, usage methods, common practices, and best practices of CompletableFuture in Java.

Table of Contents

  1. Fundamental Concepts
  2. Usage Methods
    1. Creating a CompletableFuture
    2. Chaining Operations
    3. Combining CompletableFutures
  3. Common Practices
    1. Parallel Task Execution
    2. Error Handling
  4. Best Practices 5. Conclusion 6. References

Fundamental Concepts

A CompletableFuture in Java represents a future result of an asynchronous computation. It can be thought of as a container that will eventually hold a value when the computation is completed. The key idea behind CompletableFuture is that it allows you to perform operations on the result once it becomes available, without blocking the current thread.

Asynchronous operations are executed in a separate thread, typically from a thread pool. This enables the main thread to continue executing other tasks while waiting for the asynchronous operation to finish. CompletableFuture also supports chaining of operations, which means you can define a sequence of actions to be performed on the result as it becomes available.

Usage Methods

Creating a CompletableFuture

There are several ways to create a CompletableFuture:

Using CompletableFuture.runAsync()

This method is used when you have a task that does not return a result. It takes a Runnable as an argument.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CompletableFutureExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
            try {
                Thread.sleep(2000);
                System.out.println("Task completed");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, executor);
        executor.shutdown();
    }
}

Using CompletableFuture.supplyAsync()

This method is used when your task returns a result. It takes a Supplier as an argument.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CompletableFutureSupplyExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            try {
                Thread.sleep(2000);
                return "Result from asynchronous task";
            } catch (InterruptedException e) {
                e.printStackTrace();
                return null;
            }
        }, executor);
        executor.shutdown();
    }
}

Chaining Operations

You can chain operations on a CompletableFuture using methods like thenApply(), thenAccept(), and thenRun().

thenApply()

This method takes a Function and applies it to the result of the CompletableFuture when it becomes available.

import java.util.concurrent.CompletableFuture;

public class ThenApplyExample {
    public static void main(String[] args) {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello")
               .thenApply(s -> s + " World");
        future.thenAccept(System.out::println);
    }
}

thenAccept()

This method takes a Consumer and consumes the result of the CompletableFuture when it becomes available.

import java.util.concurrent.CompletableFuture;

public class ThenAcceptExample {
    public static void main(String[] args) {
        CompletableFuture.supplyAsync(() -> "Hello")
               .thenAccept(s -> System.out.println("Received: " + s));
    }
}

thenRun()

This method takes a Runnable and runs it when the CompletableFuture is completed.

import java.util.concurrent.CompletableFuture;

public class ThenRunExample {
    public static void main(String[] args) {
        CompletableFuture.supplyAsync(() -> "Hello")
               .thenRun(() -> System.out.println("Task finished"));
    }
}

Combining CompletableFutures

You can combine multiple CompletableFutures using methods like thenCombine() and thenCompose().

thenCombine()

This method combines the results of two CompletableFutures using a BiFunction.

import java.util.concurrent.CompletableFuture;

public class ThenCombineExample {
    public static void main(String[] args) {
        CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Hello");
        CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> " World");
        CompletableFuture<String> combinedFuture = future1.thenCombine(future2, (s1, s2) -> s1 + s2);
        combinedFuture.thenAccept(System.out::println);
    }
}

thenCompose()

This method is used when you have a CompletableFuture that returns another CompletableFuture.

import java.util.concurrent.CompletableFuture;

public class ThenComposeExample {
    public static void main(String[] args) {
        CompletableFuture<CompletableFuture<String>> nestedFuture = CompletableFuture.supplyAsync(() -> {
            return CompletableFuture.supplyAsync(() -> "Hello World");
        });
        CompletableFuture<String> flatFuture = nestedFuture.thenCompose(f -> f);
        flatFuture.thenAccept(System.out::println);
    }
}

Common Practices

Parallel Task Execution

You can use CompletableFuture to execute multiple tasks in parallel and wait for all of them to complete.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ParallelTaskExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(2);
        CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
            try {
                Thread.sleep(2000);
                return "Result 1";
            } catch (InterruptedException e) {
                e.printStackTrace();
                return null;
            }
        }, executor);
        CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
            try {
                Thread.sleep(3000);
                return "Result 2";
            } catch (InterruptedException e) {
                e.printStackTrace();
                return null;
            }
        }, executor);
        CompletableFuture<Void> allFutures = CompletableFuture.allOf(future1, future2);
        CompletableFuture<String> combinedResult = allFutures.thenApply(v -> {
            return future1.join() + " " + future2.join();
        });
        combinedResult.thenAccept(System.out::println);
        executor.shutdown();
    }
}

Error Handling

You can handle errors in a CompletableFuture using the exceptionally() method.

import java.util.concurrent.CompletableFuture;

public class ErrorHandlingExample {
    public static void main(String[] args) {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            throw new RuntimeException("Error occurred");
        }).exceptionally(ex -> {
            System.out.println("Caught exception: " + ex.getMessage());
            return "Default value";
        });
        future.thenAccept(System.out::println);
    }
}

Best Practices

  • Use Custom Thread Pools: When using CompletableFuture, it is recommended to use custom thread pools instead of relying on the common fork - join pool. This gives you more control over the number of threads and their behavior.
  • Proper Error Handling: Always handle exceptions in your CompletableFuture chains to prevent unexpected failures. Use methods like exceptionally(), handle(), and whenComplete() for error handling.
  • Limit Nested Futures: Avoid creating deeply nested CompletableFutures as it can make the code hard to read and maintain. Use thenCompose() to flatten nested futures.
  • Close Thread Pools: If you are using a custom thread pool, make sure to shut it down properly to release system resources.

Conclusion

Java’s CompletableFuture is a powerful and flexible tool for asynchronous programming. It provides a wide range of APIs that allow developers to write complex asynchronous code easily. By understanding the fundamental concepts, usage methods, common practices, and best practices, you can leverage the full potential of CompletableFuture to improve the performance and responsiveness of your Java applications.

References