3 min read

Virtual Threads & Project Loom: Concurrency Architecture

Finalized in Java 21 under Project Loom, Virtual Threads revolutionize high-throughput concurrent application development in Java. For decades, Java applications followed a 1:1 model where every java.lang.Thread mapped directly to an operating system kernel thread. Because OS threads are expensive (consuming 1MB of stack memory and incurring OS kernel context switching overhead), scaling applications to handle hundreds of thousands of concurrent connections required complex asynchronous reactive frameworks.

Virtual Threads restore the simple Thread-per-Task programming paradigm, enabling JVM applications to spawn millions of concurrent virtual threads with minimal memory footprint and near-zero context switching overhead.


1. Platform Threads vs. Virtual Threads

Virtual Threads decouple the application thread abstraction from the underlying operating system kernel thread.


flowchart TD
    subgraph VirtualThreads["User-Space Virtual Threads (Millions on Heap)"]
        VT1["Virtual Thread 1"]
        VT2["Virtual Thread 2"]
        VT3["Virtual Thread 3"]
        VT100k["Virtual Thread 100,000+"]
    end

    subgraph CarrierPool["ForkJoinPool Carrier Thread Pool (M Threads = CPU Cores)"]
        PT1["Carrier Platform Thread 1"]
        PT2["Carrier Platform Thread 2"]
    end

    subgraph Kernel["OS Kernel Threads (1:1)"]
        OS1["OS Kernel Thread 1"]
        OS2["OS Kernel Thread 2"]
    end

    VT1 -- "Mounted onto" --> PT1
    VT2 -- "Mounted onto" --> PT2
    PT1 <--> OS1
    PT2 <--> OS2

Comparison Summary

  • Platform Threads: Managed by the OS kernel. Consumes ~1MB of pre-allocated stack memory per thread regardless of usage. Creating more than a few thousand platform threads leads to OutOfMemoryError: unable to create new native thread.
  • Virtual Threads: Managed entirely by the JVM runtime inside heap memory. Initial memory footprint is just a few hundred bytes, expanding dynamically only when needed. Millions of virtual threads can run concurrently inside a single JVM process!

2. Continuation Mounting & Unmounting Mechanics

The secret to Virtual Thread efficiency is Continuation Stack Unmounting. Virtual threads execute on top of standard platform threads known as Carrier Threads (managed by a default JVM ForkJoinPool).


sequenceDiagram
    participant VT as Virtual Thread (Task)
    participant Carrier as Carrier Thread (Platform)
    participant OS as OS Epoll / Network I/O

    VT->>Carrier: Execute business logic on Carrier Thread
    VT->>OS: Perform Blocking I/O (e.g. Socket Read / HTTP Call)
    Note over VT,Carrier: JVM Unmounts Continuation Stack Frame to Heap!
    Carrier->>Carrier: Carrier Thread immediately freed to execute other VTs!
    OS-->>VT: OS notifies I/O completion (epoll/kqueue)
    VT->>Carrier: JVM Mounts Continuation Stack back onto available Carrier Thread
    Carrier->>VT: Resume execution seamlessly after I/O point

What Happens During Blocking I/O:

  1. When code inside a Virtual Thread calls a blocking operation (e.g., Thread.sleep(), database query, or REST socket read), the JVM intercepts the call.
  2. The JVM unmounts the Virtual Thread's continuation stack frame from the Carrier Thread and moves it to the Java Heap.
  3. The Carrier Thread is immediately released back to the ForkJoinPool to execute other Virtual Threads.
  4. When the underlying OS asynchronous I/O event (via epoll on Linux or kqueue on macOS) signals completion, the JVM picks an available Carrier Thread, mounts the Virtual Thread continuation stack back onto it, and resumes execution seamlessly!

3. Writing Virtual Thread Code in Java 21+

Creating Virtual Threads requires zero complex reactive callbacks or RxJava operators. Standard sequential Java code automatically scales under the hood.

import java.net.URI;
import java.net.http.*;
import java.util.concurrent.Executors;

public class VirtualThreadExample {

    public static void main(String[] args) {
        // ExecutorService that spawns a new Virtual Thread per submitted task
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 100_000; i++) {
                final int taskId = i;
                executor.submit(() -> {
                    // Blocking I/O operation! Virtual thread unmounts automatically!
                    fetchData(taskId);
                    return null;
                });
            }
        } // AutoCloseable executor waits for all 100,000 virtual threads to finish!
        System.out.println("All 100,000 virtual thread tasks completed successfully!");
    }

    private static void fetchData(int id) {
        try {
            Thread.sleep(1000); // Does NOT block OS kernel thread!
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

4. Thread Pinning Pitfalls & ReentrantLock Solution

While Virtual Threads handle blocking operations transparently, developers must avoid Thread Pinning.


flowchart TD
    subgraph PinningTrap["Thread Pinning Scenario"]
        SyncBlock["synchronized(lock) or JNI Native Call"] --> BlockingIO["Blocking I/O Operation"]
        BlockingIO --> PinnedState["Virtual Thread CANNOT Unmount!\nCarrier Thread BLOCKED!"]
    end

    subgraph PinningFix["Thread Pinning Solution"]
        Reentrant["ReentrantLock.lock()"] --> CleanIO["Blocking I/O Operation"]
        CleanIO --> UnmountState["Virtual Thread Unmounts Cleanly to Heap!\nCarrier Thread FREED!"]
    end

What is Thread Pinning?

If a Virtual Thread executes a blocking I/O operation inside a synchronized block/method or a native JNI call, the virtual thread becomes pinned to its Carrier Thread. The JVM cannot unmount the continuation stack frame, forcing the underlying platform carrier thread to block entirely.

// BAD: Causes Thread Pinning!
public synchronized void processData() {
    // Blocking socket call inside synchronized block pins carrier thread!
    readFromSocket(); 
}

// GOOD: Thread Pinning Fixed! Use ReentrantLock instead!
private final ReentrantLock lock = new ReentrantLock();

public void processDataFixed() {
    lock.lock();
    try {
        readFromSocket(); // Unmounts cleanly without pinning carrier thread!
    } finally {
        lock.unlock();
    }
}

Related Guides:
← PreviousJVM Threading Model & Object MonitorsNext →JVM Profiling, Monitoring & Troubleshooting