☕ Java

Virtual Threads

Virtual threads, introduced as a preview in Java 19 and finalized in Java 21 as part of Project Loom, are lightweight threads managed by the JVM rather than the OS. A traditional platform thread maps 1:1 to an OS thread and is expensive to create (1-2MB stack, ~1ms creation time) and to block (OS context switch). Virtual threads are cheap to create (a few hundred bytes, microseconds), cheap to block (the JVM parks the virtual thread and unmounts it from its carrier platform thread without blocking the platform thread), and can number in the millions. When a virtual thread performs a blocking operation — I/O, sleep, lock acquisition, or any operation that would block a platform thread — the JVM scheduler automatically unmounts it from its carrier thread, allowing the carrier to run other virtual threads. When the blocked operation completes, the virtual thread is rescheduled on any available carrier thread. This model enables writing straightforward synchronous, blocking code that performs with the throughput of asynchronous code — the JVM provides the async optimization automatically. This entry covers the threading model, how to create and manage virtual threads, interaction with synchronized and thread-local variables, the concept of pinning and how to avoid it, structured concurrency (JEP 428/453), the correct and incorrect use cases, and migration patterns from thread pools to virtual threads.

Virtual Thread Model — Carriers, Mounting, and Unmounting

A virtual thread is a Thread instance managed entirely by the JVM. It is scheduled on a small pool of platform threads called carrier threads — by default, one carrier per CPU core. A virtual thread that is runnable is mounted on a carrier thread and executes on it. When the virtual thread encounters a blocking operation, it is unmounted from the carrier thread: the carrier thread is immediately freed to execute another virtual thread. When the blocking operation completes (e.g., data arrives on a socket, a lock is released), the virtual thread becomes runnable again and is remounted on any available carrier. This scheduling is transparent to the application code — the virtual thread appears to block, but the carrier thread never blocks. The consequence for throughput: a server with 8 platform threads (the default ForkJoinPool parallelism) can, with virtual threads, handle hundreds of thousands of concurrent requests that are each doing blocking I/O. The 8 carrier threads continuously switch among runnable virtual threads, each executing only when it has work to do. Memory usage grows only with the number of virtual threads that have pending work — a virtual thread waiting for I/O consumes only its stack frames (which can be as small as a few bytes, growing dynamically as needed) rather than a full 1-2MB OS thread stack. The JVM scheduler for virtual threads is a ForkJoinPool in FIFO mode (work-stealing, LIFO disabled). This pool has one thread per core by default and is separate from the common ForkJoinPool used by parallel streams. The parallelism can be configured with the system property jdk.virtualThreadScheduler.parallelism. Virtual threads are daemon threads by default — they do not prevent JVM shutdown. They have no name by default but can be named with Thread.ofVirtual().name("my-thread").start(runnable). Virtual threads cannot have their priority changed (the JVM ignores setPriority() calls). They are not reusable — once a virtual thread terminates, it cannot be restarted.
Java
// ── Creating virtual threads ──────────────────────────────────────────
// Method 1: Thread.ofVirtual().start(Runnable)
Thread vt1 = Thread.ofVirtual().name("virtual-1").start(() -> {
    System.out.println("Hello from: " + Thread.currentThread());
    System.out.println("Is virtual: " + Thread.currentThread().isVirtual());
});
vt1.join();

// Method 2: Thread.ofVirtual().unstarted(Runnable)
Thread vt2 = Thread.ofVirtual().name("virtual-2").unstarted(() -> {
    System.out.println("Running on carrier: " + Thread.currentThread());
});
vt2.start();
vt2.join();

// Method 3: Executors.newVirtualThreadPerTaskExecutor()
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // Submits each task as a NEW virtual thread — no pooling:
    for (int i = 0; i < 10; i++) {
        final int id = i;
        executor.submit(() -> {
            System.out.println("Task " + id + " on " + Thread.currentThread());
            Thread.sleep(100);   // blocks virtual thread, not carrier thread
            return "Result " + id;
        });
    }
}   // executor.close() waits for all tasks to complete — AutoCloseable

// ── Blocking a virtual thread — carrier is NOT blocked ────────────────
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
    // Submit 100,000 tasks that each sleep for 1 second:
    long start = System.nanoTime();
    List<Future<?>> futures = new ArrayList<>();
    for (int i = 0; i < 100_000; i++) {
        futures.add(exec.submit(() -> {
            Thread.sleep(1000);   // blocks virtual thread, frees carrier for others
            return null;
        }));
    }
    for (Future<?> f : futures) f.get();
    long elapsed = (System.nanoTime() - start) / 1_000_000;
    System.out.printf("100,000 tasks x 1s sleep completed in %dms%n", elapsed);
    // With platform threads: ~12,500 seconds (100000/8 threads * 1s)
    // With virtual threads: ~1,000ms (all 100000 sleep concurrently)
}

