Introduction to Virtual Threads in JDK 21
JDK 21 has several goodies, but let’s focus on Virtual Threads today. Many years ago, Project LOOM was aimed at revolutionizing concurrent programming. It had a name change to Virtual Threads in JDK 19 and when JDK 21 was released in 2023, it was the first Long Term Support version that support Virtual Threads.
Understanding Platform Threads Before Virtual Threads
First, let’s discuss what Java supported prior to Virtual Threads. Platform Threads are operating system resources – and finite resources at that. In fact, depending on your operating system, you will likely get an explicit error that the operating system isn’t able to create any more threads – the behavior is OS-dependent. For example, a Mac might throw an error whereas Windows might not throw an error but the whole system would slow to a crawl. Without a doubt, multi-threading (Platform Threads) was far more efficient than multi-processing (multiple JVM’s) – but each Platform Thread uses more than 1MB of memory compared with less than 1KB of memory for Virtual Threads. There is no limit to the number of Virtual Threads, heap size permitting.
Virtual Threads: A New Approach to Concurrent Programming
Virtual Threads are the next evolution in concurrent programming. They are decoupled from Platform Threads and therefore don’t utilize the OS resources unless they are mounted on a Platform Thread, called a “Carrier Thread” in that context. This mount/unmount is more efficient than traditional Platform Thread start/stop workload, which unlocks new patterns for concurrent programming.
Thread Pool Options Before Virtual Threads
Before Virtual Threads, it was common to create a ThreadPool (introduced in JDK 8). The 2 main choices were a fixed thread pool or cached thread pool. The fixed thread pool which would define a fixed number of Platform Threads (eg 10) and create a backlog when there are more Runnable/Callable objects than that the fixed size. Whereas the cached thread pool would create new Platform Threads if a new Runnable/Callable was executed and no Platform Thread is available. Each has its own tradeoff: fixed thread pools ensure that the system doesn’t utilize too many resources, but at the cost of having Platform Threads potentially being unused. Whereas the cached thread pool grows/shrinks to meet the workload, but runs the risk of causing errors if the workload becomes too large (operating system constraints).
The Power of Virtual Threads in JDK 21
Virtual Threads combine the power of these 2 types of thread pools. JDK 21 introduces a new ExecutorService.newVirtualThreadPerTaskExecutor which, as the name implies, creates a new Virtual Thread for each task. This uses a ForkJoinPool of Platform Threads under the covers which behaves like a cached thread pool but not to exceed a certain number of Platform Threads. That limit defaults to a number based on number of CPU cores. (You can override this by JVM arguments, but that’s an advanced topic beyond the scope of this article.) And so you can have thousands – or tens of thousands (heap size permitting) – of Virtual Threads without the worry of exhausting operation system resources.
Example Code: Comparing Fixed Thread Pool and Virtual Thread Pool
Let’s look at some sample code. Here’s a fixed thread pool would look like:
int count = 0;
try (ExecutorService threadPool = Executors.newFixedThreadPool(16)) {
for (int i = 0; i < 30; i++) {
CompletableFuture.supplyAsync(new SleepSupplier(count++), threadPool);
}
}
The above code will churn through 10K tasks 16 at a time. On my laptop, the ForkJoinPool will have 16 platform threads, so the new Virtual Thread based thread pool looks like this:
int count = 0;
try (ExecutorService threadPool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 30; i++) {
CompletableFuture.supplyAsync(new SleepSupplier(count++), threadPool);
}
}
Performance Comparison: FixedThreadPool vs VirtualThreadPerTaskExecutor
But note in this new code I don’t have to worry about thread pool size – the JVM optimized it for me. On top of that, the Virtual Thread pool is faster. The SleepSupplier used in the above example simply sleeps for 1 second. The FixedThreadPool completes in 4082 ms compared with 3070 ms for the VirtualThreadPerTaskExecutor.
Customizing Virtual Threads with ThreadFactory
An alternate way to define a Virtual Thread pool is to use ExecutorService.newThreadPerTaskExecutor but provide a ThreadFactory that returns Virtual Threads. This allows for a bit of icing that allows naming the Virtual Threads which will appear in Thread.toString along with the Carrier Thread:
int count = 0;
final ThreadFactory factory = Thread.ofVirtual().name("vthread-", 1).factory();
try (ExecutorService threadPool = Executors.newThreadPerTaskExecutor(factory)) {
for (int i = 0; i < Main.NUM_TASKS; i++) {
CompletableFuture.supplyAsync(new SynchronizedSleepSupplier(count++), threadPool);
}
}
Comparing Thread Output: Platform Threads vs Virtual Threads
Here are what the toString looks like for Platform Thread vs Virtual Thread:
Platform thread:
START supplier 7 on thread Thread[#37,pool-1-thread-8,5,main]
START supplier 10 on thread Thread[#40,pool-1-thread-11,5,main]
START supplier 8 on thread Thread[#38,pool-1-thread-9,5,main]
Virtual Thread:
START supplier 2 on thread VirtualThread[#33,vthread-3]/runnable@ForkJoinPool-1-worker-3
START supplier 7 on thread VirtualThread[#38,vthread-8]/runnable@ForkJoinPool-1-worker-8
START supplier 14 on thread VirtualThread[#45,vthread-15]/runnable@ForkJoinPool-1-worker-15
Future Outlook: Adoption of Virtual Threads
I think we can expect to see various frameworks and tools will start adopting Virtual Threads for concurrency in the coming years.