1. The essentials
You are already somewhat familiar with classes from the java.util.concurrent package, especially ExecutorService. It’s a “task manager”: you submit work to it (for example, via submit()), and it decides when and which thread will run it. Usually, there is a fixed-size thread pool under the hood that saves resources and doesn’t create a new thread for every task.
However, virtual threads change the game! Now you can afford a luxury: one dedicated thread per task, without worrying that the JVM will “burst” from overeating.
A new way: Executors.newVirtualThreadPerTaskExecutor()
In Java 21, a new way appeared to create an ExecutorService that runs each task in its own virtual thread:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Key difference:
- Old thread pools (Executors.newFixedThreadPool, Executors.newCachedThreadPool) limited the number of concurrent tasks due to the high cost of OS threads.
- The new virtual Executor is almost unrestricted: each task gets its own lightweight virtual thread.
Simple example
Let’s submit 10 tasks to the virtual Executor:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10; i++) {
int taskId = i; // capture the variable for the lambda
executor.submit(() -> {
System.out.println("Task " + taskId + " is running in thread: " +
Thread.currentThread());
});
}
executor.shutdown();
}
}
What happens?
Each task will be launched in its own virtual thread, and you will see lines like:
Task 1 is running in thread: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1
...
2. Massive parallelism: thousands of tasks are no problem!
To really feel the power of virtual threads, let’s try submitting not 10 but, say, 100_000 tasks to the ExecutorService. In classic pools this would be like trying to fit an elephant into a fridge: the JVM would quickly run out of memory or start to crawl. With virtual threads, it’s different!
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorMassiveDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 100_000; i++) {
int taskId = i;
executor.submit(() -> {
// For example — just sleep for 1 ms
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// System.out.println("Task " + taskId + " done."); // Don't print; otherwise there will be way too many lines!
});
}
executor.shutdown();
}
}
Note: printing 100_000 lines is a bad idea: the console will “choke” much faster than virtual threads. Either don’t print to the console or print only the first few tasks.
3. How newVirtualThreadPerTaskExecutor works
In short: this ExecutorService creates a new virtual thread for every task you submit. Unlike a fixed pool, there’s no task queue and no hard limit on the number of concurrent threads (beyond your JVM and hardware limits).
Architecturally:
- Virtual threads are mapped onto a small pool of real OS (carrier) threads.
- The JVM itself decides when and which virtual thread to run, suspend, and resume.
- If a thread blocks (for example, reading a file or waiting on the network), the JVM can park the virtual thread and free the carrier thread for other tasks.
4. Example: handling results with Future
An ExecutorService returns a Future if a task returns a result. It works just like with regular threads:
import java.util.concurrent.*;
public class VirtualExecutorWithResult {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(500);
return "Hello from virtual thread!";
});
System.out.println("Result: " + future.get()); // Wait for the result
executor.shutdown();
}
}
All is familiar: you can submit tasks that return a value, wait for the result via get(), and exceptions are handled in the standard way.
5. How to shut down the Executor properly
It’s very important not to forget to shut down the ExecutorService, so the program doesn’t hang (even if the threads are virtual rather than “real”).
shutdown() and awaitTermination
executor.shutdown(); // Tell it: we’re not accepting more tasks
executor.awaitTermination(1, TimeUnit.MINUTES); // Wait for all tasks to finish (up to 1 minute)
Why does this matter?
If you don’t call shutdown(), virtual threads may keep living, and the program won’t exit even after main() completes. This is a common beginner mistake.
6. Useful details
Comparison: virtual Executor vs classic thread pool
| Classic pool (newFixedThreadPool) | Virtual Executor (newVirtualThreadPerTaskExecutor) | |
|---|---|---|
| Number of threads | Limited by pool size | One virtual thread per task, almost unlimited |
| Tasks in a queue | Yes, if all threads are busy | Generally no: a task gets a thread immediately |
| Thread cost | High (stack, OS resources) | Very low (JVM scheduling) |
| Scalability | Limited | Almost unlimited |
| Best suited for | CPU-bound tasks, limited parallelism | I/O-bound tasks, massive parallelism |
Integration with web servers
Modern web servers (for example, Tomcat, Jetty, Undertow) are beginning to support virtual threads. This means you can handle each HTTP request in a separate virtual thread without “choking” under user surges.
Advantage: you don’t need complex asynchronous schemes with callbacks and CompletableFuture; the code becomes simpler — you can write familiar blocking code, yet the application still scales.
Mass testing and load simulation
Virtual threads are a great fit for tests where you need to simulate thousands of concurrent users, requests, or operations. For example, a test that sends 10_000 parallel requests to a server, each in its own virtual thread.
Parallel processing of files and network connections
If your application works with many files or network connections, you can handle each connection in a separate virtual thread without worrying about manually managing pools.
7. Common mistakes when working with virtual Executors
Mistake #1: forgot to call shutdown(). If you don’t close the Executor, the program won’t exit — virtual threads will still be waiting for new tasks. Add awaitTermination(...) if needed.
Mistake #2: using virtual threads for heavy computations. Virtual threads don’t speed up tasks that fully load the CPU. For CPU-bound work, use a fixed pool (Executors.newFixedThreadPool) and tune its size carefully.
Mistake #3: ignoring exceptions inside tasks. If a task throws an exception, it won’t reach the main thread — handle it via Future (the get() method) or via try/catch inside the lambda.
Mistake #4: mixing up the old and new syntax/JDK version. Make sure you’re using the correct JDK version (Java 21+) and that your IDE is configured for virtual thread support. The specific method is Executors.newVirtualThreadPerTaskExecutor().
Mistake #5: relying on ThreadLocal for context propagation. Virtual threads are often created and destroyed; ThreadLocal may not behave as you expect. For context propagation, use ScopedValue (Scoped Values; more details — in the next lecture).
GO TO FULL VERSION