Spring Boot 6 min read

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.

MR

Moshiour Rahman

· Updated May 9, 2026

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.

Virtual Threads Architecture

Key Differences

AspectOS ThreadsVirtual Threads
Memory~1MB stack each~1KB each
Creation costExpensive (syscall)Cheap (JVM managed)
Max count~5,000-20,000Millions
Blocking behaviorBlocks carrierUnmounts, carrier freed
Best forCPU-bound workI/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 OpsOS ThreadsVirtual Threads
5,0001,321 ms1,101 ms
20,000CRASHED1,219 ms
100,000CRASHED1,587 ms
1,000,000CRASHED6,416 ms
5,000,000CRASHED25,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();
}
<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:

ScenarioWhyUse Instead
CPU-bound workNo benefit - still limited by coresForkJoinPool
Synchronized blocksPins carrier threadReentrantLock
Native code/JNIMay pin carrierOS threads
Thread-local heavyMemory overhead per VTScoped 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
  • @Async methods 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

PitfallSymptomFix
Using synchronized with blocking I/OCarrier pinning, poor scalingSwitch to ReentrantLock
Large thread-localsHigh memory per VTUse scoped values (preview)
Expecting CPU speedupNo improvementUse ForkJoinPool for CPU work
Pooling virtual threadsDefeats the purposeCreate 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 newFixedThreadPool with newVirtualThreadPerTaskExecutor
  • Audit synchronized blocks 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


Ship concurrent Java apps that actually scale. Follow TechyOwls for more practical guides.

Is your AI API down right now? Check live status for OpenAI, Claude, Gemini, Groq, and more.
Check status →
MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

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

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.