Java 21 Virtual Threads: The Complete Guide to Million-Thread Concurrency
Master Java 21 virtual threads to scale from 5,000 to 5,000,000 concurrent operations. Practical examples, benchmarks, and migration guide.
Moshiour Rahman
The Problem: Thread Limits Are Killing Your App
You’re building a service that needs to handle thousands of concurrent operations - API calls, database queries, file I/O. You spin up a thread pool:
var executor = Executors.newFixedThreadPool(5000);
Then you try to scale to 20,000 concurrent requests:
[warning][os,thread] Failed to start thread - pthread_create failed
java.lang.OutOfMemoryError: unable to create native thread
OS threads are expensive. Each one costs ~1MB of stack memory. Your server just hit the wall.
Quick Answer (TL;DR)
// Before Java 21: Limited to ~5,000 threads
var executor = Executors.newFixedThreadPool(5000);
// Java 21+: Millions of virtual threads, zero config
var executor = Executors.newVirtualThreadPerTaskExecutor();
// Or even simpler with Streams
Stream.of(tasks)
.parallel()
.collect(ParallelCollectors.parallel(this::process, toList()))
.join();
Virtual threads scale to millions with no code changes.
What Are Virtual Threads?
Virtual threads (Project Loom) are lightweight threads managed by the JVM, not the OS. They’re scheduled onto a small pool of OS “carrier” threads.

