Java 21 shipped virtual threads as a production feature, and the internet immediately declared thread pools dead. The reality is more nuanced — virtual threads solve a real problem elegantly, but misunderstanding what they actually do will lead you to make architecture decisions you’ll regret. Here’s the honest technical breakdown.
⚡ TL;DR: Virtual threads make blocking I/O cheap by unmounting from the carrier thread during waits. They do NOT make CPU-bound code faster. The win is massive for I/O-heavy services. Use them there, keep thread pools for CPU-bound work.
What Virtual Threads Actually Are
Traditional Java threads map 1:1 to OS threads. Each costs ~1MB of stack memory and switching between them requires a kernel context switch (~1-10 microseconds). For a server handling 10,000 concurrent connections, that’s 10GB of memory just for thread stacks.
Virtual threads are managed by the JVM, not the OS. Many virtual threads multiplex onto a small pool of OS threads (called carrier threads — typically one per CPU core). When a virtual thread blocks on I/O, it is unmounted from its carrier thread, which immediately picks up another virtual thread. No kernel context switch. No wasted memory.
// Creating a virtual thread — Java 21+
Thread vThread = Thread.ofVirtual().start(() -> {
// This blocks, but doesn't block the carrier thread
String result = httpClient.send(request, BodyHandlers.ofString()).body();
System.out.println(result);
});
// Creating with ExecutorService (recommended for production)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> future = executor.submit(() -> {
return httpClient.send(request, BodyHandlers.ofString()).body();
});
System.out.println(future.get());
}
// One virtual thread per request — the new pattern
// Previously: thread pool of 200 threads = max 200 concurrent requests
// Now: one virtual thread per request, JVM manages carrier threads
// Result: can handle 100,000+ concurrent requests on modest hardware
The Benchmark That Reveals the Truth
Master Java concurrency
→ Java Programming Masterclass (Udemy) — Covers virtual threads, concurrency, and the complete Java ecosystem.
Sponsored links. We may earn a commission at no extra cost to you.
import java.util.concurrent.*;
import java.net.http.*;
// Test 1: Traditional thread pool — 200 threads, 10,000 requests
ExecutorService pool = Executors.newFixedThreadPool(200);
long start = System.currentTimeMillis();
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
futures.add(pool.submit(() -> {
Thread.sleep(100); // Simulate 100ms I/O wait
return "done";
}));
}
futures.forEach(f -> f.get());
System.out.println("Thread pool: " + (System.currentTimeMillis() - start) + "ms");
// Typical result: ~5,200ms (10,000 tasks / 200 threads * 100ms)
// Test 2: Virtual threads — unlimited concurrency
ExecutorService vPool = Executors.newVirtualThreadPerTaskExecutor();
start = System.currentTimeMillis();
futures.clear();
for (int i = 0; i < 10_000; i++) {
futures.add(vPool.submit(() -> {
Thread.sleep(100); // Same 100ms I/O wait
return "done";
}));
}
futures.forEach(f -> f.get());
System.out.println("Virtual threads: " + (System.currentTimeMillis() - start) + "ms");
// Typical result: ~120ms (all 10,000 run concurrently!)
// Speedup for I/O-bound: 43x
// Speedup for CPU-bound: ~0x (virtual threads don't help here)
The Pinning Problem — The Gotcha Nobody Mentions
Virtual threads can get pinned to their carrier thread in two situations, completely eliminating the concurrency benefit:
// ❌ PINNING CASE 1: synchronized block
// Virtual thread CANNOT unmount while inside synchronized
synchronized(lock) {
httpClient.send(request, handler); // PINNED — blocks carrier thread!
}
// Fix: use ReentrantLock instead of synchronized
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
httpClient.send(request, handler); // Can unmount — NOT pinned
} finally {
lock.unlock();
}
// ❌ PINNING CASE 2: native methods
// JNI calls pin the virtual thread to carrier
// Common culprits: JDBC drivers (many use native code)
// Check your drivers! PostgreSQL JDBC is OK, some Oracle drivers pin
// Detect pinning in your app:
// Run with: -Djdk.tracePinnedThreads=full
// Or programmatically:
System.setProperty("jdk.tracePinnedThreads", "short");
Virtual Threads + Spring Boot — Production Setup
// Spring Boot 3.2+ — enable virtual threads for all request handling
// application.properties:
// spring.threads.virtual.enabled=true
// Or programmatically (Spring Boot 3.2+):
@Configuration
public class VirtualThreadConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
return handler -> handler.setExecutor(
Executors.newVirtualThreadPerTaskExecutor()
);
}
}
// For @Async methods:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
// Result: every HTTP request gets its own virtual thread
// Your service can now handle 10x more concurrent connections
// without changing any business logic
When NOT to Use Virtual Threads
- ❌ CPU-bound tasks — image processing, cryptography, ML inference. Virtual threads offer zero benefit. Use ForkJoinPool or parallel streams.
- ❌ Code with lots of synchronized blocks — pinning kills the benefit. Refactor to ReentrantLock first.
- ❌ ThreadLocal-heavy code — virtual threads make ThreadLocal memory leaks worse since threads aren’t pooled and returned to a pool.
- ✅ HTTP clients — making many outbound API calls
- ✅ Database queries — waiting for query results (with pinning-safe drivers)
- ✅ File I/O — reading/writing files at scale
- ✅ WebSocket servers — one virtual thread per connection scales to millions
Cheat Sheet: Virtual Threads Migration
// Before — fixed thread pool (old way)
ExecutorService old = Executors.newFixedThreadPool(200);
// After — virtual threads (one line change)
ExecutorService modern = Executors.newVirtualThreadPerTaskExecutor();
// Check Java version
System.out.println(Runtime.version()); // Need 21+
// Enable preview features for Java 19-20 (not needed for 21+):
// javac --enable-preview --release 21 YourApp.java
// Monitor virtual thread count (should be in thousands for I/O servers):
ThreadMXBean tmx = ManagementFactory.getThreadMXBean();
System.out.println("Platform threads: " + tmx.getThreadCount());
// Virtual thread count needs JFR or custom monitoring
For the server-side performance picture, also check the Node.js event loop guide — the concepts of non-blocking I/O and cooperative scheduling are directly analogous to what Java virtual threads implement. And if you’re running Java on AWS Lambda, the Lambda cold start fix guide covers SnapStart, which interacts directly with virtual thread initialization.
Recommended resources
- System Design Interview Vol 2 — Covers concurrency models at scale, including thread pool sizing, event-driven vs thread-per-request architectures — the exact context in which virtual threads shine.
- Designing Data-Intensive Applications (DDIA) — Kleppmann’s chapter on distributed systems concurrency is the best complement to understanding when virtual threads help and when they don’t.
Disclosure: This post contains affiliate links. If you purchase through these links, CheatCoders earns a small commission at no extra cost to you. We only recommend tools and books we genuinely find valuable.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.

Pingback: Hidden AWS Lambda Cold Start Fix — Zero Cost, Zero Latency - CheatCoders