Complete Guide to Java 21 Virtual Threads

What Are Virtual Threads?

Virtual Threads (JEP 444), officially released in Java 21, are lightweight threads managed by the JVM. Unlike traditional Platform Threads that map 1:1 to OS threads, millions of Virtual Threads can run on top of a small number of OS threads (Carrier Threads).

Think of it like virtual memory. Just as the OS maps limited physical RAM to a large virtual address space, the JVM maps a small number of OS threads to a large number of Virtual Threads.

AspectPlatform ThreadVirtual Thread
Mapping1:1 with OS threadM:N (many to few OS threads)
Memory~1MB per thread~1-2KB per thread
Max concurrencyThousandsMillions
SchedulingOS schedulerJVM ForkJoinPool
PoolingThread pool requiredNo pooling needed
Best forCPU-intensiveI/O-bound

The key takeaway: Virtual Threads do not make code run “faster.” Their purpose is throughput scaling. By yielding the Carrier Thread to other tasks during I/O wait time, they dramatically increase the number of concurrent operations.

Basic Usage

Thread.ofVirtual()

The most basic way to create a Virtual Thread.

public class VirtualThreadBasic {
    public static void main(String[] args) throws InterruptedException {
        // Create and start a Virtual Thread
        Thread vThread = Thread.ofVirtual()
            .name("my-vthread")
            .start(() -> {
                System.out.println("Thread: " + Thread.currentThread());
                System.out.println("Is virtual? " + Thread.currentThread().isVirtual());
            });
        vThread.join();
        // Output:
        // Thread: VirtualThread[#21,my-vthread]/runnable@ForkJoinPool-1-worker-1
        // Is virtual? true
    }
}

In practice, Executors.newVirtualThreadPerTaskExecutor() is used more often. It creates a new Virtual Thread for each task, and when used with try-with-resources, it automatically waits for all tasks to complete.

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class VirtualThreadExecutor {
    public static void main(String[] args) throws Exception {
        // Process 10,000 concurrent tasks
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futures = new ArrayList<>();

            for (int i = 0; i < 10_000; i++) {
                final int taskId = i;
                futures.add(executor.submit(() -> {
                    // Simulating I/O (in practice: HTTP calls, DB queries, etc.)
                    Thread.sleep(100);
                    return "result-" + taskId;
                }));
            }

            // Print only the first 5 results
            for (int i = 0; i < 5; i++) {
                System.out.println(futures.get(i).get());
            }
            System.out.println("... total " + futures.size() + " tasks completed");
        }
        // All Virtual Threads complete when the try block exits
        // Output:
        // result-0
        // result-1
        // result-2
        // result-3
        // result-4
        // ... total 10000 tasks completed
    }
}

Where a Platform Thread pool would handle 200 at a time, you can now scale to 10,000 concurrent tasks. The only code change is replacing newFixedThreadPool(200) with newVirtualThreadPerTaskExecutor().

Performance Comparison

Measured data from a Spring Boot environment (Tomcat, blocking I/O).

MetricPlatform Thread (200 pool)Virtual ThreadImprovement
1,000 concurrent requestsQueuing startsAll processed~2x
10,000+ concurrent requestsTimeouts occurSmooth processing~3x
I/O response latency448ms319ms28.8% decrease
Memory usageBaseline-43%43% savings

For CPU-bound work, there is virtually no performance difference. Virtual Threads shine in I/O-bound services.

3 Key Pitfalls

1. Pinning — The synchronized Block Trap

When a Virtual Thread performs blocking I/O inside a synchronized block, it cannot unmount from the Carrier Thread and becomes pinned. This eliminates the benefits of lightweight threads.

import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;

public class PinningExample {
    // Problem: I/O inside synchronized -> Pinning occurs
    private static final Object syncLock = new Object();

    static void badExample() throws Exception {
        synchronized (syncLock) {
            Thread.sleep(1000);  // Blocking I/O simulation -> Carrier Thread pinned!
        }
    }

    // Solution: Replace with ReentrantLock
    private static final ReentrantLock reentrantLock = new ReentrantLock();

