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

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.

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.

Advertisement

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.