PCSalt
YouTube GitHub
Back to Java
Java · 4 min read

Java 21 — Virtual Threads Explained with Real Examples

Understand Java virtual threads — how they work, when to use them, migration from thread pools, and real-world performance comparisons with platform threads.


Java 21 introduced virtual threads — lightweight threads managed by the JVM instead of the OS. They’re the biggest concurrency change since Java 5’s java.util.concurrent.

The promise: write simple blocking code (like Thread.sleep, JDBC calls, HTTP requests) and scale to millions of concurrent tasks — without reactive frameworks, without callback hell, without Kotlin coroutines.

The problem with platform threads

Traditional Java threads (now called “platform threads”) map 1:1 to OS threads. Each one:

  • Allocates ~1MB of stack memory
  • Costs microseconds for context switching
  • Is limited by the OS (typically thousands, not millions)
// Try creating 100,000 platform threads
public class PlatformThreadDemo {
    public static void main(String[] args) throws Exception {
        long start = System.currentTimeMillis();

        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 100_000; i++) {
            Thread thread = new Thread(() -> {
                try { Thread.sleep(1000); }
                catch (InterruptedException e) { }
            });
            thread.start();
            threads.add(thread);
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("Time: " + (System.currentTimeMillis() - start) + "ms");
        // OutOfMemoryError on most machines
    }
}

This crashes. 100,000 threads × 1MB = 100GB of stack memory. Even 10,000 threads strain most servers.

Virtual threads — the solution

Virtual threads are managed by the JVM. They’re mounted onto a small pool of platform threads (carrier threads) and unmounted when blocked.

public class VirtualThreadDemo {
    public static void main(String[] args) throws Exception {
        long start = System.currentTimeMillis();

        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 100_000; i++) {
            Thread thread = Thread.ofVirtual().start(() -> {
                try { Thread.sleep(1000); }
                catch (InterruptedException e) { }
            });
            threads.add(thread);
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("Time: " + (System.currentTimeMillis() - start) + "ms");
        // Completes in ~1 second
    }
}

100,000 virtual threads, each sleeping for 1 second, complete in about 1 second total. Virtual threads use only a few KB of memory each and share the carrier threads.

How virtual threads work

Virtual Thread 1 ─┐
Virtual Thread 2 ─┤
Virtual Thread 3 ─┤──→ Carrier Thread 1 (OS thread)
Virtual Thread 4 ─┤──→ Carrier Thread 2 (OS thread)
Virtual Thread 5 ─┤
...               │
Virtual Thread N ─┘

When a virtual thread blocks (I/O, sleep, lock):

  1. The JVM unmounts it from the carrier thread
  2. The carrier thread picks up another virtual thread
  3. When the I/O completes, the virtual thread is remounted on any available carrier

From your code’s perspective, Thread.sleep(1000) blocks for 1 second. But the carrier thread isn’t wasted — it serves other virtual threads during that time.

Creating virtual threads

Direct creation

// Start immediately
Thread vt = Thread.ofVirtual().start(() -> {
    System.out.println("Running on: " + Thread.currentThread());
});

// Named (useful for debugging)
Thread vt = Thread.ofVirtual()
    .name("worker-", 0)
    .start(() -> { /* ... */ });

ExecutorService (preferred)

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = urls.stream()
        .map(url -> executor.submit(() -> fetchUrl(url)))
        .toList();

    for (Future<String> future : futures) {
        System.out.println(future.get());
    }
}

newVirtualThreadPerTaskExecutor() creates a new virtual thread for every submitted task. No pool size to configure — that’s the point.

Real-world example: HTTP server

A typical web server handles requests with a thread pool. With 200 threads, it handles 200 concurrent requests. With virtual threads, it handles millions:

// Before: platform thread pool (limited)
var server = HttpServer.create(new InetSocketAddress(8080), 0);
server.setExecutor(Executors.newFixedThreadPool(200));

// After: virtual thread per request (unlimited)
var server = HttpServer.create(new InetSocketAddress(8080), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());

Each request gets its own virtual thread. Blocking I/O (database queries, HTTP calls) doesn’t waste a carrier thread.

Spring Boot with virtual threads

# application.yml (Spring Boot 3.2+)
spring:
  threads:
    virtual:
      enabled: true