Key Differences
| Aspect | OS Threads | Virtual Threads |
|---|---|---|
| Memory | ~1MB stack each | ~1KB each |
| Creation cost | Expensive (syscall) | Cheap (JVM managed) |
| Max count | ~5,000-20,000 | Millions |
| Blocking behavior | Blocks carrier | Unmounts, carrier freed |
| Best for | CPU-bound work | I/O-bound work |
Benchmarks: OS Threads vs Virtual Threads
Let’s simulate a realistic I/O-bound workload - fetching user data with 1 second latency:
private static String fetchUser(int id) {
try {
Thread.sleep(1000); // Simulate I/O latency
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "user-" + id;
}
Test 1: 5,000 Concurrent Operations
OS Threads:
var executor = Executors.newFixedThreadPool(5000);
// Result: 1,321 ms ✓
Virtual Threads:
var executor = Executors.newVirtualThreadPerTaskExecutor();
// Result: 1,101 ms ✓ (17% faster!)
Test 2: 20,000 Concurrent Operations
OS Threads:
[warning][os,thread] Failed to start thread "pool-1-thread-16111"
// CRASHED ✗
Virtual Threads:
// Result: 1,219 ms ✓
Test 3: Scaling to Millions
| Concurrent Ops | OS Threads | Virtual Threads |
|---|---|---|
| 5,000 | 1,321 ms | 1,101 ms |
| 20,000 | CRASHED | 1,219 ms |
| 100,000 | CRASHED | 1,587 ms |
| 1,000,000 | CRASHED | 6,416 ms |
| 5,000,000 | CRASHED | 25,952 ms |
Virtual threads scale linearly. OS threads hit a wall.
Step-by-Step Migration Guide
Step 1: Update to Java 21
<properties>
<java.version>21</java.version>
</properties>
Step 2: Replace Thread Pools
Before:
// Fixed pool - limited by OS
ExecutorService executor = Executors.newFixedThreadPool(100);
// Cached pool - can explode memory
ExecutorService executor = Executors.newCachedThreadPool();
After:
// Virtual threads - scales automatically
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Step 3: Use try-with-resources
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = ids.stream()
.map(id -> executor.submit(() -> fetchUser(id)))
.toList();
List<String> results = futures.stream()
.map(f -> {
try {
return f.get();
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList();
}
Step 4: Or Use Parallel Collectors (Recommended)
<dependency>
<groupId>com.pivovarit</groupId>
<artifactId>parallel-collectors</artifactId>
<version>3.0.0</version>
</dependency>
// Clean, functional style - virtual threads by default in v3.x
List<String> results = Stream.iterate(0, i -> i + 1)
.limit(100_000)
.collect(ParallelCollectors.parallel(
id -> fetchUser(id),
toList()
))
.join();
When NOT to Use Virtual Threads
Virtual threads are not a silver bullet. Avoid them for:
| Scenario | Why | Use Instead |
|---|---|---|
| CPU-bound work | No benefit - still limited by cores | ForkJoinPool |
| Synchronized blocks | Pins carrier thread | ReentrantLock |
| Native code/JNI | May pin carrier | OS threads |
| Thread-local heavy | Memory overhead per VT | Scoped values |
Detecting Pinning
# Add JVM flag to detect carrier pinning
java -Djdk.tracePinnedThreads=full -jar app.jar
Fix Synchronized → ReentrantLock
Before (pins carrier):
synchronized (lock) {
// blocking I/O here pins the carrier thread
database.query();
}
After (carrier-friendly):
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
database.query();
} finally {
lock.unlock();
}
Spring Boot Integration
Spring Boot 3.2+ has built-in virtual thread support:
# application.yaml
spring:
threads:
virtual:
enabled: true
This automatically configures:
- Tomcat to use virtual threads for request handling
@Asyncmethods to run on virtual threads- Scheduled tasks on virtual threads
Manual Configuration
@Configuration
public class VirtualThreadConfig {
@Bean
AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(
Executors.newVirtualThreadPerTaskExecutor()
);
}
@Bean
TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(
Executors.newVirtualThreadPerTaskExecutor()
);
};
}
}
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
Using synchronized with blocking I/O | Carrier pinning, poor scaling | Switch to ReentrantLock |
| Large thread-locals | High memory per VT | Use scoped values (preview) |
| Expecting CPU speedup | No improvement | Use ForkJoinPool for CPU work |
| Pooling virtual threads | Defeats the purpose | Create new VT per task |
Don’t Pool Virtual Threads
Wrong:
// Don't do this - pooling defeats the purpose
var pool = Executors.newFixedThreadPool(100,
Thread.ofVirtual().factory());
Right:
// Create a new virtual thread per task
var executor = Executors.newVirtualThreadPerTaskExecutor();
Production Checklist
- Java 21+ in production
- Spring Boot 3.2+ (if using Spring)
- Replace
newFixedThreadPoolwithnewVirtualThreadPerTaskExecutor - Audit
synchronizedblocks for blocking I/O - Test with
-Djdk.tracePinnedThreads=full - Monitor with JFR for virtual thread events
- Load test to verify scaling
Code Repository
Complete examples with benchmarks:
GitHub: techyowls/techyowls-io-blog-public/java-21-virtual-threads
git clone https://github.com/techyowls/techyowls-io-blog-public.git
cd techyowls-io-blog-public/java-21-virtual-threads
./mvnw test # Runs benchmarks
Further Reading
- Spring AI MCP Guide - Uses virtual threads for AI workloads
- Java 21 Features Overview
- Project Loom JEP 444
Ship concurrent Java apps that actually scale. Follow TechyOwls for more practical guides.
Moshiour Rahman
Software Architect & AI Engineer
Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.
Related Articles
Spring Boot 3 Virtual Threads: Complete Guide to Java 21 Concurrency
Master Java 21 virtual threads in Spring Boot. Learn configuration, benchmarks, pitfalls, and scale from thousands to millions of concurrent operations reliably.
Spring BootRedis Caching with Spring Boot: Complete Implementation Guide
Master Redis caching in Spring Boot applications. Learn cache configuration, annotations, TTL management, and performance optimization techniques.
Spring BootRedis Caching Patterns: The Difference Between 200ms and 2ms Response Times
Beyond @Cacheable - learn cache-aside, write-through, read-through patterns, cache invalidation strategies, and how to avoid the thundering herd problem.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.