Part 6 of 15

Virtual Threads (JEP 444): A Million Threads Without the Pain

The Platform Thread Problem

Every Java thread since Java 1.0 maps 1:1 to an OS thread. OS threads are heavy:

  • Stack memory: 512 KB – 2 MB per thread (configurable with -Xss, default 512 KB on Linux)
  • Context switch cost: ~1–10 μs per switch (kernel mode transition + cache invalidation)
  • Hard limit: A 64-bit machine with 8 GB RAM can support roughly 8,000–16,000 OS threads before running out of stack memory

This limit shapes how Java servers are built. Thread-per-request (one OS thread per HTTP request) works for hundreds of concurrent users. Beyond that, developers use reactive programming (CompletableFuture, RxJava, Project Reactor) — async, non-blocking code that is notoriously difficult to write, read, and debug.

Virtual threads remove this ceiling while keeping synchronous, sequential code style.


How Virtual Threads Work

flowchart TB
    subgraph JVM["JVM — ForkJoinPool (N carrier threads, N = CPU cores)"]
        subgraph CT1["Carrier Thread 1 (OS Thread)"]
            VT1["Virtual Thread A\n(mounted — executing)"]
        end
        subgraph CT2["Carrier Thread 2 (OS Thread)"]
            VT3["Virtual Thread C\n(mounted — executing)"]
        end
        VT2["Virtual Thread B\n(unmounted — waiting on I/O)"]
        VT4["Virtual Thread D\n(unmounted — waiting on lock)"]
        VT_N["Virtual Thread ...N\n(unmounted — parked)"]
    end

    Heap["JVM Heap\nVirtual thread stacks\nstored as objects (~few KB each)"]
    VT2 & VT4 & VT_N --> Heap

Key mechanism — mount/unmount:

  1. Virtual thread is mounted onto a carrier thread when it has work to do
  2. When the virtual thread hits a blocking I/O call (e.g., socket.read()), it unmounts from its carrier thread
  3. The carrier thread immediately picks up another virtual thread
  4. When the I/O completes, the virtual thread is rescheduled — it will mount onto any available carrier thread

The virtual thread’s stack is stored in the JVM heap (not OS stack space) — a few KB, not hundreds of KB. You can have millions of virtual threads on a machine with 8 GB RAM.


Creating Virtual Threads

Thread.ofVirtual()

// Start a virtual thread immediately
Thread vt = Thread.ofVirtual()
    .name("order-processor")
    .start(() -> processOrder(orderId));

vt.join();  // wait for completion

// Build but don't start
Thread vt = Thread.ofVirtual()
    .name("worker-", 0)   // auto-numbered: worker-0, worker-1, ...
    .unstarted(() -> doWork());
vt.start();

Thread.startVirtualThread()

// Convenience — equivalent to Thread.ofVirtual().start(runnable)
Thread vt = Thread.startVirtualThread(() -> processOrder(orderId));
vt.join();

Executors.newVirtualThreadPerTaskExecutor()

The recommended way for server applications — one virtual thread per submitted task:

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = new ArrayList<>();

    for (String orderId : orderIds) {
        futures.add(executor.submit(() -> fetchOrderDetails(orderId)));
    }

    for (Future<String> f : futures) {
        System.out.println(f.get());
    }
}
// executor closed, all virtual threads finished

This is the drop-in replacement for Executors.newFixedThreadPool(n) — but instead of a fixed number of OS threads, you get one virtual thread per task.


Platform Thread vs Virtual Thread

// Platform thread — maps to OS thread
Thread platform = Thread.ofPlatform()
    .name("platform-worker")
    .start(() -> doWork());

// Virtual thread — JVM-managed lightweight thread
Thread virtual = Thread.ofVirtual()
    .name("virtual-worker")
    .start(() -> doWork());

// Check type
System.out.println(platform.isVirtual());  // false
System.out.println(virtual.isVirtual());   // true
PropertyPlatform ThreadVirtual Thread
OS thread mapping1:1M:N (many virtual : few OS)
Stack size512 KB – 2 MB~2 KB (heap-stored, grows on demand)
Creation costSlow (~1ms, syscall)Fast (~1μs, no syscall)
Max count per JVM~10,000–20,000Millions
DaemonConfigurableAlways daemon
PriorityConfigurableAlways NORM_PRIORITY
ThreadGroupYesEmpty group

Migrating a Spring Boot Application

The simplest migration: one property.

# application.properties — Spring Boot 3.2+
spring.threads.virtual.enabled=true

This switches the embedded Tomcat (or Jetty/Undertow) to use a virtual thread per HTTP request. No code changes needed.

For programmatic configuration:

@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
    return protocolHandler ->
        protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}

Spring WebFlux (Reactive) vs Virtual Threads

With virtual threads, you can use blocking code on the “reactive” stack without degrading performance:

// Before: forced to use reactive API
@GetMapping("/order/{id}")
public Mono<Order> getOrder(@PathVariable String id) {
    return orderRepository.findById(id);  // reactive repository
}

// After: blocking code works fine with virtual threads
@GetMapping("/order/{id}")
public Order getOrder(@PathVariable String id) {
    return orderRepository.findByIdBlocking(id);  // regular blocking call
}

