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 hardwareThe Benchmark That Reveals the Truth
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 logicWhen 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 monitoringFor 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.
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.
Free Weekly Newsletter
🚀 Don’t Miss the Next Cheat Code
You just read something most developers never learn. Get more secrets like this delivered every week — JavaScript internals, Python optimizations, AWS architectures, system design, and AI workflows.
Related: Python's GIL Explained: Why Your Threads Aren't Actually Parallel (And the Fix) · Lambda SnapStart for Python: Pitfalls Beyond the Java Marketing · JavaScript WeakMap and WeakRef: The Memory Management Primitives Nobody Teaches
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Newly added
- AWS Systems Manager Parameter Store: Hierarchical Runtime Config for Coding Agents
- Amazon Cognito Identity Pools: Federate Coding-Agent Tool Callers Without Long-Lived Keys
- Amazon Athena for Coding Agents: Query Tool Audit Logs and Eval Traces on S3 Without a Warehouse
- AWS Batch for Coding Agents: Parallel Overnight PR Reviews and Long-Running Jobs Without Lambda Timeouts
- Amazon Bedrock Prompt Management: Version and A/B Test Coding-Agent System Prompts
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
Already subscribed? or open the subscribe page.
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