Java Virtual Threads
Virtual Threads, introduced as a preview in Java 19 and finalised in Java 21 (JEP 444 ), represent a fundamental shift in how we approach concurrent programming in Java. This guide covers everything you need to know to effectively leverage this feature.
The Problem: Why We Needed Virtual Threads
The Thread-Per-Request Model’s Scalability Wall
Traditional Java web applications follow a thread-per-request model. Each incoming request is handled by a dedicated thread. This works well until you hit a wall: platform threads are expensive resources.
// Traditional approach - each request consumes a platform thread
ExecutorService executor = Executors.newFixedThreadPool(200);
for (Request request : incomingRequests) {
executor.submit(() -> {
// This thread is blocked during the entire I/O operation
String result = httpClient.send(request); // Blocks for 100ms+
database.save(result); // Blocks for 50ms+
return result;
});
}A platform thread typically consumes around 1MB of stack memory and requires significant OS resources for context switching. Most production servers cap their thread pools at a few hundred threads, meaning you can only handle a few hundred concurrent requests—even though each thread spends 90%+ of its time waiting on I/O.
The Reactive Workaround and Its Costs
Before virtual threads, the solution was reactive programming:
// Reactive approach - efficient but complex
Mono.fromCallable(() -> httpClient.sendAsync(request))
.flatMap(response -> Mono.fromCallable(() -> database.saveAsync(response)))
.subscribeOn(Schedulers.boundedElastic())
.subscribe();While reactive frameworks solve the scalability problem, they introduce significant costs: a steep learning curve, difficult debugging (stack traces become nearly useless), coloured functions that infect your entire codebase, and inability to use traditional control flow structures naturally.
What Are Virtual Threads?
Virtual threads are lightweight threads managed by the JVM rather than the operating system. They’re designed to have a near-zero cost when blocked, enabling you to create millions of them.
| Aspect | Platform Thread | Virtual Thread |
|---|---|---|
| Memory footprint | ~1MB stack | ~1KB (grows as needed) |
| Creation cost | Expensive (OS call) | Cheap (JVM managed) |
| Context switch | OS-level, expensive | JVM-level, cheap |
| Blocking cost | Wastes resources | Near-zero |
| Maximum count | Hundreds to thousands | Millions |
The Key Insight
Virtual threads don’t make your code run faster. They make your application scale better by allowing you to write simple, blocking code that doesn’t waste resources when waiting.
Creating and Using Virtual Threads
Basic Creation
// Method 1: Thread.startVirtualThread()
Thread vThread = Thread.startVirtualThread(() -> {
System.out.println("Running in: " + Thread.currentThread());
});
// Method 2: Thread.ofVirtual() builder
Thread vThread = Thread.ofVirtual()
.name("my-virtual-thread")
.start(() -> doWork());
// Method 3: Using an ExecutorService (recommended for most applications)
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> handleRequest(request1));
executor.submit(() -> handleRequest(request2));
} // Auto-closes and waits for completionIdentifying Virtual Threads
Thread current = Thread.currentThread();
// Check if current thread is virtual
boolean isVirtual = current.isVirtual();
// Virtual threads have a distinct toString() format
System.out.println(current);
// Platform: Thread[#1,main,5,main]
// Virtual: VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1When to Use Virtual Threads
Ideal Use Cases
Virtual threads excel in I/O-bound workloads where threads spend significant time waiting:
- Web servers handling many concurrent requests - REST APIs, GraphQL endpoints
- Microservices with multiple downstream calls - Service mesh, API gateways
- Database-heavy applications - CRUD operations, batch processing
- Fan-out/fan-in patterns - Parallel API calls, map-reduce style operations
// Perfect use case: Fan-out pattern with multiple I/O operations
public UserProfile enrichUserProfile(String userId) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<User> userFuture = executor.submit(() -> userService.getUser(userId));
Future<List<Order>> ordersFuture = executor.submit(() -> orderService.getOrders(userId));
Future<Preferences> prefsFuture = executor.submit(() -> prefService.getPreferences(userId));
// All three calls happen concurrently on virtual threads
return new UserProfile(
userFuture.get(),
ordersFuture.get(),
prefsFuture.get()
);
}
}When NOT to Use Virtual Threads
-
CPU-bound workloads - Number crunching, video encoding, cryptographic operations. Virtual threads provide no benefit here; use platform threads with a pool sized to your CPU cores.
-
Long-running computations without blocking points - The JVM needs blocking operations to yield the carrier thread.
-
Code holding locks during blocking operations - This “pins” the virtual thread to its carrier (more on this below).
// Anti-pattern: CPU-bound work gains nothing from virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// This just creates overhead without benefit
executor.submit(() -> computePrimes(1_000_000)); // CPU-bound
}
// Better: Use platform threads sized to CPU cores
ExecutorService cpuExecutor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);Deep Dive: Why Virtual Threads Don’t Help CPU-Bound Tasks
This is one of the most misunderstood aspects of virtual threads. Let’s break down exactly why they provide no benefit (and can actually hurt) for CPU-bound work.
The Core Mechanism
Virtual threads achieve their scalability through yielding during blocking operations. When a virtual thread hits a blocking call, the JVM captures the continuation, unmounts the virtual thread, and runs another one on the same carrier. This is the entire source of their efficiency.
CPU-bound tasks never block. They’re continuously computing, which means they never yield:
┌─────────────────────────────────────────────────────────────────┐
│ I/O-Bound Task on Virtual Thread │
│ │
│ VT-1: [compute]──[BLOCK: HTTP call]──────────[compute]──[done] │
│ │ ▲ │
│ ▼ │ │
│ (unmount, carrier runs VT-2) (remount when I/O done) │
│ │
│ Carrier utilisation: HIGH (always running something) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ CPU-Bound Task on Virtual Thread │
│ │
│ VT-1: [compute──compute──compute──compute──compute──compute] │
│ │
│ (never yields, monopolises carrier the entire time) │
│ │
│ Carrier utilisation: HIGH but... only running ONE virtual thread│
└─────────────────────────────────────────────────────────────────┘The Scheduler Bottleneck
The default virtual thread scheduler uses a ForkJoinPool with parallelism equal to your CPU cores. On an 8-core machine:
// You have 8 carrier threads (on an 8-core machine)
// You submit 1000 CPU-bound tasks to virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1000; i++) {
executor.submit(() -> {
// Pure CPU work - matrix multiplication, hashing, etc.
return computeIntensiveTask();
});
}
}What happens:
- 1000 virtual threads are created (cheap, ~1KB each)
- 8 virtual threads get mounted on the 8 carrier threads
- Those 8 run to completion, monopolising their carriers
- The other 992 virtual threads sit waiting in a queue
- No parallelism benefit beyond what 8 platform threads would give you
You’ve created 1000 objects, added scheduling overhead, but you’re still only running 8 computations in parallel.
The Overhead Cost
Virtual threads aren’t free. They add continuation allocation, scheduling overhead, and stack management. For I/O-bound work, this overhead is negligible compared to milliseconds of I/O wait. For CPU-bound work, it’s measurable:
// Benchmarking CPU-bound work
@Benchmark
public void platformThreads() {
try (var executor = Executors.newFixedThreadPool(8)) {
var futures = IntStream.range(0, 1000)
.mapToObj(i -> executor.submit(() -> fibonacci(35)))
.toList();
futures.forEach(f -> f.get());
}
}
@Benchmark
public void virtualThreads() {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = IntStream.range(0, 1000)
.mapToObj(i -> executor.submit(() -> fibonacci(35)))
.toList();
futures.forEach(f -> f.get());
}
}
// Typical results:
// platformThreads: 1.23 seconds
// virtualThreads: 1.31 seconds (slower due to overhead!)Visual Timeline Comparison
Scenario: 4 carriers, processing tasks over time
═══════════════════════════════════════════════════════════════
I/O-BOUND TASKS (Virtual Threads shine)
────────────────────────────────────────
Each task: 5ms compute, 100ms I/O wait, 5ms compute
Carrier 1: [VT1]─░░░░░░░░░░─[VT1]╾[VT5]─░░░░░░░░░░─[VT5]╾[VT9]...
Carrier 2: [VT2]─░░░░░░░░░░─[VT2]╾[VT6]─░░░░░░░░░░─[VT6]╾[VT10]...
Carrier 3: [VT3]─░░░░░░░░░░─[VT3]╾[VT7]─░░░░░░░░░░─[VT7]╾[VT11]...
Carrier 4: [VT4]─░░░░░░░░░░─[VT4]╾[VT8]─░░░░░░░░░░─[VT8]╾[VT12]...
░ = Virtual thread blocked, carrier runs other VTs
Result: 100 tasks complete in ~250ms (massive parallelism!)
CPU-BOUND TASKS (Virtual Threads add nothing)
─────────────────────────────────────────────
Each task: 110ms pure compute
Carrier 1: [═══════VT1═══════][═══════VT5═══════][═══════VT9════...
Carrier 2: [═══════VT2═══════][═══════VT6═══════][═══════VT10═══...
Carrier 3: [═══════VT3═══════][═══════VT7═══════][═══════VT11═══...
Carrier 4: [═══════VT4═══════][═══════VT8═══════][═══════VT12═══...
═ = Virtual thread computing, no yielding possible
Result: 100 tasks complete in ~2750ms (same as 4 platform threads)Hybrid Executor Strategy
Most real applications have mixed workloads. Use the right executor for each task type:
public class ComputeService {
// For CPU-bound work: platform threads, sized to cores
private final ExecutorService cpuExecutor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
// For I/O-bound work: virtual threads
private final ExecutorService ioExecutor =
Executors.newVirtualThreadPerTaskExecutor();
public Result process(Request request) {
// Fan out I/O calls on virtual threads
CompletableFuture<Data> dataFuture = CompletableFuture.supplyAsync(
() -> fetchFromDatabase(request.id()),
ioExecutor
);
// Heavy computation on platform thread pool
CompletableFuture<Analysis> analysisFuture = dataFuture.thenApplyAsync(
data -> performHeavyAnalysis(data), // CPU-bound
cpuExecutor
);
return analysisFuture.join();
}
}Quick Reference
| Aspect | I/O-Bound | CPU-Bound |
|---|---|---|
| Blocking points | Many | None |
| Can yield carrier | Yes | No |
| Virtual thread benefit | Massive | None |
| Effective parallelism | Millions of concurrent ops | Limited to CPU cores |
| Recommended executor | newVirtualThreadPerTaskExecutor() | newFixedThreadPool(cores) |
The mental model: Virtual threads let you scale the number of “things waiting” without cost. They don’t let you scale the number of “things computing” beyond your CPU capacity.
Internal Implementation: How Virtual Threads Work
Understanding the internals helps you write better code and debug issues effectively.
The Carrier Thread Model
Virtual threads run on top of carrier threads, which are platform threads managed by a ForkJoinPool. When a virtual thread performs a blocking operation, the JVM:
- Saves the virtual thread’s stack (continuation) to the heap
- Unmounts it from the carrier thread
- Mounts another runnable virtual thread onto the carrier
- When the blocking operation completes, the original virtual thread becomes runnable again
┌─────────────────────────────────────────────────────────────┐
│ Virtual Threads │
│ VT-1 (blocked) VT-2 (running) VT-3 (runnable) │
│ │ │ │ │
│ ▼ ▼ │ │
│ [saved to heap] [mounted on] │ │
│ │ │ │
├──────────────────────────┼───────────────────┼──────────────┤
│ Carrier Threads (ForkJoinPool) │
│ [CT-1] [CT-2] [CT-3] │
│ │ │ │ │
├────────────────────────┼─────────┼─────────┼────────────────┤
│ Operating System │
│ [OS Thread] [OS Thread] [OS Thread] │
└─────────────────────────────────────────────────────────────┘Continuations: The Magic Behind Virtual Threads
A continuation is a representation of “the rest of the computation.” When a virtual thread blocks, its continuation (including call stack and local variables) is captured and stored on the heap.
// Conceptually, what happens during a blocking call:
void handleRequest() {
User user = userService.getUser(id); // <-- blocking point
// When getUser() blocks on I/O:
// 1. Continuation captured: [stack frame: handleRequest, local: id]
// 2. Virtual thread unmounted from carrier
// 3. Carrier thread runs other virtual threads
// 4. When I/O completes: continuation restored, execution resumes
process(user);
}The Scheduler
The default scheduler is a work-stealing ForkJoinPool with parallelism equal to availableProcessors(). You can tune it:
// JVM flags to configure the scheduler
// -Djdk.virtualThreadScheduler.parallelism=8 // Number of carrier threads
// -Djdk.virtualThreadScheduler.maxPoolSize=256 // Max carrier threads
// -Djdk.virtualThreadScheduler.minRunnable=1 // Min runnable threads before compensationThread Pinning: The Critical Pitfall
Pinning occurs when a virtual thread cannot be unmounted from its carrier thread. This defeats the purpose of virtual threads and can cause throughput issues.
What Causes Pinning
- Synchronised blocks/methods during blocking operations
- Native methods or foreign functions
// PROBLEMATIC: This pins the virtual thread
public synchronized void updateCache() {
String data = httpClient.send(request); // Blocks while holding monitor
cache.put(key, data);
}
// When VT-1 executes this:
// 1. VT-1 acquires monitor (enters synchronized)
// 2. VT-1 blocks on HTTP call
// 3. VT-1 CANNOT unmount because it holds the monitor
// 4. Carrier thread is stuck, reducing throughputDetecting Pinning
// Enable pinning detection via JVM flags
// -Djdk.tracePinnedThreads=full // Prints stack trace when pinning occurs
// -Djdk.tracePinnedThreads=short // Prints summary
// In logs, you'll see:
// Thread[#29,ForkJoinPool-1-worker-1,5,CarrierThreads]
// java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
// ...stack trace showing where pinning occurred...Solving Pinning
Replace synchronized with ReentrantLock:
// SOLUTION: Use ReentrantLock instead
private final ReentrantLock lock = new ReentrantLock();
public void updateCache() {
lock.lock();
try {
String data = httpClient.send(request); // Can unmount while blocked
cache.put(key, data);
} finally {
lock.unlock();
}
}Important: Not all synchronized usage causes pinning. Pinning only occurs when the virtual thread blocks while holding the monitor. Short, non-blocking synchronized blocks are fine.
Best Practices
1. Don’t Pool Virtual Threads
Pooling virtual threads defeats their purpose. They’re designed to be created per-task.
// WRONG: Treating virtual threads like platform threads
ExecutorService pool = Executors.newFixedThreadPool(100, Thread.ofVirtual().factory());
// CORRECT: Create one per task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var task : tasks) {
executor.submit(task);
}
}2. Use Structured Concurrency (Preview in Java 21+)
Structured concurrency ensures child tasks complete when the parent scope exits, preventing resource leaks and improving debuggability.
// Using StructuredTaskScope (preview feature)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<User> user = scope.fork(() -> fetchUser(userId));
Supplier<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
scope.join(); // Wait for all tasks
scope.throwIfFailed(); // Propagate any exceptions
return new Response(user.get(), orders.get());
}
// All forked tasks are guaranteed to complete when exiting this block3. Leverage Scoped Values Over ThreadLocal
ThreadLocal has issues with virtual threads: memory isn’t reclaimed until the thread dies, and inheritance is expensive. Use ScopedValue instead (preview).
// Avoid: ThreadLocal with virtual threads
private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
// Prefer: ScopedValue (preview)
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
void handleRequest(User user) {
ScopedValue.where(CURRENT_USER, user).run(() -> {
// CURRENT_USER.get() available here and in all called methods
processRequest();
});
// Value automatically cleared when scope exits
}4. Keep Thread-Per-Task Semantics
Virtual threads work best with the simple thread-per-task model. Don’t overcomplicate with complex scheduling.
// Clean and efficient with virtual threads
public List<Price> getPrices(List<String> symbols) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
return symbols.stream()
.map(symbol -> executor.submit(() -> fetchPrice(symbol)))
.map(this::getUnchecked)
.toList();
}
}
private <T> T getUnchecked(Future<T> future) {
try {
return future.get();
} catch (Exception e) {
throw new RuntimeException(e);
}
}5. Monitor and Observe Properly
Traditional thread metrics don’t translate directly. Focus on:
// Get virtual thread statistics via JFR (Java Flight Recorder)
// Enable: -XX:StartFlightRecording=filename=recording.jfr
// Key JFR events for virtual threads:
// - jdk.VirtualThreadStart
// - jdk.VirtualThreadEnd
// - jdk.VirtualThreadPinned
// - jdk.VirtualThreadSubmitFailed
// Programmatic monitoring
Thread.currentThread().threadId(); // Unique ID (can be very large for VTs)
Thread.currentThread().isVirtual(); // Check thread type6. Design for High Cardinality
With virtual threads, you can have millions of concurrent operations. Design your downstream systems accordingly:
// Consider: Connection pool sizing
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(50); // Database can only handle so many connections
// Virtual threads will queue waiting for connections - that's fine!
// But you need appropriate timeouts
config.setConnectionTimeout(30000);
// Consider: Rate limiting for external services
RateLimiter limiter = RateLimiter.create(1000); // 1000 requests/second
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var request : requests) {
executor.submit(() -> {
limiter.acquire(); // Blocks (unmounts) until permitted
return callExternalApi(request);
});
}
}Complete Example: Building a Concurrent API Gateway
Here’s a practical example combining the concepts:
public class ApiGateway {
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
private final Duration timeout = Duration.ofSeconds(5);
public record AggregatedResponse(
UserData user,
List<Order> orders,
Recommendations recommendations
) {}
public AggregatedResponse aggregate(String userId) {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Fork three concurrent tasks on virtual threads
var userTask = scope.fork(() -> fetchUser(userId));
var ordersTask = scope.fork(() -> fetchOrders(userId));
var recsTask = scope.fork(() -> fetchRecommendations(userId));
// Wait with timeout
scope.joinUntil(Instant.now().plus(timeout));
scope.throwIfFailed();
return new AggregatedResponse(
userTask.get(),
ordersTask.get(),
recsTask.get()
);
} catch (Exception e) {
throw new GatewayException("Aggregation failed", e);
}
}
private UserData fetchUser(String userId) throws Exception {
var request = HttpRequest.newBuilder()
.uri(URI.create("http://user-service/users/" + userId))
.build();
// This blocks but unmounts the virtual thread
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return parseUser(response.body());
}
private List<Order> fetchOrders(String userId) throws Exception {
var request = HttpRequest.newBuilder()
.uri(URI.create("http://order-service/orders?userId=" + userId))
.build();
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return parseOrders(response.body());
}
private Recommendations fetchRecommendations(String userId) throws Exception {
var request = HttpRequest.newBuilder()
.uri(URI.create("http://rec-service/recommendations/" + userId))
.build();
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return parseRecommendations(response.body());
}
}Spring Boot Integration
Spring Boot 3.2+ has built-in support for virtual threads:
# application.yml
spring:
threads:
virtual:
enabled: true # Enables virtual threads for request handling// Or configure programmatically
@Configuration
public class VirtualThreadConfig {
@Bean
TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
// For async operations
@Bean
AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
}Migration Checklist
When migrating existing code to virtual threads:
- Audit
synchronizedblocks - Replace withReentrantLockwhere blocking occurs inside - Check
ThreadLocalusage - ConsiderScopedValueor ensure proper cleanup - Review thread pool sizing - Remove artificial limits; use virtual thread executors
- Verify library compatibility - Ensure JDBC drivers, HTTP clients support virtual threads
- Update monitoring - Add JFR events for virtual thread metrics
- Test under load - Virtual threads change backpressure characteristics
- Check connection pool sizes - Virtual threads don’t magically increase database capacity
Summary
Virtual threads enable writing simple, blocking code that scales like reactive programming. They’re not faster, but they’re more scalable and dramatically simpler than the alternatives.
Key takeaways:
- Use for I/O-bound workloads, not CPU-bound
- Create one per task; never pool them
- Avoid
synchronizedwith blocking operations (useReentrantLock) - Embrace structured concurrency with
StructuredTaskScope - Prefer
ScopedValueoverThreadLocal - Remember: downstream resources (DBs, APIs) still have limits
Virtual threads don’t eliminate the need to understand concurrency, but they remove much of the accidental complexity we’ve been dealing with for decades.
- Further reading: Java SE 25 Core Libraries - Virtual Threads
- Full example for virtual threads: VirtualThreadExample