DEV Community

Said Olano
Said Olano

Posted on

Understanding Java's Virtual Threads: A Practical Guide

Understanding Java's Virtual Threads: A Practical Guide

Java 21 introduced one of the most significant changes to the platform's concurrency model in years: virtual threads. Finalized under JEP 444, virtual threads dramatically simplify writing high-throughput concurrent applications. In this post, we'll explore what they are, why they matter, and how to use them.

The Problem with Platform Threads

Traditionally, every java.lang.Thread in the JVM maps directly to an operating system thread. These platform threads are expensive:

  • Each one consumes around 1MB of stack memory by default.
  • The OS scheduler limits how many you can realistically create (typically a few thousand).
  • Blocking a platform thread wastes a scarce resource.

This forced developers into asynchronous, callback-heavy programming models to scale, sacrificing readability and debuggability.

Enter Virtual Threads

Virtual threads are lightweight threads managed by the JVM rather than the OS. Millions can run concurrently because they are cheap to create and don't tie up an OS thread while blocked.

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

// Using an ExecutorService backed by virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int taskId = i;
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return taskId;
});
}
}

When a virtual thread hits a blocking operation (like I/O), the JVM unmounts it from its carrier platform thread, freeing that carrier to run other work. When the operation completes, the virtual thread is remounted.

Writing Simple, Blocking Code

The key benefit is that you can write straightforward, synchronous code that scales:

java
void handleRequest(Socket socket) throws IOException {
try (var in = socket.getInputStream();
var out = socket.getOutputStream()) {
byte[] data = in.readAllBytes(); // blocking is fine!
out.write(process(data));
}
}

No CompletableFuture chains, no reactive operators—just readable, debuggable code.

Pitfalls to Avoid

  1. Don't pool virtual threads. They're cheap; create a new one per task.
  2. Watch for pinning. Blocking inside a synchronized block pins the virtual thread to its carrier. Prefer ReentrantLock for critical sections that block.
  3. Avoid thread-local abuse. With millions of threads, heavy ThreadLocal usage can inflate memory.

java
// Prefer this over synchronized when blocking
private final ReentrantLock lock = new ReentrantLock();

void safeOperation() {
lock.lock();
try {
performBlockingIO();
} finally {
lock.unlock();
}
}

Conclusion

Virtual threads let Java developers return to a simple thread-per-request model while achieving the scalability once reserved for asynchronous frameworks. If you're building I/O-bound services, upgrading to Java 21+ and adopting virtual threads can simplify your codebase and boost throughput with minimal effort.

Top comments (0)