Virtual Threads in Java: A Deep Dive into Project Loom

Since its early days, Java has provided a robust threading model based on OS-level threads. While reliable, it comes at the cost of heavyweight resource management and limits scalability in high-concurrency applications.

Enter Project Loom, an ambitious effort by the OpenJDK team to bring lightweight, virtual threads to Java. Now part of the standard Java 21 release, virtual threads are poised to revolutionize the way Java developers handle concurrency.

In this article, we’ll explore:

  • The pain points of traditional Java threads
  • What virtual threads are and how they work
  • Practical examples comparing traditional and virtual threads
  • Key benefits, limitations, and best practices
  • How virtual threads affect frameworks like Spring, Hibernate, and others

Let’s dive in.


Traditional Threads: A Scalability Bottleneck

Before virtual threads, Java relied exclusively on platform threads â€” that is, wrappers around the operating system’s native threads.

Each thread:

  • Requires significant memory (typically 1MB stack by default)
  • Is scheduled by the OS kernel
  • Is relatively expensive to create and context-switch

This model works fine for applications with hundreds or maybe a few thousands of threads. But what if you need millions of concurrent tasks, like in a chat server, a high-traffic web application, or a reactive API aggregator?

Solutions like thread pooling (via ExecutorService) tried to mitigate the problem, but they didn’t eliminate the fundamental issue: threads were too expensive.


Introducing Virtual Threads

Virtual threads are a new kind of thread in Java. They are:

  • Lightweight â€” consuming a tiny fraction of memory compared to platform threads
  • Managed by the JVM, not the OS
  • Mapped to platform threads only when actively running

A virtual thread is essentially a coroutine or green thread: a user-mode construct scheduled by the Java runtime itself.

This changes everything.

Think of virtual threads as thousands (or millions) of tasks running independently, without bringing your system to its knees.

Creating Virtual Threads

The best part? The Java threading API stays almost identical.

You can create a virtual thread using:

Thread.startVirtualThread(() -> {
    System.out.println("Running in a virtual thread: " + Thread.currentThread());
});

Alternatively, you can use a custom ExecutorService:

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

for (int i = 0; i < 1_000_000; i++) {
    int taskId = i;
    executor.submit(() -> {
        System.out.println("Task " + taskId + " running in " + Thread.currentThread());
    });
}

executor.shutdown();

Notice:

  • No need for special libraries.
  • No learning new APIs.
  • No reactive programming if you don't want it.
  • Just native Java, better performance.

Under the Hood: How Virtual Threads Work

When you create a virtual thread:

  • It lives in the JVM.
  • It uses an underlying platform thread only while executing.
  • When it encounters a blocking operation (like I/O), the JVM parks it, freeing up the underlying platform thread.

Thus, the system can manage millions of virtual threads atop a small number of platform threads.

Key details:

  • Parking/Unparking: Virtual threads can be suspended and resumed efficiently.
  • Stack Management: Virtual threads can unmount their stacks when parked to free memory.
  • Scheduling: The JVM uses a work-stealing scheduler optimized for fairness and throughput.

Virtual Threads vs Platform Threads: A Side-by-Side Example

Let’s compare creating 10,000 threads:

Platform Threads Example

for (int i = 0; i < 10_000; i++) {
    Thread thread = new Thread(() -> {
        try {
            Thread.sleep(1000); // Simulate work
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
    thread.start();
}
  • Expected outcome: Heavy CPU load, possible OutOfMemoryError, or sluggishness.

Virtual Threads Example

for (int i = 0; i < 10_000; i++) {
    Thread.startVirtualThread(() -> {
        try {
            Thread.sleep(1000); // Simulate work
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}
  • Expected outcome: Minimal system impact. Smooth execution.

Real-World Example: Simple Web Server

Imagine you want a web server that handles each request in a separate thread.

With virtual threads:

try (var serverSocket = new ServerSocket(8080)) {
    while (true) {
        var clientSocket = serverSocket.accept();
        Thread.startVirtualThread(() -> handleClient(clientSocket));
    }
}

Where handleClient could be:

private static void handleClient(Socket socket) {
    try (socket;
         var reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
         var writer = new PrintWriter(socket.getOutputStream())) {

        writer.println("Hello, world!");
        writer.flush();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

This code scales to thousands of concurrent clients without complicated thread pools or asynchronous frameworks.


How Virtual Threads Change the Game

  1. Simplicity: No need for complex async code or callbacks.
  2. Scalability: Millions of concurrent threads? No problem.
  3. Resource Efficiency: Lower memory footprint, better CPU utilization.
  4. Improved Debugging: Stack traces look normal, unlike complex async stacks.
  5. Seamless Adoption: Your existing code, libraries, and frameworks just work — no need for a rewrite.

Important Considerations

Despite their magic, virtual threads aren't a silver bullet.

Here are key things to keep in mind:

1. Blocking Matters

Virtual threads shine when blocking operations are cooperative (like Thread.sleepSocket.read, etc.).
If the blocking call is deeply embedded in native code that doesn’t cooperate with the JVM (e.g., some database drivers), the virtual thread can still pin a platform thread.

Pinned virtual threads waste resources.

Tip: Use Java libraries that are virtual-thread-friendly (like JDBC drivers supporting Connection.setNetworkTimeout).


2. CPU-Bound Tasks

If your tasks are CPU-intensive rather than I/O-intensive, virtual threads don't help much.
They don't magically make CPUs faster. You’ll still need to optimize CPU usage separately.


3. Structured Concurrency

Project Loom also introduces structured concurrency APIs (still incubating) to manage lifecycles of related tasks easily.

Example:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> user = scope.fork(() -> findUser());
    Future<String> order = scope.fork(() -> findOrder());

    scope.join();
    scope.throwIfFailed();

    System.out.println(user.resultNow() + " " + order.resultNow());
}

This ensures that:

  • Child tasks are attached to parent scopes.
  • Failure or cancellation propagates naturally.
  • Resource cleanup is easier.

Structured concurrency could become the idiomatic style for Java concurrency moving forward.


  • Spring Boot: Starting from Spring Boot 3.1, you can enable virtual threads easily. It dramatically improves web server concurrency with Tomcat, Jetty, and Undertow.
  • Hibernate: Mostly compatible, but careful with database connection management (don't block on connections too long).
  • gRPC / Netty / Vert.x: These frameworks are already highly asynchronous; virtual threads offer an alternative model but don't obsolete them.
  • Database Access: JDBC is blocking, but upcoming R2DBC drivers and improvements help here.

Best Practices with Virtual Threads

  • Favor Short-Lived Threads: Virtual threads are cheap, so use them liberally.
  • Tune Executors: Prefer Executors.newVirtualThreadPerTaskExecutor() for tasks.
  • Test Your Libraries: Ensure third-party libraries play nice with virtual threads.
  • Avoid Unnecessary Pinnings: Be aware of native code behaviors.
  • Use Structured Concurrency: Especially for large task hierarchies.
  • Monitor Production: Virtual threads reduce thread counts but don't remove the need for good observability.

Conclusion

Virtual threads bring game-changing concurrency improvements to Java — offering the scalability of reactive programming with the simplicity of imperative code.

You don't need to rethink your entire application architecture. You don't need to jump into event loops, callbacks, or functional programming if you don't want to.
With virtual threads, Java developers can write straightforward code that handles massive concurrency efficiently.

Virtual threads are not just another feature.
They are a fundamental shift in how Java applications are designed.