Contents

CompletableFuture with Virtual threads

Writen by: David Vlijmincx

Introduction

In this post, we look at how to use virtual threads with compatible futures. While it is possible to combine virtual threads and CompletableFuture I also want to show you an alternative way of writing your code.

Using CompletableFuture with Virtual Threads

The easiest way to use virtual threads with CompletableFuture is to pass an ExecutorService as a second parameter to the supplyAsync or runAsync method. In the following example, on lines 2 and 3 you can see the executorService being created and used for the execution of the CompletableFuture.

1
2
3
4
5
6
7
8
9
Supplier<String> supplier = () -> "Hello, World!";
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture<String> completableFuture = CompletableFuture.supplyAsync(supplier, executorService);

CompletableFuture<Void> lastCompatibleFuture = completableFuture
.thenAccept(s -> System.out.println("Computer says: " + s));

lastCompatibleFuture.get();
executorService.shutdown();

While it works, is not very pretty code or directly clear to the reader what is going on. That is why I want to show you two alternative ways that make (in my opinion better) use of virtual threads.

Using Future with Virtual Threads

One way to improve the previous example is to use the newVirtualThreadPerTaskExecutor inside a try-with-resource statement. You can use the future returned by the executor later in your code if you need to return a value from a virtual thread.

1
2
3
4
try(var executor = Executors.newVirtualThreadPerTaskExecutor()){
    Future<String> stringFuture = executor.submit(() -> "Computer says: " + supplier.get());
    System.out.println("stringFuture = " + stringFuture.get());
}

This example is already a huge improvement in readability over the first example. Making code easier to read is always a great improvement.

Only using Virtual Threads

If you don't need the return value from the virtual thread, you can rewrite the example into something as small as the following example. It does the same, calling the supplier and printing it to the console.

1
Thread.startVirtualThread(() -> System.out.println("Computer says: " + supplier.get()));

This example is small, concise, and shows the reader exactly what you want the code to do.

CompletableFuture.runAsync().thenRun() to virtual threads

If you are using CompletableFuture to run one method after the previous one is done using the thenRun method, you can also switch to virtual threads.

In the following example bar() will run after foo().

1
CompletableFuture.runAsync(() -> foo()).thenRun(() -> bar());

We can replicate this behavior with virtual threads by using the newVirtualThreadPerTaskExecutor and submitting a runnable that encapsulates those two methods. Now foo() and bar() will run like any other sequential code.

1
2
3
4
5
6
try(var executor = Executors.newVirtualThreadPerTaskExecutor()){
    executor.submit(()->{
       foo();
       bar();
    });
}

These two examples will have the same result, only the last one is easier to read and debug.

Conclusion

In this post, we looked at how to use virtual threads with CompletableFuture, but that it can be more readable if you use virtual threads or the newVirtualThreadPerTaskExecutor instead. Of course, it all depends on your code base, but I hope this offers another perspective on looking at your code.