// ── isVirtual, carrier detection ─────────────────────────────────────
Thread platformThread = Thread.ofPlatform().start(() -> {
    System.out.println("Platform: " + Thread.currentThread().isVirtual()); // false
});
Thread virtualThread = Thread.ofVirtual().start(() -> {
    System.out.println("Virtual:  " + Thread.currentThread().isVirtual()); // true
    // Thread.currentThread() returns the virtual thread, NOT the carrier
});
platformThread.join(); virtualThread.join();

Pinning, synchronized, and ThreadLocal Interactions

Pinning is the situation where a virtual thread cannot be unmounted from its carrier thread during a blocking operation. A pinned virtual thread holds its carrier thread blocked until the pin is released, defeating the scalability advantage. Two situations cause pinning: executing inside a synchronized block or method, and calling native code via JNI that blocks. Synchronized pinning occurs because Java's intrinsic locks (monitors) are implemented in the JVM using the carrier thread's identity — the monitor is associated with the current OS thread, not the virtual thread. When a virtual thread executing inside a synchronized block tries to park (e.g., for I/O or Object.wait()), it cannot unmount because unmounting would change the OS thread that holds the monitor, breaking the monitor semantics. The JVM therefore keeps the virtual thread pinned to its carrier for the duration of the synchronized block. The fix for synchronized pinning is to replace synchronized with java.util.concurrent.locks.ReentrantLock. ReentrantLock is implemented using AQS and LockSupport.park(), which the JVM's virtual thread scheduler understands — parked virtual threads inside ReentrantLock can be unmounted. For code you control, replacing synchronized with ReentrantLock is straightforward. For library code you do not control, you must either avoid calling it from virtual threads in contexts where pinning matters or accept the pinning. JDK 24 begins making synchronized non-pinning for many cases. The JVM can now detect that a virtual thread inside synchronized has attempted a blocking operation and may be able to unmount it if no native code or JVM-internal monitors depend on the carrier identity. This is progressive — not all synchronized blocks are unpinned, but the common cases (blocking I/O inside synchronized) will be handled. ThreadLocal variables work with virtual threads but require care. A ThreadLocal is keyed to the Thread object — for virtual threads, Thread.currentThread() returns the virtual thread, so each virtual thread has its own ThreadLocal values, independent of all other virtual threads. This is correct and expected. However, thread-local caches (SimpleDateFormat, JDBC connections, HttpClient instances) that are used to avoid allocation overhead do not help when each task runs on its own virtual thread — each virtual thread would create a new cached instance, eliminating the caching benefit. Scoped values (JEP 446, Java 21 preview, Java 23+ standard) are the virtual-thread-friendly replacement: they are immutable within a scope and efficient to inherit across virtual threads.
Java
// ── Pinning: synchronized blocks prevent unmounting ───────────────────
Object monitor = new Object();

// This virtual thread is PINNED while inside synchronized:
Thread pinned = Thread.ofVirtual().start(() -> {
    synchronized (monitor) {
        System.out.println("Inside synchronized — virtual thread is PINNED");
        try {
            Thread.sleep(1000);  // carrier thread BLOCKED — pinning defeats scalability
        } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        System.out.println("Leaving synchronized — carrier free again");
    }
});
pinned.join();

// ── Fix: replace synchronized with ReentrantLock ─────────────────────
ReentrantLock lock = new ReentrantLock();

Thread unpinned = Thread.ofVirtual().start(() -> {
    lock.lock();
    try {
        System.out.println("Inside ReentrantLock — virtual thread CAN be unmounted");
        Thread.sleep(1000);   // virtual thread parks, carrier is FREE for other work
        System.out.println("Done — no pinning occurred");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } finally {
        lock.unlock();
    }
});
unpinned.join();

// ── Detecting pinning: JVM flag ───────────────────────────────────────
// Run with: -Djdk.tracePinnedThreads=full
// Output when pinning occurs:
// Thread[#21,ForkJoinPool-1-worker-1,5,CarrierThreads]
//     java.base/java.lang.VirtualThread$PinnedScope.run(VirtualThread.java:...)
//     ...
//     PinningExample.lambda$0(PinningExample.java:12)  ← synchronized block

// ── ThreadLocal vs virtual threads ────────────────────────────────────
ThreadLocal<String> localValue = new ThreadLocal<>();

// ThreadLocal IS per-virtual-thread (each virtual thread = separate Thread object):
Thread v1 = Thread.ofVirtual().start(() -> {
    localValue.set("Value from virtual thread 1");
    Thread.sleep(100);
    System.out.println(Thread.currentThread() + ": " + localValue.get());
    // "Value from virtual thread 1" — isolated correctly
});
Thread v2 = Thread.ofVirtual().start(() -> {
    localValue.set("Value from virtual thread 2");
    Thread.sleep(100);
    System.out.println(Thread.currentThread() + ": " + localValue.get());
    // "Value from virtual thread 2" — isolated from v1
});
v1.join(); v2.join();

