Java Executors.newVirtualThreadPerTaskExecutor() Example

Since Java 21, we can create virtual threads (Project Loom). Unlike platform threads relying on the operating system, the virtual threads are managed by the JVM. This is why a million virtual threads can be created without exhausting system resources.

We can create a virtual thread either in started mode or in stopped mode which can be run in the future. When virtual threads have to be executed later, using the Executors is a good idea to decouple the logic contained in thread and control its execution.

Added in Java 21, the Executors.newVirtualThreadPerTaskExecutor() method returns an Executor that creates a new virtual thread for each task submitted to it. Let us learn more about this method and how to use it.

1. When to use Executors.newVirtualThreadPerTaskExecutor()?

We know that virtual threads consume very less memory compared to native threads. Also, virtual threads are managed by JVM allowing better resource utilization and reduced overhead associated with platform threads. This essentially means that virtual threads are very cheap to create, and we can create them in millions without impacting the performance of overall application.

So, it is a wise decision to create a new virtual thread to run a task, rather than reusing an existing virtual thread. Using a thread pool, in the case of virtual threads, does not bring any benefit as we see in the case of platform threads.

In Java, traditionally, we have been using Executors to run the submitted tasks and these executors used fixed-size thread pools where platform threads were reused to execute the submitted tasks.

Now, when thread pools bring no benefit for virtual threads, we can create as many virtual threads as many tasks are submitted. This is exactly what the newVirtualThreadPerTaskExecutor() method does. It returns an Executor that executes each submitted task in a new virtual thread.

Executor executor = Executors.newVirtualThreadPerTaskExecutor();

We can gain more control over the task execution by using ExecutorService in place of Executor. Note that ‘ExecutorService extends Executor’.

ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();

2. Executors newVirtualThreadPerTaskExecutor() Example

In the following example, we are supplying a list of numbers, calculating the square of a number and printing in a Runnable task. Later as the task does not wait for any external resource, it makes perfect sense to execute the task in a virtual thread.

List<Integer> numList = Arrays.asList(1, 2, 3, 4, 5);

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

numList.forEach(num ->
  executor.execute(() -> {
    System.out.println(STR."Square of \{num} is :: \{square(num)}");

executor.awaitTermination(2, TimeUnit.SECONDS);

In this example, we have used awaitTermination() because the main program was terminated before the virtual threads ran and the program output was not printed in the console. In a production application, the awaitTermination() instruction is not required.

The program prints the statements in random order in each run.

Square of 1 is :: 1
Square of 2 is :: 4
Square of 5 is :: 25
Square of 4 is :: 16
Square of 3 is :: 9

Similarly, we can run several types of non-blocking tasks in virtual threads using the Executors.newVirtualThreadPerTaskExecutor() method.

For example, in a web-scrapping project, we can use virtual threads for scrapping multiple pages concurrently as follows:

Executor executor = Executors.newVirtualThreadPerTaskExecutor();

List<String> urls = Arrays.asList(

urls.forEach(url ->
    executor.execute(() -> scrapeWebPage(url))

3. Executing Asynchronous Tasks

With virtual threads, asynchronous operations, and task aggregation is really very simple. We can call the blocking get() on a Future without taking a performance hit caused by blocking the system resources in the case of platform threads. Similarly, virtual threads are a great fit for aggregating the results of multiple asynchronous tasks.

In the following example, we can use executor.submit() to perform two actions asynchronously, and later aggregate their results. Since virtual threads are created, queued, and executed at the JVM level, we can comfortably block and wait for the results without consuming excessive resources or causing performance degradation.

public String invokeAndCombineResults() throws ExecutionException, InterruptedException {

	try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {

		Future<String> future1 = executor.submit(this::callAPI_1);
		Future<String> future2 = executor.submit(this::callAPI_2);
		String result1 = future1.get();
		String result2 = future2.get();
		//Do something meaningfull
		return result1 + " " + result2;
private String callAPI_1() {
   // Fetch result from an API
   return "result1";
private String callAPI_2() {
   // Fetch result from an API
   return "result2";

4. Conclusion

In this short Java concurrency tutorial, we learned to create an Executor or ExecutorService using the newVirtualThreadPerTaskExecutor() method. We learned how to submit and execute the tasks in virtual threads using the created executor.

Happy Learning !!


Notify of
Inline Feedbacks
View all comments

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.