    static void goodExample() throws Exception {
        reentrantLock.lock();
        try {
            Thread.sleep(1000);  // Blocking I/O simulation -> No pinning
        } finally {
            reentrantLock.unlock();
        }
    }

    public static void main(String[] args) throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            // Run 10 concurrent tasks with ReentrantLock
            for (int i = 0; i < 10; i++) {
                executor.submit(() -> {
                    goodExample();
                    System.out.println(Thread.currentThread() + " done");
                    return null;
                });
            }
        }
        System.out.println("All tasks completed (no pinning)");
    }
}

Netflix once experienced an application-wide deadlock caused by synchronized pinning. You can detect pinning with JFR (Java Flight Recorder).

# Detect pinning events
java -Djdk.tracePinnedThreads=full -jar your-app.jar

Note that starting with Java 24 (JEP 491), this issue is fundamentally resolvedsynchronized no longer causes pinning.

2. ThreadLocal Memory Explosion

With a Platform Thread pool (200 threads), ThreadLocal caches hold at most 200 instances. But since Virtual Threads are not pooled, 50,000 concurrent threads means 50,000 instances are created.

import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.Executors;

public class ThreadLocalDanger {
    // Danger: ThreadLocal caching with Virtual Threads
    // 50,000 Virtual Threads -> 50,000 instances created!
    static final ThreadLocal<SimpleDateFormat> BAD_FORMATTER =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

    // Solution: Share a thread-safe immutable object -> only one instance
    static final DateTimeFormatter GOOD_FORMATTER =
        DateTimeFormatter.ofPattern("yyyy-MM-dd");

    public static void main(String[] args) throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 5; i++) {
                executor.submit(() -> {
                    // Use immutable DateTimeFormatter (recommended)
                    String date = LocalDate.now().format(GOOD_FORMATTER);
                    System.out.println(Thread.currentThread().threadId() + " -> " + date);
                    return null;
                });
            }
        }
        // Output:
        // 21 -> 2026-04-07
        // 22 -> 2026-04-07
        // ...
    }
}

3. Protecting Downstream Systems

Virtual Threads are cheap, but DB connection pools and external APIs are not. Use a Semaphore to limit concurrency.

import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

public class SemaphoreExample {
    // Limit concurrent access to match the DB connection pool size
    static final Semaphore dbSemaphore = new Semaphore(3); // Example: max 3 concurrent

    public static void main(String[] args) throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10; i++) {
                final int taskId = i;
                executor.submit(() -> {
                    dbSemaphore.acquire();
                    try {
                        // Simulating a DB query
                        System.out.println("Task " + taskId + " running (concurrent: "
                            + (3 - dbSemaphore.availablePermits()) + "/3)");
                        Thread.sleep(500);
                    } finally {
                        dbSemaphore.release();
                    }
                    return null;
                });
            }
        }
        System.out.println("All tasks completed");
    }
}

Enabling in Spring Boot

With Spring Boot 3.2+ and Java 21, a single line enables Virtual Threads.

# application.properties
spring.threads.virtual.enabled=true

This setting makes Tomcat HTTP request handling, @Async methods, and message listeners (Kafka, RabbitMQ) all run on Virtual Threads.

However, if you are already using WebFlux, Virtual Threads are unnecessary. WebFlux is already non-blocking, and mixing the two can cause issues. Virtual Threads deliver the greatest benefit in Spring MVC (blocking I/O) based services.

Summary

ScenarioRecommendation
I/O-bound services (DB, API calls)Actively use Virtual Threads
CPU-intensive tasks (image processing, encryption)Keep Platform Thread pool
Using Java 21Watch out for synchronized pinning
Using Java 24+Pinning resolved, use with confidence
ThreadLocal cachingReplace with shared immutable objects
Need to limit concurrencyUse Semaphore

Do not pool Virtual Threads. The design intent is to create one per task and discard it. Abandon the old assumption that “threads are expensive” and shift to the new model: “Virtual Threads are nearly free.”

Was this article helpful?