// ── Scoped values: virtual-thread-friendly immutable inheritance ───────
// Java 21 (preview) / Java 23+ (standard):
ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

// Bind a value for the scope of a task and all virtual threads it spawns:
ScopedValue.where(REQUEST_ID, "req-12345").run(() -> {
    System.out.println("Main task: " + REQUEST_ID.get());   // req-12345
    Thread.ofVirtual().start(() -> {
        System.out.println("Child virtual thread: " + REQUEST_ID.get()); // req-12345 — inherited
    }).join();
});

// Outside the scope:
System.out.println("Outside scope: " + REQUEST_ID.isBound());   // false

Structured Concurrency, Use Cases, and Migration

Structured concurrency (JEP 428 preview in Java 19-21, JEP 453 in Java 21+) is an API that organizes concurrent sub-tasks so they form a tree: a parent task creates child tasks, and the parent task's lifetime encompasses all child tasks. StructuredTaskScope ensures that when the parent task finishes (normally or by exception), all child virtual threads are cancelled and joined. This eliminates orphaned background tasks, simplifies error propagation, and makes concurrent code as readable as sequential code. StructuredTaskScope.ShutdownOnFailure() implements a pattern where the first failing sub-task cancels all others and propagates the exception to the parent. StructuredTaskScope.ShutdownOnSuccess() implements a pattern where the first successful result cancels the remaining sub-tasks and returns that result — useful for racing multiple equivalent computations and taking the fastest. Custom StructuredTaskScope subclasses can implement arbitrary fan-out patterns. Virtual threads are the right tool for I/O-bound concurrent code: HTTP servers handling many concurrent requests, database-heavy applications, microservices calling multiple downstream services in parallel, and any code that currently uses thread pools as a scalability workaround for blocking I/O. They are not the right tool for CPU-bound parallel computation — for that, parallel streams or ForkJoinPool with platform threads are appropriate, since virtual threads are scheduled on the same number of carrier threads as platform threads. Migration from thread pools to virtual threads: replace Executors.newFixedThreadPool(N) with Executors.newVirtualThreadPerTaskExecutor() for I/O-bound work. The new executor submits each task as a new virtual thread rather than queuing tasks on N platform threads. Remove artificial thread pool limits that were introduced to avoid resource exhaustion — those limits constrain throughput unnecessarily with virtual threads. Replace synchronized with ReentrantLock where blocking occurs inside synchronized blocks. Remove thread-local caching of expensive-to-create objects (like database connections) that were cached to avoid per-thread creation overhead — with virtual threads, connection pools (bounded by database capacity, not thread count) are the right abstraction.
Java
// ── Structured Concurrency with StructuredTaskScope ───────────────────
// Java 21+
public record UserData(String name, String email) {}
public record OrderData(List<String> orders) {}

public UserProfile loadUserProfile(long userId) throws InterruptedException, ExecutionException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // Fork two concurrent virtual-thread tasks:
        StructuredTaskScope.Subtask<UserData>  userTask  = scope.fork(() -> fetchUserData(userId));
        StructuredTaskScope.Subtask<OrderData> orderTask = scope.fork(() -> fetchOrderData(userId));

        scope.join();           // wait for both to complete
        scope.throwIfFailed();  // propagate any exception — cancels all if one fails

        // Both succeeded — combine results:
        return new UserProfile(userTask.get(), orderTask.get());
    }
    // scope.close() automatically cancels/joins any remaining tasks
}

// ── ShutdownOnSuccess: race multiple computations, take the fastest ────
public String fetchWithFallback(String primaryUrl, String backupUrl)
        throws InterruptedException, ExecutionException {

    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
        scope.fork(() -> httpGet(primaryUrl));   // try primary
        scope.fork(() -> httpGet(backupUrl));    // try backup concurrently

        scope.join();   // wait for first success — cancels the other
        return scope.result();  // result of the winning task
    }
}

// ── Migration: thread pool → virtual threads ──────────────────────────
// BEFORE: bounded thread pool to limit concurrency for I/O-bound work
ExecutorService oldPool = Executors.newFixedThreadPool(200);  // 200 platform threads

Future<String> future1 = oldPool.submit(() -> fetchFromDatabase(1));
Future<String> future2 = oldPool.submit(() -> fetchFromDatabase(2));
// ... only 200 tasks can run concurrently — rest queue

// AFTER: virtual thread executor — no arbitrary limit needed
try (ExecutorService vtExec = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> vt1 = vtExec.submit(() -> fetchFromDatabase(1));
    Future<String> vt2 = vtExec.submit(() -> fetchFromDatabase(2));
    // ... 100,000 tasks can run concurrently if needed
    // I/O blocking unmounts virtual threads — carrier threads never starve
}

