Virtual Threads (Java 21+)

Learning goals

By the end of this chapter you should be able to:

  • Explain virtual threads vs platform threads and when each fits
  • Start virtual threads with Thread.startVirtualThread and Executors.newVirtualThreadPerTaskExecutor
  • Avoid pinning carrier threads by holding monitors or doing heavy CPU on virtual threads
  • Refactor “thread-per-request” blocking IO designs to virtual threads on JDK 21

The platform thread problem

A platform thread maps to an OS thread — typically ~1 MB stack, costly to create tens of thousands.

Classic fix: small fixed thread pool + non-blocking IO (callbacks, reactive stacks). That trades simple blocking code for complex async chains.

Virtual threads (Project Loom, Java 21) give you millions of lightweight threads scheduled on a small pool of carrier platform threads. When a virtual thread blocks on IO, the carrier is freed for another virtual thread.

Write blocking style; JVM mounts/unmounts virtual threads efficiently.


Creating virtual threads

Thread vthread = Thread.startVirtualThread(() -> {
    String body = fetchHttp(url);  // blocking OK
    process(body);
});

vthread.join();

Builder API:

Thread.ofVirtual().name("request-", 0).start(task);

Executor — one virtual thread per task:

try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i ->
        exec.submit(() -> handleRequest(i))
    );
}

No need to size the pool to CPU count for IO-bound workloads — size to concurrent operations you tolerate (memory, downstream limits).


Structured concurrency (preview)

StructuredTaskScope (Java 21+, evolving) groups subtasks with clear lifetime:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<String> user = scope.fork(() -> fetchUser(id));
    Subtask<Integer> balance = scope.fork(() -> fetchBalance(id));

    scope.join();
    scope.throwIfFailed();

    return new Dashboard(user.get(), balance.get());
}

Parent scope waits for children — avoids orphaned background threads.


What virtual threads are good for

  • HTTP servers handling many concurrent blocking requests
  • JDBC queries (blocking driver) per request
  • File IO with blocking APIs
  • Replacing cached thread pools that grew unbounded waiting on sockets

CPU-bound work still belongs on platform threads or a fixed pool sized to cores — virtual threads do not multiply CPU throughput.


Pinning — the footgun

When a virtual thread blocks the carrier because code holds a monitor (synchronized) during native/blocking work, or runs long CPU without yielding, you lose scalability — pinning.

synchronized (lock) {
    socket.getInputStream().read();  // may pin carrier during block
}

Mitigations evolving across JDK releases:

  • Prefer ReentrantLock over synchronized in hot blocking paths inside virtual threads (when profiling shows pinning)
  • Keep synchronized sections short
  • Use async drivers or dedicated platform pool for CPU-heavy stages

Profile with JDK tools (jcmd Thread.dump_to_file -format=json) — pinning shows carrier monopolization.


ThreadLocal and virtual threads

ThreadLocal on millions of virtual threads can consume huge memory — each virtual thread has slot storage.

Java 21+ introduces scoped values (preview in 21, evolving in 25) as a structured alternative for immutable context propagation across virtual threads.

For new code on 21, minimize ThreadLocal in virtual-thread servers; pass context explicitly or use scoped values when available.


Migrating a blocking service

Before — platform thread pool sized to connection count:

ExecutorService platform = Executors.newFixedThreadPool(200);

After — virtual thread per request:

ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor();

Keep downstream connection pool limits (DB pool size) — virtual threads cheap ≠ unlimited DB connections.


Common mistakes

  1. Millions of virtual threads + unbounded memory per task — still OOM if each holds large buffers.
  2. CPU work on virtual thread executor — starves carriers; use separate platform pool.
  3. Ignoring DB/client pool sizing — threads scale faster than connections.
  4. Assuming reactive code needed — virtual threads revive servlet-style blocking handlers.
  5. Long synchronized blocks in request path — check for pinning under load.

Practice checkpoint

  1. 50,000 concurrent HTTP clients, blocking HttpClient — platform pool 200 or virtual threads? Answer: virtual threads (with back-pressure at gateway); platform pool would queue or reject.

  2. Video transcoding farm — virtual thread per job? Answer: no — CPU-bound; use platform pool ≈ core count.

  3. What schedules virtual threads on carriers? Answer: JVM fork-join style scheduler (implementation detail); carriers are platform threads.

  4. Why can synchronized hurt virtual thread throughput? Answer: may pin carrier during block, reducing effective concurrency.


Interview angles

  • Loom value prop: cheap threads + blocking code vs reactive complexity.
  • Not faster CPU: more concurrency for waiting work, not more compute per core.
  • Carrier pinning: know symptom and mitigation direction.
  • Structured concurrency: child tasks tied to parent scope — failure propagation, cancellation.

Next: /java/learn/wrap-up/java-engineer-checklist/