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
Advertisement
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.
Advertisement
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 virtual threads in Spring Boot 3. Learn configuration, performance benchmarks, when to use them, common pitfalls, and production-ready patterns for high-throughput applications.
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.