🧵 Section 9 · Question #22

What are Virtual Threads in Java 21

Virtual Threads drastically slash the cost of creating and retaining concurrent threads: their call frames reside directly in the Java Heap as resizable chunks (expanding from h...


🟢 Junior Level

30-Second Summary

Virtual Threads are lightweight user-mode execution threads introduced as a production-ready feature in Java 21 (JEP 444) under Project Loom. Unlike traditional Platform Threads, which map 1:1 onto operating system kernel threads (pthread), Virtual Threads are managed entirely in user space by the JVM runtime and implement an M:N multiplexing model (millions of virtual threads are scheduled over a modest pool of underlying OS threads, termed Carrier Threads).

Virtual Threads drastically slash the cost of creating and retaining concurrent threads: their call frames reside directly in the Java Heap as resizable chunks (expanding from hundreds of bytes) rather than reserving a static ~1 MB off-heap native memory stack per thread. This resurrects the straightforward, synchronous “Thread-per-request” programming model with throughput comparable to asynchronous reactive frameworks (Netty, WebFlux), completely avoiding callback hell and reactive cognitive overhead.


Comparative Overview: Platform Threads vs. Virtual Threads

Dimension Platform Threads (Classic) Virtual Threads (Java 21)
Management OS Kernel Scheduler JVM User-space Runtime
OS Thread Mapping 1:1 (One Java thread = One OS thread) M:N (Millions of VT share N Carrier threads)
Stack Size Fixed (~1 MB native off-heap memory) Dynamic in JVM Heap (starting at hundreds of bytes)
Context Switch Kernel syscall (~1–10 microseconds) User-space JVM jump (~tens of nanoseconds)
Scalability Ceiling Thousands (bounded by OS memory & ulimits) Millions (bounded solely by JVM heap capacity)
Thread Pooling Mandatory (ThreadPoolExecutor) Anti-pattern (instantiate and discard per task)
Workload Profile Mixed, including heavy CPU-bound Exclusively I/O-bound (network, disk, DB wait)

Creation and Execution in Java 21

In Java 21, java.lang.Thread gained factory methods and fluent builders for virtual thread instantiation:

import java.util.concurrent.Executors;

public class VirtualThreadBasicsDemo {
    public static void main(String[] args) throws Exception {
        // 1. Immediate execution via Thread.startVirtualThread
        Thread vThread1 = Thread.startVirtualThread(() -> {
            System.out.println("Running virtual thread: " + Thread.currentThread());
        });
        vThread1.join();

        // 2. Fluent builder Thread.ofVirtual()
        Thread vThread2 = Thread.ofVirtual()
                .name("order-worker-", 1)
                .inheritInheritableThreadLocals(false)
                .start(() -> {
                    System.out.println("Running named worker: " + Thread.currentThread());
                });
        vThread2.join();

        // 3. Production Standard: ExecutorService spawning a new thread per task
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                final int taskId = i;
                executor.submit(() -> {
                    // Simulating blocking network I/O
                    Thread.sleep(100);
                    return "Task " + taskId + " complete";
                });
            }
        } // try-with-resources automatically awaits completion of all tasks (AutoCloseable)!
    }
}

🟡 Middle Level

Under the Hood: Continuations and Carrier Threads

At the heart of the virtual thread implementation lies a low-level primitive for resumable computation: Delimited Continuations (jdk.internal.vm.Continuation):

Virtual Threads (User Space):
[ VT 1 (Blocked I/O) ]      [ VT 2 (Running) ]      [ VT 3 (Runnable) ]
         │                           │
   (Unmounted in Heap)        (Mounted)
                                     ▼
Carrier Threads (ForkJoinPool in Kernel Space):
                    [ Carrier Worker Thread 1 (OS Platform Thread) ]

1. Carrier Threads

  • A virtual thread cannot execute in a vacuum; to run CPU machine code, it must temporarily mount onto a physical OS platform thread, termed its Carrier Thread.
  • By default, carrier threads are managed by a specialized internal ForkJoinPool.
  • The carrier pool parallelism defaults to available CPU cores (Runtime.getRuntime().availableProcessors()), configurable via -Djdk.virtualThreadScheduler.parallelism=N.
  • Unlike the standard common pool, the virtual thread scheduler operates in FIFO mode (asyncMode = true), preventing task starvation.

