[THREADING 101] 09 – Virtual Threads (Java 21+)

[

The Problem with Platform Threads

Platform threads (the traditional Thread class) are thin wrappers around OS threads. Each one consumes:

  • ~1 MB of stack memory
  • Kernel resources for scheduling
  • Expensive context switches (~1-10 μs)

This means a typical server can sustain only thousands of platform threads before running out of memory or spending all its time context-switching.

But modern servers handle blocking I/O — calling databases, APIs, file systems — where threads spend most of their time waiting. A thread blocked on a network call is occupying 1 MB of RAM and an OS thread slot while doing nothing.

The Throughput Dilemma

Before Java 21, you had two choices:

  1. Thread-per-request — Simple code, but limited to ~thousands of concurrent requests.
  2. Reactive/Async — Scales well, but code becomes complex callback chains that are hard to read and debug.

Virtual threads give you the simplicity of option 1 with the scalability of option 2.

How Virtual Threads Work

Virtual threads are managed by the JVM, not the OS. The JVM maintains a small pool of carrier threads (platform threads, typically one per CPU core) and mounts virtual threads onto them:

When a virtual thread blocks (I/O, sleep, lock acquisition), the JVM unmounts it from the carrier thread and stores its state on the heap. The carrier thread is immediately freed to run another virtual thread. When the blocking operation completes, the virtual thread is mounted back onto an available carrier thread and resumes.

This mounting/unmounting is vastly cheaper than an OS context switch — it’s just copying a small stack frame to/from the heap.

Creating Virtual Threads

Direct Creation

// Create and start a virtual thread
Thread vThread = Thread.ofVirtual().start(() -> {
    System.out.println("Running on: " + Thread.currentThread());
});

// Create without starting
Thread vThread2 = Thread.ofVirtual().unstarted(() -> {
    System.out.println("Will run on a virtual thread");
});
vThread2.start();

// Named virtual thread (useful for debugging)
Thread vThread3 = Thread.ofVirtual()
    .name("worker-", 1) // worker-1, worker-2, etc.
    .start(() -> doWork());

Platform Thread for Comparison

Thread platformThread = Thread.ofPlatform().start(() -> {
    System.out.println("Running on: " + Thread.currentThread());
});

ExecutorService with Virtual Threads

// One virtual thread per task — the recommended approach
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            // Each task gets its own virtual thread
            String result = callExternalService(); // Blocking — but cheap!
            processResult(result);
        });
    }
} // Auto-closes and waits for all tasks to complete

This creates 100,000 virtual threads — something impossible with platform threads (would require ~100 GB of stack memory). With virtual threads, the JVM handles it with a small carrier pool.

When Virtual Threads Unmount

The JVM unmounts a virtual thread from its carrier when it encounters:

Blocking OperationUnmounts?Notes
Thread.sleep()Frees carrier immediately
ReentrantLock.lock()Unmounts while waiting for lock
Semaphore.acquire()Unmounts while waiting for permit
BlockingQueue.take()Unmounts while queue is empty
TCP/UDP Socket I/ONetwork reads/writes
Future.get()Waiting for async result
synchronized block (Java 21)PINSFixed in Java 24 (JEP 491)
Native/JNI callsPINSCan’t unmount — native stack
CPU-intensive workN/ANo blocking — stays mounted

Pinning

Pinning occurs when a virtual thread cannot be unmounted. The carrier thread is stuck until the virtual thread finishes the pinned operation. This reduces throughput because other virtual threads can’t use that carrier.

In Java 21-23, synchronized pins the carrier. Starting with Java 24 (JEP 491), synchronized blocks properly unmount virtual threads.

For Java 21-23, prefer ReentrantLock over synchronized in hot paths:

// AVOID in Java 21-23 (pins carrier thread):
public synchronized String getData() {
    return httpClient.get("http://api.example.com/data"); // Pins entire I/O wait!
}

// PREFER (unmounts while waiting for lock AND during I/O):
private final ReentrantLock lock = new ReentrantLock();

public String getData() {
    lock.lock();
    try {
        return httpClient.get("http://api.example.com/data");
    } finally {
        lock.unlock();
    }
}

Performance Characteristics

What Virtual Threads Improve

  • Throughput — More concurrent requests with the same hardware.
  • Resource efficiency — Thousands of blocked threads without GB of wasted RAM.
  • Code simplicity — Write blocking code without callback hell.

