Virtual Threads in Java 21: Boosting Application Scalability and Performance

Weberton Faria
Just Eat Takeaway-tech

--

In the past, Java’s approach to concurrency relied heavily on OS-level threads — powerful, sure, but often heavy on resource consumption. Now, with the advent of Java 21, there’s a game-changer on the scene: Virtual Threads. Launched as part of Project Loom, this new feature is engineered to cut down the heavy lifting often associated with handling multiple tasks at once. Virtual Threads excel in managing numerous blocking IO operations, making them a perfect fit for modern applications that need both efficiency and scalability.

In this article, we’re going to dive into what Virtual Threads are, show you how to use them through practical examples and compare them to traditional threads to spotlight their benefits. This fresh take on concurrency is exactly what today’s developers need to meet the challenges of modern software development.

Exploring Virtual Threads: The New Approach to Concurrency

Imagine Virtual Threads as a smarter, more efficient way to manage tasks that typically slow down systems — like reading files or waiting for network data. Launched with Java 21 under Project Loom, Virtual Threads are crafted to be lightweight and are fully managed by the Java Virtual Machine (JVM). This design means they aren’t as dependent on the operating system’s resources as their traditional counterparts.

In traditional Java setups, each thread is a Platform Thread directly linked to an OS thread. This one-to-one correlation means every Java thread consumes substantial resources, potentially bogging down your system. Check out the image below for a visual breakdown of this relationship.

Diagram of one-to-one mapping between Java Platform Threads and OS Threads.
Java Platform Thread to OS Thread Mapping

In contrast, Virtual Threads are managed directly by the JVM and are capable of running many threads on just a few OS threads. This efficient many-to-one relationship means that a large number of Virtual Threads can be handled with significantly fewer Platform Threads. The image below illustrates how this works in practice.

Many-to-one mapping of multiple Virtual Threads (VT) to a single Java Platform Thread, for efficient concurrency.
Java Virtual Threads to Platform Thread Mapping

Virtual Threads excel at handling tasks that involve a lot of waiting, such as I/O operations. They can be created in vast numbers without the hefty resource demands typically associated with Platform Threads. This efficiency allows applications to scale more effectively, accommodating more operations with less overhead.

Getting Started with Virtual Threads in Java

Integrating Virtual Threads into your Java applications is straightforward, especially with the tools that Java 21 provides. Here’s a quick guide on how to harness Virtual Threads for managing concurrent tasks effortlessly.

Java 21 introduced the Executors.newVirtualThreadPerTaskExecutor() method. This useful method creates an executor service that spawns a new Virtual Thread for each task submitted to it. This capability is particularly valuable in scenarios involving blocking I/O, where tasks need to run in parallel without weighing down the system. To see Virtual Threads in action, check out the example below:

import java.util.concurrent.Executors;

public class VirtualThreadsExample {
public static void main(String[] args) {

try (var executorService = Executors.newVirtualThreadPerTaskExecutor()) {
executorService.submit(() -> System.out.println("Running on a virtual thread!"));
}
}
}

Virtual Threads vs. Traditional Threads: A Comparative Analysis

The example below illustrates a traditional threading scenario where a thread pool of 500 threads manages 10,000 tasks. Each task is programmed to sleep for one second. Given that the thread pool size is substantially smaller than the number of tasks, the tasks are queued and executed as threads become available. In theory, if each task executes simultaneously without any overhead, the total estimated completion time would be about 20 seconds.

import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class TraditionalThreadsExample {

public static void main(String[] args) {
AtomicInteger counter = new AtomicInteger();

Runnable task = () -> {
try {
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("Counter " + counter.incrementAndGet());
};

long startTime = System.currentTimeMillis();
try (ExecutorService executorService = Executors.newFixedThreadPool(500)) {
for (int i = 0; i < 10_000; i++) {
executorService.submit(task);
}
}

long endTime = System.currentTimeMillis();
System.out.println("Total execution time: " + (endTime - startTime) + " ms");
}
}

For a fun experiment, try dramatically increasing the thread pool size to something extreme, like 1 million threads. You’ll likely encounter an OutOfMemoryError as the system struggles to allocate enough resources for such a large number of threads.

Virtual Threads Example

Now, let’s look at how we could achieve the same with Virtual Threads.

import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class VirtualThreadExample {

public static void main(String[] args) {
AtomicInteger counter = new AtomicInteger();

Runnable task = () -> {
try {
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}

System.out.println("Counter " + counter.incrementAndGet());
};

long startTime = System.currentTimeMillis();
try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executorService.submit(task);
}
}

long endTime = System.currentTimeMillis();
System.out.println("Total execution time: " + (endTime - startTime) + " ms");
}
}

In this example, we’re leveraging Java’s Executors.newVirtualThreadPerTaskExecutor() to manage 10,000 tasks, with each task taking a brief pause for one second. This method is a boon for such heavy-duty tasks, as it avoids the need for a large thread pool and sidesteps the risk of running out of memory—common issues with fixed thread pools.

Because Virtual Threads are so lightweight, the total execution time will generally be just slightly longer than the duration of the longest task, with a bit of extra time for minor overhead. Give this code a try; you’ll likely see it complete in just over one second. All tasks run almost in parallel, without the heavy resource drain typically associated with traditional threading models.

Wrapping Up: The Future of Concurrency with Virtual Threads

The introduction of Virtual Threads in Java 21 is a significant step forward for developers looking to build efficient and scalable applications. These nimble threads handle concurrency effortlessly, especially for tasks heavy on I/O, liberating developers from the constraints of traditional thread management. By adopting Virtual Threads, you gain smoother scalability and more straightforward code, empowering you to manage vast numbers of tasks with ease.

As Java continues to advance, Virtual Threads are emerging as an essential tool for the modern developer, paving the way toward a simpler, more effective future in concurrent programming. Try integrating Virtual Threads into your projects and experience the transformative impact they can have on your Java applications!

Happy coding!

--

--