2. Mounting and Unmounting Lifecycle

  • Mounting: When a virtual thread becomes runnable, the JVM attaches it to an available carrier thread. Its stack frames are copied from the heap onto the carrier thread’s native execution stack.
  • Unmounting: When the virtual thread performs a blocking operation (e.g., reading from SocketInputStream, calling Thread.sleep(), or blocking on LockSupport.park()):
    1. The JVM intercepts the blocking call at the native boundary.
    2. The virtual thread’s active stack frames are frozen and saved into heap memory as StackChunk objects.
    3. The virtual thread unmounts from the carrier thread.
    4. The carrier thread immediately becomes free to mount and execute another runnable virtual thread.
  • Wakeup & Reschedule: When the OS signals that network data has arrived (via OS-native multiplexers like Linux epoll, macOS kqueue, or Windows IOCP), the JVM’s background network poller transitions the virtual thread back to Runnable. The scheduler mounts it onto any idle carrier thread (not necessarily the same one).

Blocking I/O Behavior: Network Sockets vs. Disk Files

Almost all blocking operations in the JDK standard library were rewritten in Java 21 to support cooperative unmounting:

try (Socket socket = new Socket("api.internal.service", 8080);
     InputStream in = socket.getInputStream()) {
    
    byte[] buffer = new byte[1024];
    // PLATFORM THREAD: Thread blocks in OS kernel, stack ties up 1 MB, CPU stalls.
    // VIRTUAL THREAD: Unmounts from carrier! Carrier serves other tasks immediately!
    int bytesRead = in.read(buffer); 
}

[!NOTE] File I/O (FileInputStream, FileChannel):
On Linux systems, standard disk file I/O does not universally integrate with epoll. During legacy java.io file operations, virtual threads may remain pinned to their carrier thread, and the internal ForkJoinPool temporarily compensates by spawning additional carrier threads (up to maxPoolSize). For non-blocking file access, prefer java.nio.channels.AsynchronousFileChannel.


🔴 Senior Level

Under the Hood: Carrier Pinning

The most critical production operational hazard when migrating to virtual threads is Carrier Pinning. Pinning is a failure state where a virtual thread enters a blocking operation but cannot unmount, permanently monopolizing its underlying OS carrier thread.

2 Root Causes of Pinning:

  1. Executing Blocking Operations Inside a synchronized Block or Method: In the HotSpot JVM, the synchronized mechanism is tied to the C++ ObjectMonitor structure in native memory, which is historically bound to the native OS thread handle (pthread_t). If a virtual thread blocks on I/O or sleep while holding an intrinsic monitor, it cannot yield its stack frames.
  2. Executing Native Code (JNI) or Foreign Function Calls (Project Panama): Native C/assembly stacks exist outside GC and Continuation control, making heap unmounting physically impossible.
import java.util.concurrent.locks.ReentrantLock;

public class PinningProblemDemo {
    private final Object monitorLock = new Object();
    private final ReentrantLock reentrantLock = new ReentrantLock();

    // ❌ HAZARDOUS FOR VIRTUAL THREADS (Induces Carrier Pinning):
    public void badProcessing() {
        synchronized (monitorLock) {
            // Pins carrier thread to this virtual thread for the entire 5-second duration!
            // If all carrier threads become pinned, the entire JVM freezes!
            callExternalHttpService(); 
        }
    }

    // ✅ IDEAL FOR VIRTUAL THREADS:
    public void goodProcessing() {
        reentrantLock.lock();
        try {
            // ReentrantLock is built on LockSupport.park() and AQS.
            // It unmounts the virtual thread cleanly, freeing the carrier thread!
            callExternalHttpService();
        } finally {
            reentrantLock.unlock();
        }
    }

