⚡ Advanced Concurrency · Advanced

Virtual threads in Java

Java 21 lightweight threads for blocking I/O at massive scale; pinning; don't pool them.

🧩 The mystery10,000 platform threads can strain a server. A million virtual threads? A laptop shrugs. Java 21 made threads cheap. Here's how, and the one trap called pinning.

Lightweight threads

Virtual threads (final in Java 21) are managed by the JVM, not the OS. Many virtual threads run on a few carrier platform threads. Create one with Thread.ofVirtual().start(task), or get one per task from Executors.newVirtualThreadPerTaskExecutor().

Thread.ofVirtual().start(() -> handle(req));
 
try (var ex = Executors
        .newVirtualThreadPerTaskExecutor()) {
    ex.submit(() -> handle(req));
}

Unmounting

1. VT-1 runs on carrier C. 2. VT-1 blocks on a socket read, so it unmounts: its stack is parked on the heap. 3. C runs VT-2. 4. Data arrives; VT-1 remounts, maybe on another carrier. Blocking costs almost nothing, so millions of threads can wait at once.

🔮 Predict it

Your turn

What does this print?

Thread t = Thread.ofVirtual().start(() ->
    System.out.println(
        Thread.currentThread().isVirtual()));
t.join();
System.out.println(
    Thread.currentThread().isVirtual());
  1. true false
  2. true true
  3. false false
Show the answer

The task runs on a virtual thread, so it prints true. main is an ordinary platform thread, so the last line prints false. join() makes main wait for the virtual thread first.

Made for waiting

Virtual threads shine at blocking I/O: HTTP calls, database queries, files. They give no speedup for CPU-bound work, because the cores are still the limit. One more fact: virtual threads are always daemon threads, so they never keep the JVM alive on their own.

Don't pool them

✗ Pooled
var ex = Executors.newFixedThreadPool(
    200, Thread.ofVirtual().factory());

Pooling a cheap thing brings back the very limit virtual threads were designed to remove.

✓ Per task + limit the resource
var db = new Semaphore(100);
try (var ex = Executors
        .newVirtualThreadPerTaskExecutor()) {
    ex.submit(() -> query(db));
}

One new virtual thread per task. Cap the scarce resource (100 DB connections) with a Semaphore or a sized connection pool; extra requests wait cheaply.

Pinning

If a blocked virtual thread can't unmount, it's pinned to its carrier. On Java 21–23, blocking inside synchronized pinned it. With only a few carriers, a handful of pinned threads stalls everything. JEP 491 (Java 24) lets virtual threads unmount while holding monitors, largely fixing this, though native calls still pin.

💼 In the real world

The Java 21 stall

A team moved to virtual threads on Java 21. Under load the service froze: virtual threads blocked on JDBC inside synchronized methods, and every carrier was pinned. Fixes: upgrade to Java 24+, or swap synchronized for ReentrantLock. JFR's jdk.VirtualThreadPinned event shows where pinning happens.

Key takeaways

  1. Thread.ofVirtual().start(task) or newVirtualThreadPerTaskExecutor()
  2. Great for blocking I/O; no speedup for CPU-bound work
  3. Don't pool them — cap concurrency with a Semaphore instead
  4. Always daemon threads
🤯 Did you know?

By default the virtual-thread scheduler uses one carrier per CPU core, so a million virtual threads may share just 8 platform threads.

Practice questions

What does this print?

Thread t = Thread.ofVirtual().unstarted(() -> {});
System.out.println(t.isVirtual());
System.out.println(t.isDaemon());
  1. true true
  2. true false
  3. false true
  4. false false
Check your answer

true true. Thread.ofVirtual() builds a virtual thread, and virtual threads are always daemon threads.

What does this print?

var n = new AtomicInteger();
try (var ex = Executors
        .newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        ex.submit(() -> n.incrementAndGet());
    }
}
System.out.println(n.get());
  1. 10000
  2. A number below 10000
  3. 0
  4. Throws OutOfMemoryError
Check your answer

10000. 10,000 virtual threads are cheap. close() waits for every task, and AtomicInteger loses no increments.

Next: you start three subtasks and one fails. What happens to the other two? Structured concurrency makes them live and die together.