DEV Community

Said Olano
Said Olano

Posted on

Understanding Java Virtual Threads: Lightweight Concurrency in Java 21

Understanding Java Virtual Threads: Lightweight Concurrency in Java 21

Java 21 introduced one of the most significant additions to the platform in years: virtual threads (Project Loom). This feature fundamentally changes how we write concurrent applications in Java, making high-throughput concurrent code both simpler and more scalable.

The Problem with Platform Threads

Traditionally, every Java Thread maps directly to an operating system thread. These platform threads are expensive:

  • Each consumes roughly 1MB of stack memory.
  • Context switching is handled by the OS and is relatively costly.
  • A typical machine can only support a few thousand of them.

This limitation forced developers toward complex asynchronous programming models (callbacks, CompletableFuture chains, reactive streams) to achieve scalability—at the cost of readability and debuggability.

Enter Virtual Threads

Virtual threads are lightweight threads managed by the JVM rather than the OS. Millions of them can run on just a handful of platform threads (called carrier threads). When a virtual thread blocks on I/O, the JVM unmounts it from its carrier thread, freeing that carrier to run other work.

Creating a Virtual Thread

java
// Start a single virtual thread
Thread.startVirtualThread(() -> {
System.out.println("Running in a virtual thread!");
});

// Using a builder
Thread vThread = Thread.ofVirtual()
.name("worker-", 0)
.start(() -> doWork());

Executor Service with Virtual Threads

The most idiomatic way to use them is via an executor:

java
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // executor.close() waits for all tasks to finish

This spawns 10,000 concurrent tasks. With platform threads this would likely exhaust system resources, but with virtual threads it runs comfortably.

Writing Simple Blocking Code Again

The beauty of virtual threads is that you write straightforward blocking code, and the JVM handles the scaling:

java
String fetchUserData(int userId) {
var user = userService.findById(userId); // blocking call
var orders = orderService.findByUser(userId); // blocking call
return combine(user, orders);
}

No reactive chains, no callbacks—just readable, debuggable, sequential logic.

Spring Boot Support

Spring Boot 3.2+ supports virtual threads with a single property:

properties
spring.threads.virtual.enabled=true

Once enabled, Tomcat serves each request on a virtual thread, dramatically increasing the number of concurrent requests the server can handle without tuning thread pools.

Pitfalls to Watch For

  1. Pinning: When a virtual thread runs inside a synchronized block during a blocking call, it stays pinned to its carrier thread. Prefer ReentrantLock instead.
  2. Don't pool virtual threads: They are cheap to create. Use newVirtualThreadPerTaskExecutor() rather than a fixed pool.
  3. CPU-bound work: Virtual threads shine for I/O-bound tasks. For CPU-bound work, the number of platform threads is still the limiting factor.

Conclusion

Virtual threads let you keep the simple thread-per-request programming model while achieving the scalability previously reserved for asynchronous frameworks. If you're on Java 21 or later, they're one of the easiest performance wins available—especially in I/O-heavy services.

Try enabling them in your next Spring Boot project and measure the difference!

Top comments (0)