Back to blog
JavaConcurrencyPerformance

Virtual Threads in Java 21: A Practical Guide

Conference · 15 January 2025

Virtual threads, delivered in Java 21 as a preview and finalized in Java 21, are a game-changer for writing concurrent applications. They let you model one thread per task without the cost of OS threads.

Why virtual threads?

Platform threads are expensive. Each one maps to an OS thread and consumes around 1 MB of stack. Virtual threads are cheap: millions can run on a small number of carrier threads.

Here’s a simple example that creates 10,000 virtual threads:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}

With platform threads, this would be impractical. With virtual threads, it just works.

Blocking is OK again

Virtual threads are scheduled by the JVM. When a virtual thread blocks (e.g. on I/O or Lock.lock()), the carrier thread is released to run other virtual threads. So you can write clear, blocking-style code:

void handleRequest(SocketChannel channel) {
    try (channel) {
        String request = readRequest(channel);  // blocks
        String response = process(request);     // might block
        writeResponse(channel, response);       // blocks
    }
}

No need for reactive APIs or callback hell—just straightforward blocking calls, with scalability preserved.

Pinning and what to avoid

Virtual threads are pinned to their carrier when they execute native code or when they block inside a synchronized block. Pinning limits scalability. Prefer ReentrantLock over synchronized when holding a lock across I/O or other blocking calls:

// Prefer this when blocking inside the critical section
private final ReentrantLock lock = new ReentrantLock();

void process() {
    lock.lock();
    try {
        restClient.call();  // blocking; virtual thread can yield
    } finally {
        lock.unlock();
    }
}

Summary

  • Use virtual threads for high numbers of concurrent tasks (e.g. request handling, parallel I/O).
  • Prefer Executors.newVirtualThreadPerTaskExecutor() for new code.
  • Avoid synchronized in code paths that block; use ReentrantLock instead.
  • Virtual threads are perfect for Spring Boot 3.2+ and other frameworks that support them—often with zero config.

Virtual threads make the JVM a great fit for highly concurrent, blocking-style workloads. Give them a try in your next service.