What Virtual Threads Do NOT Improve

  • Single-request latency — Each individual request takes the same time.
  • CPU-bound workloads — If threads never block, virtual threads add no benefit.
  • Algorithmic performance — The computation itself isn’t faster.

Best Practices

DO

// 1. Use newVirtualThreadPerTaskExecutor — let JVM manage the pool
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = tasks.stream()
        .map(task -> executor.submit(task))
        .toList();
}

// 2. Write simple, blocking code — it's efficient with virtual threads
String data = httpClient.send(request, BodyHandlers.ofString()).body(); // Blocking is OK!

// 3. Use try-with-resources for executors (Java 21+)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // executor.close() waits for completion
}

DON’T

// 1. DON'T create fixed-size virtual thread pools — defeats the purpose
ExecutorService BAD = Executors.newFixedThreadPool(100); // Platform threads!
// Virtual threads should be unlimited — JVM manages carrier pool internally

// 2. DON'T use virtual threads for CPU-bound work
// No benefit — they'll just stay mounted on carriers the whole time

// 3. DON'T pool virtual threads — they're cheap to create
// Unlike platform threads, there's no reason to reuse them

// 4. DON'T rely on thread priority — virtual threads ignore it
Thread.ofVirtual().start(() -> {
    Thread.currentThread().setPriority(Thread.MAX_PRIORITY); // Ignored!
});

Virtual Threads Properties

PropertyVirtual ThreadPlatform Thread
Stack memory~few KB (grows on demand)~1 MB (pre-allocated)
OS resourceNone (heap-only)1:1 with OS thread
DaemonAlways trueConfigurable
PriorityIgnoredRespected
Thread groupFixed VirtualThreadsConfigurable
Max countMillionsThousands
Context switch cost~nanoseconds (mount/unmount)~microseconds (OS)
DebuggingHarder (many threads)Familiar

Migration Strategy

Existing Code

Most existing blocking code benefits immediately from virtual threads. The migration is often just changing the executor:

// Before (platform threads)
ExecutorService executor = Executors.newFixedThreadPool(200);

// After (virtual threads) — just change this one line
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Spring Boot (3.2+)

# application.properties
spring.threads.virtual.enabled=true

This makes all request-handling threads virtual — an entire web server upgrade in one config line.

Structured Concurrency (Preview — Java 21+)

// Structured Concurrency scopes virtual thread lifetime to a code block
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<String> user = scope.fork(() -> fetchUser(userId));
    Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(userId));

    scope.join();           // Wait for both
    scope.throwIfFailed();  // Propagate exceptions

    return new UserProfile(user.get(), orders.get());
}
// If one fails, the other is cancelled automatically
// No thread leaks — scope guarantees cleanup

Virtual Threads vs Reactive Programming

AspectVirtual ThreadsReactive (Project Reactor, RxJava)
Code styleSequential/blockingChained operators/callbacks
ReadabilityFamiliar, imperativeSteep learning curve
DebuggingStack traces workStack traces are cryptic
Back-pressureThread blocking = natural back-pressureExplicit operators needed
Ecosystem maturityNew (Java 21)Battle-tested
PerformanceExcellent for I/OExcellent for I/O
CPU overheadSlightly higherLower (fewer allocations)
Migration effortChange executorRewrite entire codebase

For most applications, virtual threads provide equivalent throughput to reactive frameworks with dramatically simpler code.

Summary

  • Virtual threads are lightweight threads managed by the JVM, not the OS.
  • They unmount from carrier threads during blocking operations, freeing carriers for other work.
  • Write simple blocking code — the JVM makes it efficient.
  • Use Executors.newVirtualThreadPerTaskExecutor() — don’t pool virtual threads.
  • Best for I/O-heavy workloads (servers, microservices, database access).
  • No benefit for CPU-bound computation.
  • Thread safety rules are identical to platform threads — you still need synchronization.
  • In Java 21-23, prefer ReentrantLock over synchronized to avoid pinning.

References

About the author

Maksim

I build AI-powered products and lead engineering teams. I've launched platforms from zero to millions of users and learned most lessons the hard way. I write about the gap between engineering theory and practice, what actually matters when building products, and the decisions that shape teams and systems.

Add Comment

By Maksim

Maksim

Get in touch

Reach out if you want to discuss engineering leadership, collaborate on something interesting, or suggest topics you'd like me to write about.