    private void callExternalHttpService() {
        try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

Diagnostic Monitoring of Pinning in Production:

To catch pinning occurrences during testing, supply the JVM flag:

# Prints stack traces whenever a pinned virtual thread blocks:
java -Djdk.tracePinnedThreads=full -jar application.jar

In APM production telemetry, track the JDK Flight Recorder (JFR) event: jdk.VirtualThreadPinned.


The ThreadLocal Scalability Hazard: Enter ScopedValue

Historically, microservice architectures preserved request context (Trace ID, Security Token, Tenant ID) in ThreadLocal:

public static final ThreadLocal<RequestContext> CONTEXT = new ThreadLocal<>();

Why This Breaks Down with Virtual Threads:

  1. Spawning 1,000,000 virtual threads instantiates 1,000,000 distinct ThreadLocalMap tables. If the context DTO consumes 50 KB, this demands 50 GB of JVM heap memory, triggering rapid OutOfMemoryError: Java heap space.
  2. Java 21 introduces Scoped Values (JEP 446):
    • Values are deeply immutable;
    • Cleaned up automatically upon scope exit without manual remove() calls;
    • Inherited by child threads by reference without cloning hash tables.
import java.lang.ScopedValue;

public class ScopedValueDemo {
    public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

    public void handleRequest() {
        // Bind context to lexical execution scope:
        ScopedValue.where(TRACE_ID, "req-xyz-9876").run(() -> {
            processBusinessLogic();
        });
        // Outside the block, TRACE_ID is automatically unbound and cleaned up!
    }

    private void processBusinessLogic() {
        System.out.println("Processing with trace: " + TRACE_ID.get());
    }
}

Cooperative Scheduling and the Infinite Loop Trap

A crucial HotSpot architecture reality: Virtual threads do not have preemptive time-slicing.

  • Switching between virtual threads occurs cooperatively at explicit yield points (I/O, locks, sleep(), yield()).
  • If a virtual thread executes a pure CPU-bound tight loop without blocking calls or Thread.yield():
    Thread.startVirtualThread(() -> {
        while (true) {
            // Pure compute loop with zero unmount points!
        }
    });
    

    It monopolizes its carrier thread indefinitely. Running as many compute loops as available physical CPU cores will completely starve the scheduler, freezing all other virtual threads.


Production Diagnostics: Dumping Millions of Threads

The legacy jstack <pid> command is obsolete for virtual threads; attempting to format text stacks for a million threads creates multi-gigabyte log outputs and pauses the JVM.

Recommended Java 21 Tooling:

# Capture a compact JSON thread dump via jcmd:
jcmd <PID> Thread.dump_to_file -format=json thread_dump.json

The JSON format groups waiting virtual threads by common unmount sites, keeping dump sizes compact.


🎯 Interview Cheat Sheet

1. 30-Second Elevator Pitch

Virtual Threads (Project Loom, Java 21) are user-space threads managed directly by the JVM. Their stack frames live dynamically in the Java heap, and their execution is multiplexed M:N onto a small pool of OS Carrier Threads (ForkJoinPool). Whenever a virtual thread blocks on I/O or locks, it unmounts from its carrier, serializing its stack to a StackChunk and freeing the carrier thread to execute other virtual tasks. This delivers the throughput of non-blocking reactive frameworks with the simplicity of synchronous thread-per-request code. Primary gotchas include Carrier Pinning (due to synchronized or JNI), memory bloat from ThreadLocal, and ineffectiveness for CPU-bound computations.


2. 4 Tricky Follow-up Questions with Deep Answers

Question 1: Why does synchronized cause carrier pinning while ReentrantLock unmounts cleanly?

Answer: synchronized is implemented in the HotSpot C++ runtime using ObjectMonitor, which ties monitor locking to the native OS thread identity (pthread_t). The JVM cannot safely transfer ObjectMonitor ownership across different carrier threads during unmounting, forcing the carrier thread to block. Conversely, ReentrantLock is written entirely in Java using AbstractQueuedSynchronizer and LockSupport.park(). The JVM intercepts park() calls natively, invoking Continuation.yield() to unmount the virtual thread cleanly.

Question 2: What occurs if you use virtual threads for compute-heavy, CPU-bound workloads?

Answer: Throughput degrades compared to a correctly sized Executors.newFixedThreadPool(N). Virtual threads switch cooperatively at blocking points; CPU-bound tasks have no unmount points. Each virtual thread monopolizes a carrier thread just like a platform thread, but managing millions of virtual thread heap objects, continuations, and GC sweeps introduces extra overhead with zero throughput gain.

Question 3: Why is pooling virtual threads (newFixedThreadPool with a virtual thread factory) an anti-pattern?

Answer: Thread pools were designed because platform threads cost ~1 MB of off-heap memory and require expensive kernel system calls to allocate. Virtual threads cost a few hundred bytes and instantiate in nanoseconds as ordinary Java heap objects. Pooling them eliminates their primary advantage (the Thread-Per-Task model). When rate-limiting access to external constrained resources (e.g., a pool of 20 database connections), developers should use java.util.concurrent.Semaphore rather than thread pools.

Question 4: How do virtual threads interact with the Java Memory Model (JMM) and Happens-Before rules?

Answer: Virtually identically to platform threads. The JLS Chapter 17 specification treats virtual threads as standard java.lang.Thread instances. All Happens-Before guarantees (volatile reads/writes, lock release/acquire, Thread.start(), Thread.join()) apply unchanged. The JVM inserts memory barriers during mounting and unmounting, guaranteeing CPU register and L1/L2 cache coherency across different carrier threads.


3. Red Flags & Common Gotchas

❌ “Virtual threads make individual tasks run faster.”
Correction: Virtual threads do not reduce task latency; they increase aggregate system throughput when handling thousands of concurrent I/O-bound operations.

❌ “Pooling virtual threads to save memory.”
Correction: Virtual threads should never be pooled; use Executors.newVirtualThreadPerTaskExecutor() where threads are discarded after task completion.

❌ “Using synchronized around blocking HTTP or socket operations.”
Correction: Triggers Carrier Pinning, exhausting carrier threads and stalling the entire JVM. Use ReentrantLock.

❌ “Storing heavy request context objects inside ThreadLocal.”
Correction: Storing large objects across millions of threads creates massive heap memory consumption. Use ScopedValue instead.