StackWise
Modern JavaModern Java (17–21)

Virtual Threads

Project Loom's lightweight threads — how they work, when they help, pinning, and using them in Spring Boot.

advanced 4 min readLesson 20 of 26

The problem with platform threads

A classic Java thread (a platform thread) is a thin wrapper over an operating-system thread. OS threads are expensive: each reserves around 1 MB of stack, and creating or context-switching them involves the kernel. A server can comfortably run a few thousand.

The traditional thread-per-request model therefore hits a wall: if each request spends 95% of its time waiting on a database or HTTP call, threads sit idle while holding scarce resources. The workaround was reactive programming (WebFlux, CompletableFuture chains) — scalable, but hard to write, debug and profile.

Virtual threads (Java 21)

A virtual thread is a thread managed by the JVM, not the OS. Millions can exist at once. When a virtual thread performs a blocking operation (socket I/O, Thread.sleep, BlockingQueue.take, Lock waits), the JVM unmounts it from its carrier OS thread and parks it on the heap; the carrier is free to run another virtual thread. When the I/O completes, the virtual thread is remounted on some carrier and continues.

sequenceDiagram
  participant V1 as Virtual thread 1
  participant C as Carrier (OS thread)
  participant V2 as Virtual thread 2
  participant DB as Database
  V1->>C: mounted, runs code
  V1->>DB: blocking query
  Note over V1,C: V1 unmounts — its stack is saved to the heap
  V2->>C: mounted, runs code
  DB-->>V1: result ready
  V2->>C: blocks on HTTP call, unmounts
  V1->>C: remounted, continues after the query

You keep writing simple, blocking, sequential code — and get the scalability of asynchronous I/O.

Creating virtual threads

Thread.ofVirtual().name("worker-", 0).start(() -> handle(request));   // builder
Thread.startVirtualThread(() -> handle(request));                     // shortcut

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    futures = tasks.stream().map(executor::submit).toList();
}   // close() waits for all tasks

Don’t pool virtual threads. They are cheap to create — one per task is the intended model. To limit concurrency against a scarce resource (e.g. a database allowing 50 connections), use a Semaphore, not a small pool.

See it scale

VirtualThreads.javaJava 21
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;

class Main {
    // Simulates a blocking call such as a REST request or DB query
    static String fetchPrice(int productId) {
        try { Thread.sleep(Duration.ofMillis(200)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        return "p" + productId + "=" + (productId % 50 + 10);
    }

    static Duration run(ExecutorService executor, int tasks) throws Exception {
        Instant start = Instant.now();
        try (executor) {
            List<Future<String>> futures = IntStream.range(0, tasks)
                .mapToObj(i -> executor.submit(() -> fetchPrice(i)))
                .toList();
            for (Future<String> f : futures) f.get();
        }
        return Duration.between(start, Instant.now());
    }

    public static void main(String[] args) throws Exception {
        int tasks = 10_000;
        System.out.println(tasks + " tasks, each blocking for 200 ms");

        Duration virtual = run(Executors.newVirtualThreadPerTaskExecutor(), tasks);
        System.out.println("virtual threads:         " + virtual.toMillis() + " ms");

        Duration pooled = run(Executors.newFixedThreadPool(200), 2_000);
        System.out.println("200 platform threads (only 2,000 tasks): " + pooled.toMillis() + " ms");

        // Semaphore to protect a scarce downstream resource
        Semaphore dbConnections = new Semaphore(20);
        var inFlight = new java.util.concurrent.atomic.AtomicInteger();
        var maxSeen = new java.util.concurrent.atomic.AtomicInteger();
        try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 500; i++) {
                ex.submit(() -> {
                    dbConnections.acquireUninterruptibly();
                    try {
                        maxSeen.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
                        Thread.sleep(10);
                    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                    finally { inFlight.decrementAndGet(); dbConnections.release(); }
                });
            }
        }
        System.out.println("max concurrent 'DB calls' with Semaphore(20): " + maxSeen.get());
        System.out.println("isVirtual? " + Thread.ofVirtual().unstarted(() -> {}).isVirtual());
    }
}

Ten thousand blocking tasks finish in roughly the time of one (~200 ms plus overhead), because each sleeping virtual thread releases its carrier. The fixed pool of 200 needs ten “waves” for only 2,000 tasks.

When virtual threads help — and when they don’t

✓ Great fit ✗ No benefit
I/O-bound request handling (REST, JDBC, messaging) CPU-bound work — cores are the limit, use a pool sized to cores or parallel streams
Fan-out calls to several services Very short tasks with no blocking
Replacing complex reactive/callback code Code that already uses non-blocking I/O efficiently

Pinning

A virtual thread is pinned to its carrier — and can’t unmount while blocked — when it:

  • blocks inside a synchronized block or method (Java 21–23), or
  • calls native code / foreign functions.

Pinning hurts throughput when it’s frequent and long. In Java 21, prefer ReentrantLock over synchronized around blocking I/O. Java 24 (JEP 491) removed the synchronized pinning limitation, so on 24+ this concern largely disappears. Diagnose with -Djdk.tracePinnedThreads=full (Java 21) or JFR’s jdk.VirtualThreadPinned event.

Virtual threads in Spring Boot 4

One property switches Tomcat request handling, @Async, @Scheduled task execution, and Spring’s messaging listeners to virtual threads:

spring.threads.virtual.enabled=true

With it enabled, a plain blocking @RestController calling JDBC and RestClient scales like a reactive app — without changing a line of business code. Keep your connection pool size in mind: more concurrent requests now reach the database, and HikariCP’s pool becomes the natural throttle.

Finished this lesson?

Mark it complete to track your progress through Modern Java.