One line. Every request handler now runs on a virtual thread. Your existing blocking code (JDBC, RestTemplate, etc.) scales without modification.

When virtual threads help

Virtual threads shine when your application spends most of its time waiting on I/O:

  • Database queries
  • HTTP calls to other services
  • File I/O
  • Network socket operations

Benchmark: 10,000 concurrent HTTP calls

// Platform threads: 200 threads, processing 10,000 calls
try (var executor = Executors.newFixedThreadPool(200)) {
    var futures = IntStream.range(0, 10_000)
        .mapToObj(i -> executor.submit(() -> httpClient.send(request, bodyHandler)))
        .toList();
    futures.forEach(f -> { try { f.get(); } catch (Exception e) {} });
}
// Time: ~50 seconds (200 concurrent, 10,000 / 200 = 50 batches)

// Virtual threads: one per call
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    var futures = IntStream.range(0, 10_000)
        .mapToObj(i -> executor.submit(() -> httpClient.send(request, bodyHandler)))
        .toList();
    futures.forEach(f -> { try { f.get(); } catch (Exception e) {} });
}
// Time: ~1 second (all 10,000 concurrent)

50x faster — because all 10,000 requests run concurrently instead of in batches of 200.

When virtual threads don’t help

CPU-bound work

If your threads are doing computation (no I/O), virtual threads add overhead without benefit:

// CPU-bound: virtual threads won't help
executor.submit(() -> {
    long result = 0;
    for (long i = 0; i < 1_000_000_000L; i++) {
        result += i;
    }
    return result;
});

For CPU work, use ForkJoinPool or platform thread pools sized to the number of CPU cores.

Synchronized blocks (pinning)

When a virtual thread enters a synchronized block while doing I/O, it gets pinned to the carrier thread — the carrier can’t serve other virtual threads:

synchronized (lock) {
    // This pins the carrier thread
    database.query("SELECT ..."); // I/O inside synchronized = bad
}

Fix: use ReentrantLock instead of synchronized:

private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    database.query("SELECT ...");
} finally {
    lock.unlock();
}

ReentrantLock doesn’t pin the virtual thread. Java 25 further reduces pinning issues, but the best practice remains: use ReentrantLock for virtual thread-friendly code.

Migration guide

Step 1: Replace thread pools

// Before
ExecutorService executor = Executors.newFixedThreadPool(200);

// After
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Step 2: Remove thread pool tuning

You no longer need to calculate optimal pool sizes. Remove:

  • corePoolSize / maxPoolSize configurations
  • Custom ThreadPoolExecutor configurations
  • Pool size calculations based on I/O vs CPU ratio

Step 3: Replace synchronized with ReentrantLock

Find synchronized blocks that contain I/O operations and replace them.

Step 4: Remove thread-local caching

ThreadLocal is expensive with virtual threads (potentially millions of threads = millions of ThreadLocal values). Use scoped values (Java 25) or pass values explicitly.

Step 5: Test and measure

  • Monitor carrier thread utilization
  • Look for pinning events (-Djdk.tracePinnedThreads=short)
  • Compare throughput before and after

Virtual threads vs Kotlin coroutines

AspectVirtual ThreadsKotlin Coroutines
LanguageJavaKotlin
MechanismJVM-managed threadsCompiler-generated state machines
Blocking codeJust worksNeeds suspend keyword
Existing Java librariesCompatibleNeeds wrappers (withContext(Dispatchers.IO))
Structured concurrencyJava 25 (StructuredTaskScope)Built-in (coroutineScope)
CancellationVia interruptionCooperative (isActive, ensureActive)

Virtual threads are simpler — your existing blocking Java code scales without changes. Kotlin coroutines are more powerful — structured concurrency, flows, channels. Both solve the same underlying problem: efficient async I/O.

Summary

  • Virtual threads = lightweight threads managed by the JVM
  • Scale to millions of concurrent tasks with minimal memory
  • Best for I/O-bound work (database, HTTP, file I/O)
  • Don’t help for CPU-bound work
  • Replace synchronized with ReentrantLock to avoid pinning
  • One line to enable in Spring Boot 3.2+
  • Migration is straightforward: replace thread pools, remove tuning

Virtual threads make the simple blocking programming model scale. No reactive frameworks, no callbacks, no color functions. Just Thread.sleep() that actually works at scale.