A Practical Guide to Using CompletableFuture with Virtual Threads in Java.

A Practical Guide to Using CompletableFuture with Virtual Threads in Java.

With the introduction of Virtual Threads in Java (via Project Loom), asynchronous programming is now simpler and more efficient than ever. CompletableFuture was already a powerful tool for managing asynchronous tasks, but combining it with virtual threads opens new possibilities for handling high-concurrency workloads without the traditional overhead of platform (OS) threads.

In this guide, we’ll explore how to use CompletableFuture with virtual threads, covering practical examples and explaining how they improve the performance, scalability, and readability of asynchronous code.


1. Overview of CompletableFuture and Virtual Threads

  • CompletableFuture is part of Java’s java.util.concurrent package and provides a way to handle async tasks, making it easier to work with multiple threads.

  • Virtual Threads are lightweight, memory-efficient threads that are managed by the JVM, allowing applications to spawn millions of threads without consuming excessive resources.

By leveraging both, you can create highly efficient and easy-to-manage async workflows, particularly for tasks involving I/O or network operations.


2. Creating a Basic CompletableFuture with Virtual Threads

A CompletableFuture is often created with methods like CompletableFuture.supplyAsync() or CompletableFuture.runAsync(). To make it run on a virtual thread, you can provide an executor backed by virtual threads.

Example: Running a Task on a Virtual Thread

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

public class CompletableFutureWithVirtualThreadExample {

    public static void main(String[] args) {
        // Creating an executor with virtual threads
        var executor = Executors.newVirtualThreadPerTaskExecutor();

        CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
            System.out.println("Task running in virtual thread: " + Thread.currentThread());
            try {
                Thread.sleep(1000); // Simulating a blocking I/O operation
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            System.out.println("Task completed");
        }, executor);

        future.join(); // Wait for completion
        executor.close();
    }
}

Explanation: Here, we create a CompletableFuture that runs a task in a virtual thread executor, performing a simple sleep to simulate I/O. The virtual thread ensures that the program can efficiently manage many such tasks without blocking traditional threads.


3. Combining Multiple Async Tasks

One of CompletableFuture’s strengths is its ability to combine multiple async tasks. With virtual threads, managing these tasks becomes even easier and more resource-efficient.

Example: Combining Tasks with thenApplyAsync and thenCombine

public class CompletableFutureCombineExample {

    public static void main(String[] args) {
        var executor = Executors.newVirtualThreadPerTaskExecutor();

        CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> {
            System.out.println("Task 1 running on: " + Thread.currentThread());
            return 10;
        }, executor);

        CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> {
            System.out.println("Task 2 running on: " + Thread.currentThread());
            return 20;
        }, executor);

        CompletableFuture<Integer> combinedFuture = future1.thenCombineAsync(future2, Integer::sum, executor);

        combinedFuture.thenAcceptAsync(result -> {
            System.out.println("Result of combined tasks: " + result);
        }, executor).join();

        executor.close();
    }
}

Explanation: In this example, two tasks are run asynchronously on virtual threads, then combined using thenCombineAsync(). Using virtual threads minimizes memory usage, making it practical to run large numbers of tasks concurrently without overwhelming system resources.


4. Exception Handling in CompletableFuture with Virtual Threads

Handling exceptions in asynchronous tasks is essential. With CompletableFuture, you can manage errors using methods like exceptionally or handle. Virtual threads allow you to handle more tasks without crashing the system, even when some tasks may fail.

Example: Error Handling with exceptionally

public class CompletableFutureErrorHandlingExample {

    public static void main(String[] args) {
        var executor = Executors.newVirtualThreadPerTaskExecutor();

        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            System.out.println("Executing task on virtual thread: " + Thread.currentThread());
            if (true) {
                throw new RuntimeException("An error occurred!");
            }
            return "Completed Task";
        }, executor).exceptionally(ex -> {
            System.out.println("Handling error: " + ex.getMessage());
            return "Fallback Result";
        });

        System.out.println("Result: " + future.join());
        executor.close();
    }
}

Explanation: Here, a task is executed on a virtual thread, and if an exception occurs, the exceptionally method catches it and provides a fallback result. Virtual threads allow this code to handle many such tasks concurrently without exhausting system resources.


5. Creating Complex Workflows with CompletableFuture and Virtual Threads

In real-world applications, you often need to chain multiple async operations. With virtual threads, you can handle these workflows without the performance drawbacks that typically accompany traditional thread-based async operations.

Example: Chaining Tasks

public class CompletableFutureChainingExample {

    public static void main(String[] args) {
        var executor = Executors.newVirtualThreadPerTaskExecutor();

        CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> {
            System.out.println("Step 1 on virtual thread: " + Thread.currentThread());
            return "Step 1 Result";
        }, executor).thenApplyAsync(result -> {
            System.out.println("Step 2 on virtual thread: " + Thread.currentThread());
            return result + " -> Step 2 Result";
        }, executor).thenAcceptAsync(finalResult -> {
            System.out.println("Final Result: " + finalResult);
        }, executor);

        future.join(); // Wait for completion
        executor.close();
    }
}

Explanation: This example demonstrates a multi-step async workflow using virtual threads. Each step runs on a virtual thread, and the final result is printed at the end. With virtual threads, managing such workflows becomes simpler and more efficient.


6. Performance Considerations: Virtual Threads vs Platform Threads with CompletableFuture

Running the same async tasks on platform threads (OS threads) versus virtual threads can show a significant difference in memory usage and responsiveness, especially under high concurrency.

Benchmark: Platform Threads vs Virtual Threads

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;

public class ThreadComparisonBenchmark {

    public static void main(String[] args) {
        int taskCount = 10_000; // Number of tasks
        List<CompletableFuture<Void>> futures = new ArrayList<>();

        // Virtual Thread Executor
        var executor = Executors.newVirtualThreadPerTaskExecutor();

        long start = System.currentTimeMillis();

        for (int i = 0; i < taskCount; i++) {
            CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
                try {
                    Thread.sleep(100); // Simulate I/O task
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }, executor);
            futures.add(future);
        }

        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
        long end = System.currentTimeMillis();

        System.out.println("Time taken with Virtual Threads: " + (end - start) + " ms");
        executor.close();
    }
}

Explanation: This benchmark creates 10,000 asynchronous tasks and measures execution time using virtual threads. Virtual threads allow the program to handle this workload more efficiently compared to platform threads, with minimal memory overhead and better scalability.


Conclusion

Using CompletableFuture with virtual threads offers a powerful and efficient way to handle asynchronous tasks in Java, particularly in I/O-heavy and highly concurrent applications. This combination allows Java applications to scale to handle thousands, even millions, of tasks without overwhelming system resources. Key takeaways include:

  • Scalability: Virtual threads combined with CompletableFuture make it practical to handle massive concurrency.

  • Error Handling: Easily manage errors in async tasks while maintaining performance.

  • Improved Code Readability: Virtual threads simplify async programming, making CompletableFuture chains easier to read and maintain.

By using CompletableFuture with virtual threads, Java developers can build high-performance, scalable applications that fully leverage the concurrency capabilities introduced by Project Loom.

More such articles:

medium.com/techwasti

youtube.com/@maheshwarligade

techwasti.com/series/spring-boot-tutorials

techwasti.com/series/go-language

Did you find this article valuable?

Support techwasti by becoming a sponsor. Any amount is appreciated!