Java Multithreading | Part 6

Published on July 23, 2024 6 min read

Tags:
Java

Exploring Java's Future and CompletableFuture for Multithreading

In this post, we'll dive into Java's Future and CompletableFuture classes, exploring how they can be used to manage asynchronous tasks in a multithreaded environment.

Using Future in Java for Asynchronous Computation

The Future interface in Java allows us to write asynchronous programs, where tasks can be executed in separate threads. Here's an overview of using Future with Runnable and Callable tasks, along with key methods and outputs.

Example Code

package multithreading;
 
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
 
public class FutureExample {
 
    public static void main(String[] args) {
        ExecutorService taskExecutor = Executors.newFixedThreadPool(2);
 
        // Runnable task without a return value
        Future<?> futureObj = taskExecutor.submit(() -> {
            System.out.println("Runnable task Executed by: " + Thread.currentThread().getName());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
 
        try {
            Object returnValue = futureObj.get(); // Blocking call
            System.out.println("Return value should be null: " + (returnValue == null));
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
 
        System.out.println("Methods in Future Interface");
        System.out.println("isCancelled() ==> " + futureObj.isCancelled());
        System.out.println("isDone() ==> " + futureObj.isDone());
 
        // Runnable task with a return object
        List<Integer> inp = new ArrayList<>();
        Future<List<Integer>> futureObj2 = taskExecutor.submit(() -> {
            System.out.println("Another runnable task Executed by: " + Thread.currentThread().getName());
            inp.add(100);
            System.out.println("Runnable task with a return object");
        }, inp);
 
        try {
            List<Integer> returnValue = futureObj2.get();
            System.out.println("Input List: " + inp);
            System.out.println("Input List -- : " + returnValue);
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
 
        // Callable task with a return value
        Future<List<Integer>> futureObj3 = taskExecutor.submit(() -> {
            List<Integer> out = new ArrayList<>();
            System.out.println("Another callable task Executed by: " + Thread.currentThread().getName());
            out.add(500);
            System.out.println("Callable task with a return object");
            return out;
        });
 
        try {
            List<Integer> returnValue2 = futureObj3.get();
            System.out.println("Output List -- : " + returnValue2);
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
 
        taskExecutor.shutdown();
    }
}

Key Points

  1. Runnable Task:

    • Executes asynchronously with no return value.
    • The Future object can still provide status information (e.g., isCancelled(), isDone()).

    Output:

    Runnable task Executed by: pool-1-thread-1
    Return value should be null: true
    Methods in Future Interface
    isCancelled() ==> false
    isDone() ==> true
    
  2. Runnable Task with Return Object:

    • Allows passing a result object alongside task execution.

    Output:

    Another runnable task Executed by: pool-1-thread-2
    Runnable task with a return object
    Input List: [100]
    Input List -- : [100]
    
  3. Callable Task:

    • Executes asynchronously and returns a result.
    • Suitable for tasks that compute a value and may throw exceptions.

    Output:

    Another callable task Executed by: pool-1-thread-1
    Callable task with a return object
    Output List -- : [500]
    

Runnable vs. Callable in Java: A Quick Comparison

The Runnable Interface

  • Method: run()
  • Return Type: void
  • Checked Exceptions: Cannot throw
  • Usage: Simple tasks without a return value

Example

Runnable runnableTask = () -> {
    System.out.println("Runnable executed by: " + Thread.currentThread().getName());
};
new Thread(runnableTask).start();

Output:

Runnable executed by: main

The Callable Interface

  • Method: call()
  • Return Type: Generic (e.g., Integer)
  • Checked Exceptions: Can throw
  • Usage: Tasks that return a result or may throw exceptions

Example

Callable<Integer> callableTask = () -> {
    System.out.println("Callable executed by: " + Thread.currentThread().getName());
    return 42;
};
 
Future<Integer> future = Executors.newSingleThreadExecutor().submit(callableTask);
Integer result = future.get();
System.out.println("Callable result: " + result);

Output:

Callable executed by: pool-1-thread-1
Callable result: 42

Key Differences

  1. Return Values:

    • Runnable: No return value.
    • Callable: Returns a value.
  2. Exception Handling:

    • Runnable: Cannot throw checked exceptions.
    • Callable: Can throw checked exceptions.
  3. Use Cases:

    • Runnable: For simple tasks without a need for a return value.
    • Callable: For tasks requiring a result or exception handling.

Choose Runnable for simple, non-returning tasks and Callable when you need a result or need to handle exceptions.


Using CompletableFuture in Java for Asynchronous Computation

CompletableFuture is a more flexible and powerful class that supports both manually completed and chained asynchronous computations.

Simple Asynchronous Task

With CompletableFuture, tasks can be run asynchronously using supplyAsync():

CompletableFuture<String> asyncTask1 = CompletableFuture.supplyAsync(() -> {
    return "Task completed by: " + Thread.currentThread().getName();
}, taskExecutor);

If no executor is passed, the default ForkJoinPool is used.

  • Advantages: Automatically uses the ForkJoinPool if no executor is provided, simplifying resource management.
  • Limitations: Can lead to unexpected thread management behaviors if not carefully controlled.

Output:

Task completed by: pool-1-thread-2

Chaining with thenApply and thenApplyAsync

You can chain further actions using methods like thenApply() and thenApplyAsync(). The latter executes the next stage asynchronously:

CompletableFuture<String> asyncTask2 = asyncTask1.thenApply(val -> {
    return val + " processed by thenApply on thread: " + Thread.currentThread().getName();
});
  • thenApply: Executes the function in the same thread that completes the previous stage.
  • thenApplyAsync: Executes the function asynchronously, using the thread pool or a provided executor.

Output:

Task completed by: pool-1-thread-2 processed by thenApply on thread: pool-1-thread-2

Composing with thenCompose and thenComposeAsync

For tasks that depend on the results of previous tasks, thenCompose() and thenComposeAsync() are useful:

CompletableFuture<String> asyncTask3 = asyncTask2.thenCompose(val -> {
    return CompletableFuture.supplyAsync(() -> val + " continued in thenCompose by: " + Thread.currentThread().getName());
});
  • thenCompose: Flattens nested CompletableFutures into a single stage.
  • thenComposeAsync: Similar to thenCompose, but runs asynchronously.

Output:

Task completed by: pool-1-thread-2 processed by thenApply on thread: pool-1-thread-2 continued in thenCompose by: pool-1-thread-1

Consuming Results with thenAccept and thenAcceptAsync

To consume the result without returning a new value, use thenAccept() or thenAcceptAsync():

CompletableFuture<Void> asyncTask4 = asyncTask3.thenAccept(val -> {
    System.out.println("Result consumed: " + val);
});
  • thenAccept: Executes a Consumer upon task completion.
  • thenAcceptAsync: Asynchronously executes the Consumer.

Output:

Result consumed: Task completed by: pool-1-thread-2 processed by thenApply on thread: pool-1-thread-2 continued in thenCompose by: pool-1-thread-1

Combining Tasks with thenCombine and thenCombineAsync

To combine results from multiple tasks, thenCombine() and thenCombineAsync() are used:

CompletableFuture<String> asyncTask6 = CompletableFuture.supplyAsync(() -> "Task 1", taskExecutor);
CompletableFuture<String> asyncTask7 = CompletableFuture.supplyAsync(() -> "Task 2", taskExecutor);
 
CompletableFuture<String> combinedTask = asyncTask6.thenCombine(asyncTask7, (val1, val2) -> {
    return val1 + " + " + val2;
});
  • thenCombine: Combines two independent CompletableFutures.
  • thenCombineAsync: Similar to thenCombine, but executes asynchronously.

Output:

Task 1 + Task 2

Both Future and CompletableFuture are essential for handling asynchronous tasks in Java. While Future is simple and straightforward, CompletableFuture offers greater flexibility, especially with task chaining, composition, and combining.