Virtual threads make blocking I/O as efficient as reactive non-blocking I/O, without the complexity.


JDBC and Database Access

JDBC is inherently blocking. With platform threads, blocking on a DB query wastes a thread. With virtual threads, the virtual thread unmounts while waiting for the DB — the carrier thread is free.

// This blocks on the JDBC call — but with virtual threads, that's fine
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Order>> futures = orderIds.stream()
        .map(id -> exec.submit(() -> {
            // This blocking JDBC call unmounts the virtual thread
            try (Connection conn = dataSource.getConnection();
                 PreparedStatement ps = conn.prepareStatement(
                     "SELECT * FROM orders WHERE id = ?")) {
                ps.setString(1, id);
                ResultSet rs = ps.executeQuery();
                return mapToOrder(rs);
            }
        }))
        .toList();

    return futures.stream().map(f -> {
        try { return f.get(); }
        catch (Exception e) { throw new RuntimeException(e); }
    }).toList();
}

Connection pool sizing: With virtual threads, hundreds of virtual threads may try to acquire a database connection simultaneously. Size your connection pool based on the database’s capacity, not thread count. For most databases, 10–50 connections are sufficient even with thousands of virtual threads.

spring.datasource.hikari.maximum-pool-size=20

Pinning — The Main Pitfall

A virtual thread is pinned when it cannot unmount from its carrier thread. Pinning happens when:

  1. The virtual thread executes inside a synchronized block or method
  2. The virtual thread calls a native method

When pinned, the carrier thread is blocked — defeating the purpose of virtual threads.

// PROBLEM: synchronized pins the virtual thread
synchronized (lock) {
    Thread.sleep(1000);  // virtual thread is pinned during sleep!
}

// SOLUTION: use ReentrantLock instead
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    Thread.sleep(1000);  // virtual thread unmounts here — carrier is free
} finally {
    lock.unlock();
}

Detecting Pinning

# JVM flag to print a stack trace whenever pinning occurs
java -Djdk.tracePinnedThreads=full MyApp

# Or short form (just thread name, no stack trace)
java -Djdk.tracePinnedThreads=short MyApp

Output:

Thread[#21,ForkJoinPool-1-worker-1,5,CarrierThreads]
    ...
    com.example.MyService.processOrder(MyService.java:42) <== monitors:1

This tells you exactly which synchronized block caused pinning.

Common Pinning Sources

Pinning causeFix
synchronized blockReplace with ReentrantLock
synchronized methodReplace with ReentrantLock
Older JDBC driversUpgrade — most modern drivers removed synchronized
Older HTTP clientsUpgrade or use java.net.http.HttpClient

Note: Not all pinning is catastrophic. Short synchronized blocks (sub-microsecond) pin briefly — acceptable. Long blocking operations inside synchronized are the problem.


Thread-Local Variables and Virtual Threads

ThreadLocal works with virtual threads but has a problem at scale: if you create 1,000,000 virtual threads and each inherits a ThreadLocal, you create 1,000,000 copies of that value.

// Problematic with millions of virtual threads
static ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();

// Better: use ScopedValue (JEP 446, Preview)
static ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();

See Scoped Values for the virtual-thread-friendly alternative.


How Many Virtual Threads Can You Run?

// Benchmark: how many virtual threads can one JVM handle?
int count = 1_000_000;
CountDownLatch latch = new CountDownLatch(count);

for (int i = 0; i < count; i++) {
    Thread.startVirtualThread(() -> {
        try {
            Thread.sleep(Duration.ofSeconds(1));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            latch.countDown();
        }
    });
}

latch.await();
System.out.println("1 million virtual threads completed");

On a typical developer laptop with 16 GB RAM, this runs in ~1–2 seconds peak memory usage of a few hundred MB. The equivalent with platform threads would require ~500 GB of stack memory.


When NOT to Use Virtual Threads

Virtual threads shine for I/O-bound work. They do not help for CPU-bound work:

// CPU-bound: no blocking, no I/O, no benefit from virtual threads
// Use platform threads / ForkJoinPool.commonPool() instead
long result = 0;
for (long i = 0; i < 1_000_000_000L; i++) {
    result += compute(i);
}

For CPU-intensive parallel work, the ForkJoinPool with platform threads is still the right tool. Virtual threads are for tasks that spend most of their time waiting — HTTP calls, DB queries, file I/O, sleep.


Key Takeaways

  • Virtual threads are JVM-managed, lightweight threads — a few KB each vs. 512 KB+ for OS threads
  • They mount on carrier threads when executing and unmount when blocking on I/O — the carrier is free during the wait
  • Executors.newVirtualThreadPerTaskExecutor() is the standard executor for virtual thread applications
  • Spring Boot 3.2+: spring.threads.virtual.enabled=true switches Tomcat to virtual threads instantly
  • synchronized blocks pin virtual threads — replace with ReentrantLock for long-blocking critical sections
  • Detect pinning with -Djdk.tracePinnedThreads=full
  • ThreadLocal works but is memory-heavy at scale — prefer ScopedValue (preview)
  • Virtual threads do not improve CPU-bound workloads — only I/O-bound

Next: Structured Concurrency (JEP 453) — organize concurrent subtasks safely with StructuredTaskScope, eliminating thread leaks and simplifying cancellation.