A Practical Guide to Java Virtual Threads
Master Java virtual threads with hands-on examples. Learn thread creation, executor patterns, structured concurrency, pinning pitfalls, and migration strategies.
What you'll learn
- ✓How virtual threads differ from platform threads
- ✓Practical patterns for adopting virtual threads in existing codebases
- ✓How to avoid thread pinning and synchronized block issues
Prerequisites
- •Basic Java concurrency (Thread, Runnable)
- •Familiarity with ExecutorService
- •Java 21+
Java 21 finalized virtual threads as part of Project Loom, fundamentally changing how Java applications handle concurrency. Instead of mapping each thread to an expensive OS thread, virtual threads are managed by the JVM on a small pool of carrier threads. You can spawn millions of them without running out of memory.
This guide focuses on practical adoption: how to integrate virtual threads into real applications, common mistakes developers make during migration, and performance patterns that actually matter in production.
Why virtual threads change the concurrency model
Traditional Java servers allocate one platform thread per request. Each platform thread consumes roughly 1MB of stack memory and maps directly to an OS thread. A server with 200 threads can handle 200 concurrent requests, even if 195 of those threads are idle waiting on database queries.
Virtual threads break this limitation. They cost roughly 1KB when idle and are scheduled onto a small pool of platform threads by the JVM. When a virtual thread blocks on I/O, the JVM unmounts it from the carrier thread and schedules another virtual thread in its place.
// platform thread: expensive, limited
Thread platform = Thread.ofPlatform().start(() -> {
// consumes ~1MB of OS stack
fetchFromDatabase();
});
// virtual thread: cheap, abundant
Thread virtual = Thread.ofVirtual().start(() -> {
// consumes ~1KB, scheduled on carrier pool
fetchFromDatabase();
});
The key insight is that virtual threads make the thread-per-request model scalable again. You do not need reactive frameworks or callback chains to achieve high throughput.
Creating and managing virtual threads
The virtual thread executor
The most practical way to use virtual threads is through Executors.newVirtualThreadPerTaskExecutor(). Each submitted task gets its own virtual thread.
import java.util.concurrent.*;
import java.util.List;
import java.util.ArrayList;
public class VirtualThreadExecutorDemo {
public static void main(String[] args) throws Exception {
List<Future<String>> futures = new ArrayList<>();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
final int taskId = i;
futures.add(executor.submit(() -> {
// simulate blocking I/O
Thread.sleep(1000);
return "Result from task " + taskId;
}));
}
// all 10,000 tasks run concurrently
for (Future<String> future : futures) {
future.get(); // collect results
}
}
// executor auto-closes when try-with-resources exits
}
}
This launches 10,000 concurrent tasks. With platform threads, you would need 10,000 OS threads (roughly 10GB of memory). With virtual threads, this uses a fraction of that.
Thread factory for naming and configuration
ThreadFactory factory = Thread.ofVirtual()
.name("worker-", 0) // worker-0, worker-1, worker-2, ...
.factory();
ExecutorService executor = Executors.newThreadPerTaskExecutor(factory);
executor.submit(() -> {
System.out.println(Thread.currentThread().getName()); // worker-0
});
Structured concurrency with ScopedValue
Java 21 introduced ScopedValue as a replacement for ThreadLocal in virtual thread contexts. Scoped values are immutable within a scope and automatically cleaned up.
import java.lang.ScopedValue;
public class ScopedValueDemo {
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public static void handleRequest(String requestId) {
ScopedValue.runWhere(REQUEST_ID, requestId, () -> {
System.out.println("Processing: " + REQUEST_ID.get());
callServiceA();
callServiceB();
});
}
private static void callServiceA() {
// REQUEST_ID is available here without passing it as a parameter
System.out.println("Service A, request: " + REQUEST_ID.get());
}
private static void callServiceB() {
System.out.println("Service B, request: " + REQUEST_ID.get());
}
}
Avoiding thread pinning
Thread pinning is the most common performance pitfall with virtual threads. When a virtual thread enters a synchronized block or method while holding a monitor, the JVM cannot unmount it from the carrier thread. The carrier thread is pinned and unavailable for other virtual threads.
// BAD: synchronized pins the carrier thread
public class PinnedExample {
private final Object lock = new Object();
public void processRequest() {
synchronized (lock) {
// if this blocks on I/O, the carrier thread is pinned
fetchFromDatabase(); // entire carrier thread is wasted
}
}
}
// GOOD: use ReentrantLock instead
import java.util.concurrent.locks.ReentrantLock;
public class UnpinnedExample {
private final ReentrantLock lock = new ReentrantLock();
public void processRequest() {
lock.lock();
try {
// virtual thread can unmount during I/O
fetchFromDatabase();
} finally {
lock.unlock();
}
}
}
To detect pinning at runtime, start the JVM with -Djdk.tracePinnedThreads=full. This prints a stack trace whenever a virtual thread is pinned.
Migrating existing code to virtual threads
Step 1: replace thread pools
The simplest migration is replacing fixed thread pools with virtual thread executors.
// before
ExecutorService executor = Executors.newFixedThreadPool(200);
// after
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Step 2: audit synchronized blocks
Search your codebase for synchronized blocks that contain blocking I/O. Replace them with ReentrantLock.
# find synchronized blocks in your project
grep -rn "synchronized" src/main/java/
Step 3: replace ThreadLocal with ScopedValue
ThreadLocal works with virtual threads but can cause memory issues because virtual threads are cheap to create and you may spawn millions. Each ThreadLocal instance holds a reference per thread.
// before: ThreadLocal
private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();
// after: ScopedValue (preview in Java 21)
private static final ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();
Step 4: update Spring Boot configuration
If you use Spring Boot 3.2+, enabling virtual threads is a single property:
# application.properties
spring.threads.virtual.enabled=true
This configures Tomcat to use virtual threads for request handling. Every incoming HTTP request runs on its own virtual thread.
When not to use virtual threads
Virtual threads are optimized for I/O-bound workloads. They do not help with CPU-bound tasks because the carrier thread pool is bounded to the number of available processors.
// virtual threads do NOT help here
// CPU-bound work keeps the carrier thread busy
executor.submit(() -> {
// heavy computation: no I/O blocking, no opportunity to unmount
BigInteger result = factorial(100_000);
});
// virtual threads excel here
// I/O-bound work lets the carrier serve other virtual threads
executor.submit(() -> {
String data = httpClient.send(request, bodyHandler).body();
String enriched = anotherService.call(data);
database.save(enriched);
});
Monitoring virtual threads in production
Virtual threads do not appear in thread dumps the same way platform threads do. Use jcmd to capture virtual thread information:
# thread dump including virtual threads
jcmd <pid> Thread.dump_to_file -format=json output.json
# in Java code, check if current thread is virtual
boolean isVirtual = Thread.currentThread().isVirtual();
For metrics, track the number of active carrier threads and the throughput of your virtual thread executor. If carrier threads are consistently maxed out, you likely have a pinning issue.
Summary
Virtual threads make the thread-per-request model viable at scale. The migration path is straightforward: swap your thread pools, replace synchronized with ReentrantLock, and consider ScopedValue over ThreadLocal. Focus on I/O-bound workloads, watch for pinning, and monitor carrier thread utilization in production. For Spring Boot applications, a single configuration property is all you need.
Related articles
- Java Java CompletableFuture Patterns for Async Code
Master CompletableFuture composition patterns including chaining, combining, error handling, timeouts, and async pipeline design in Java.
- Java Java Concurrency with CompletableFuture
How CompletableFuture composes asynchronous work in Java: chaining, combining, error handling, executors, and the patterns that keep concurrent code readable.
- Java Java Multithreading and Synchronization: A Practical Guide
Understand threads, the Java memory model, synchronization, locks, and concurrent collections. A practical guide to writing correct multithreaded Java code.
- Java Pattern Matching with Switch Expressions in Java
Master Java pattern matching in switch expressions. Covers type patterns, guarded patterns, sealed class exhaustiveness, and record deconstruction patterns.