// ── When NOT to use virtual threads ───────────────────────────────────
// CPU-bound: virtual threads don't help — still limited by carrier count
// Sequential map/reduce on 10M numbers — use parallel streams, not virtual threads:
long sum = LongStream.range(0, 10_000_000).parallel().sum();   // ✓ correct tool

// WRONG: virtual threads for CPU work — no speedup, just overhead:
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Long>> futures = new ArrayList<>();
    for (int i = 0; i < 10_000_000; i++) {
        final int n = i;
        futures.add(exec.submit(() -> (long)(n * n)));  // 10M virtual threads — wasteful
    }
    long s = 0;
    for (Future<Long> f : futures) s += f.get();
}   // far slower than parallel streams for CPU work

// ── Correct use case: concurrent I/O with simple blocking code ────────
public List<String> fetchAll(List<String> urls) throws InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        List<StructuredTaskScope.Subtask<String>> tasks = urls.stream()
            .map(url -> scope.fork(() -> httpGet(url)))  // each URL = one virtual thread
            .toList();
        scope.join().throwIfFailed();
        return tasks.stream().map(StructuredTaskScope.Subtask::get).toList();
    }
}
// All URLs are fetched concurrently — each virtual thread parks during I/O
// Code is as simple as a sequential for loop but executes in parallel

Related Topics in Multithreading

Process vs Thread
A process is an independent program in execution with its own isolated memory space, file handles, and system resources, managed by the operating system and separated from all other processes by strict boundaries. A thread is a unit of execution that lives inside a process, sharing that process's memory, heap, and resources with every other thread in the same process. Java programs run inside a JVM process; the JVM itself creates and manages threads, and every Java application starts with at least one thread — the main thread — with additional threads created by the JVM for garbage collection, JIT compilation, signal handling, and other runtime tasks. Understanding the distinction between processes and threads is the foundation for all concurrent programming in Java: it determines what is shared and what is isolated, what is fast and what is expensive, what fails independently and what fails together. This entry covers the OS-level and JVM-level model of processes and threads, the memory model that follows from the shared-versus-isolated distinction, the cost model for creation and context switching, failure isolation and its consequences, inter-process and inter-thread communication mechanisms, and the practical decision of when to use multiple processes versus multiple threads.
Thread Basics
A Java thread is an instance of java.lang.Thread that represents an independent path of execution within the JVM process. Every thread has a lifecycle — from creation through runnable, running, blocked, waiting, timed-waiting, and terminated states — and a set of properties including its name, priority, daemon status, thread group, and uncaught exception handler. The Java memory model specifies what visibility guarantees exist between threads and when writes by one thread are guaranteed to be visible to another. Thread scheduling is controlled by the OS scheduler subject to hints from the JVM via thread priority; the JVM does not provide real-time scheduling guarantees. This entry covers the complete thread lifecycle and its state machine, thread properties and how they affect scheduling and JVM shutdown, the happens-before relationship and why it matters for visibility, daemon threads and their relationship to JVM shutdown, thread interruption as a cooperative cancellation mechanism, and the methods on Thread that every Java developer must understand.
Creating Threads
Java provides three primary abstractions for defining the work a thread will execute: the Thread class itself (subclassed to override run()), the Runnable interface (a task with no return value and no checked exception), and the Callable interface (a task with a return value and a declared checked exception). Each represents a different contract between the task and the infrastructure that runs it. Thread subclassing couples the task definition to the execution mechanism and is the oldest and least flexible approach. Runnable decouples the task from the thread, allowing the same Runnable to be submitted to thread pools, scheduled executors, or wrapped in Thread objects. Callable extends that decoupling to include a return value and exception propagation, returning a Future that allows the caller to retrieve the result or handle exceptions asynchronously. Understanding all three — their contracts, their limitations, and when to use each — is the foundation of concurrent programming in Java before reaching for higher-level constructs.
Thread Lifecycle
The Java thread lifecycle is the complete sequence of states a thread passes through from the moment a Thread object is constructed to the moment its execution ends. Java defines six states in the Thread.State enum — NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED — and the JVM transitions threads between these states in response to specific method calls, lock acquisitions, monitor notifications, timeouts, and exceptions. Each state has a precise meaning, a defined set of entry conditions, and a defined set of exit conditions. Understanding the lifecycle in full is prerequisite knowledge for diagnosing deadlocks, thread leaks, performance bottlenecks in thread dumps, and incorrect synchronization — all of which manifest as threads stuck in specific states. This entry covers every state in the lifecycle with its entry and exit conditions, all legal and illegal state transitions, how thread dumps represent each state, the interaction between lifecycle states and interruption, the effect of uncaught exceptions on lifecycle, and how to observe lifecycle transitions programmatically.