☕ Java

Virtual Threads

Virtual threads, finalized in Java 21 (JEP 444, after preview in Java 19–20) as part of Project Loom, are lightweight threads implemented and scheduled by the JVM itself rather than mapped one-to-one onto a limited number of operating-system threads, allowing applications to create and run enormous numbers of concurrent threads — potentially millions — without the memory and context-switching overhead that has always made traditional, OS-backed (platform) threads an expensive, carefully-rationed resource. This entry covers exactly why platform threads were a scarce resource and how that scarcity shaped Java's concurrency idioms for decades, how virtual threads are created and how mounting/unmounting onto carrier platform threads works, the concept of pinning and which operations prevent a virtual thread from unmounting, and practical guidance on when virtual threads help versus when they don't.

Motivation — Platform Threads Were Always a Scarce, Expensive Resource

Every java.lang.Thread created before Java 21 (a platform thread) is backed one-to-one by an actual operating-system thread, and OS threads are a comparatively heavyweight resource: each carries a substantial fixed memory footprint (typically around a megabyte, dominated by its reserved stack), and the operating system's scheduler must context-switch between them, an operation with real, non-trivial CPU cost that scales with the number of threads competing for the CPU. Because of this, a typical JVM application could realistically support some thousands of concurrent platform threads, not millions — far short of what would be needed to dedicate one thread per concurrent unit of application work (one thread per simultaneous incoming request, for instance) in any application handling more than a modest level of concurrent load. This scarcity directly shaped how Java concurrency was practiced for two decades: rather than blocking a thread for the full duration of a slow operation (waiting on a network response, a database query, a file read) and dedicating one thread per concurrent request, applications under real concurrent load adopted thread pools (a fixed, carefully-sized number of reusable platform threads, since creating one per request would exhaust the OS thread budget) combined increasingly with asynchronous, non-blocking programming models (callbacks, CompletableFuture chains, and reactive libraries like RxJava or Project Reactor) specifically to avoid ever letting a thread sit idle, blocked, while waiting on a slow I/O operation — since an idle-but-blocked platform thread is still consuming its full fixed memory and scheduling overhead while accomplishing nothing. The cost of this asynchronous style was substantial added code complexity: straightforward, sequential-looking logic ("call the database, then process the result, then call the downstream service, then return") had to be restructured into a chain of callbacks or composed CompletableFuture stages, where the natural sequential flow of the logic became fragmented across separate lambda bodies, error handling required deliberate propagation through every stage of the chain rather than a simple try/catch, and a stack trace captured during an exception in an asynchronous callback often bore little resemblance to the actual logical call chain that led there, making debugging meaningfully harder than it would be for equivalent simple, sequential, blocking code. Virtual threads, delivered through Project Loom and finalized in Java 21 (JEP 444), directly remove the scarcity that drove this whole shift: a virtual thread is implemented by the JVM, not the OS, with a dramatically smaller memory footprint and no direct OS-scheduler involvement for each one individually, making it practical to create one virtual thread per concurrent unit of work — even potentially millions of them — while writing straightforward, sequential, blocking-style code, since blocking a virtual thread no longer means tying up a scarce, expensive OS thread for the duration of that block.
Java
// ── The old, scarcity-driven pattern — bounded thread pool + async style ─
ExecutorService pool = Executors.newFixedThreadPool(200);   // a CAREFULLY SIZED, limited pool —
// creating thousands of platform threads directly would exhaust OS thread resources

CompletableFuture<String> result = CompletableFuture
    .supplyAsync(() -> callDatabase(), pool)
    .thenApplyAsync(dbResult -> callDownstreamService(dbResult), pool)
    .thenApply(serviceResult -> processResult(serviceResult));
// Sequential LOGIC fragmented across separate lambda stages — error handling,
// stack traces, and natural control flow all become harder to follow

// ── Java 21+ — virtual threads make simple, sequential, BLOCKING code viable
// at massive concurrency, with no thread-pool sizing concerns at all:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {           // 100,000 concurrent tasks — completely
        executor.submit(() -> {                    // impractical with platform threads,
            String dbResult = callDatabase();        // routine with virtual threads
            String serviceResult = callDownstreamService(dbResult);
            return processResult(serviceResult);
            // Straightforward, sequential, BLOCKING code — no callback chain,
            // no CompletableFuture composition, natural stack traces on exception
        });
    }
}

Creating Virtual Threads and How Mounting Works

Virtual threads can be created directly via Thread.ofVirtual().start(runnable) (and the corresponding .unstarted(...) for deferred starting), or — the pattern expected to be far more common in practice — via Executors.newVirtualThreadPerTaskExecutor(), an ExecutorService that creates a brand-new virtual thread for every individual submitted task rather than drawing from a fixed-size reusable pool, which is the deliberately intended usage pattern: because virtual threads are so lightweight, there is generally no need to pool and reuse them the way platform threads must be pooled, and creating a fresh one per task is itself the cheap, idiomatic approach. Virtual threads also work directly with the existing Thread API and most existing concurrency utilities (java.util.concurrent's locks, blocking queues, CompletableFuture, and so on) without modification, since the entire design goal was to let existing blocking-style code run essentially unmodified on virtual threads rather than requiring a new, separate concurrency API. Internally, every virtual thread is, when actually running, mounted onto a platform thread called its carrier thread, drawn from a small pool of carrier threads (by default sized to the number of available processors) managed by the JVM's own scheduler for virtual threads. When a virtual thread performs a blocking operation that the JVM specifically knows how to handle this way — most blocking I/O operations and many java.util.concurrent APIs were updated specifically for this purpose — the virtual thread unmounts from its carrier thread entirely, freeing that carrier thread to mount and run a different virtual thread in the meantime, and the original virtual thread's state is parked, to be remounted onto some carrier thread (not necessarily the same one) once the blocking operation actually completes. This unmounting is exactly what allows a small, fixed number of carrier (platform) threads to service an enormous number of concurrent virtual threads, even when many of those virtual threads are simultaneously blocked on slow I/O — the scarce platform-thread resource is only actually consumed while a virtual thread has genuine CPU work to do, not for the entire duration it happens to be logically blocked. This mounting/unmounting happens transparently to the application code running on the virtual thread — ordinary blocking calls like an InputStream read or a synchronous HTTP client call simply block from the virtual thread's own point of view, with the unmount/remount machinery operating beneath that level entirely, which is precisely what allows existing, ordinary-looking blocking code to run on virtual threads with no changes required.
Java
// ── Creating virtual threads directly ───────────────────────────────────
Thread vt = Thread.ofVirtual().start(() -> {
    System.out.println("Running on: " + Thread.currentThread());   // isVirtual() == true
});
vt.join();

// ── The idiomatic pattern — one virtual thread PER submitted task ──────
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = new ArrayList<>();
    for (int i = 0; i < 10_000; i++) {
        int taskId = i;
        futures.add(executor.submit(() -> {
            Thread.sleep(Duration.ofMillis(100));   // ordinary BLOCKING call — virtual thread
            return "Task " + taskId + " done";        // UNMOUNTS from its carrier during the sleep,
        }));                                           // freeing that carrier for other virtual threads
    }
    for (Future<String> f : futures) {
        System.out.println(f.get());
    }
}
// 10,000 concurrent virtual threads, each blocking for 100ms, complete in roughly
// 100ms total wall-clock time — NOT 10,000 x 100ms — because the small pool of
// carrier platform threads is shared efficiently via mount/unmount, not exhausted

// ── Existing concurrency APIs work unmodified on virtual threads ───────
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
Thread.ofVirtual().start(() -> {
    try {
        String item = queue.take();   // ordinary blocking call — unmounts the carrier while waiting
        process(item);
    } catch (InterruptedException e) { /* ... */ }
});

Pinning, Carrier-Thread Limitations, and When Virtual Threads Help

Not every blocking operation allows a virtual thread to unmount from its carrier — certain operations cause the virtual thread to remain mounted ("pinned") to its carrier platform thread for the duration of the block, meaning the carrier thread is genuinely unavailable to run any other virtual thread during that time, exactly as if it were an ordinary platform thread blocking directly. The most significant pinning case in Java 21 is a synchronized block or method: entering synchronized while running on a virtual thread pins that virtual thread to its carrier for as long as it holds the lock (this was specifically called out as a known limitation in JEP 444 itself, with subsequent JDK releases after 21 working to reduce or remove this pinning behavior in various cases). Native method calls and certain foreign-function-interface boundary crossings can also pin a virtual thread to its carrier for the call's duration, since the JVM cannot transparently unmount a virtual thread out from underneath code executing outside the JVM's own control. Pinning matters practically because if a large number of virtual threads simultaneously pin their carriers (for instance, many virtual threads all blocked simultaneously inside synchronized blocks contending for a heavily-used lock), the small, fixed pool of carrier threads can become exhausted by pinning alone, in which case virtual threads stop providing their intended scalability benefit and the application's effective concurrency reverts toward being bounded by the small carrier-thread pool size, similar to the platform-thread-pool scarcity virtual threads were introduced specifically to escape. The general mitigation, where heavily-contended locking is unavoidable, is preferring java.util.concurrent.locks.ReentrantLock over synchronized in code that will run on virtual threads, since ReentrantLock does not pin the virtual thread to its carrier the way synchronized does. Virtual threads are specifically designed to help with I/O-bound, high-concurrency workloads — many simultaneous tasks that spend most of their time blocked waiting on network or other I/O rather than performing sustained CPU computation — which is exactly the profile of a typical server handling many simultaneous client requests, each largely waiting on a database or downstream service call. Virtual threads provide no benefit at all for CPU-bound work: a virtual thread performing sustained computation, with no blocking, simply occupies its carrier thread for that entire duration exactly as a platform thread would, and a CPU-bound workload is fundamentally limited by the number of actual CPU cores available regardless of how many virtual (or platform) threads are used to express it — virtual threads solve a concurrency-scale problem caused specifically by blocking, not a parallelism problem caused by limited CPU throughput, and applying them to CPU-bound work brings all the same creation/scheduling mechanics with none of the actual benefit they're designed to provide.
Java
// ── PITFALL: synchronized PINS a virtual thread to its carrier ─────────
Object lock = new Object();

Thread.ofVirtual().start(() -> {
    synchronized (lock) {                  // virtual thread is PINNED here —
        slowBlockingOperation();             // cannot unmount during this entire block,
    }                                         // carrier thread is unavailable to others meanwhile
});

// ── Mitigation — prefer ReentrantLock over synchronized for code on virtual threads
ReentrantLock reentrantLock = new ReentrantLock();

Thread.ofVirtual().start(() -> {
    reentrantLock.lock();
    try {
        slowBlockingOperation();    // does NOT pin the carrier — virtual thread can
    } finally {                       // unmount normally even while "holding" this lock
        reentrantLock.unlock();
    }
});

// ── Virtual threads help I/O-bound concurrency; they do NOT help CPU-bound work
// GOOD fit — many concurrent tasks, mostly waiting on I/O:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (String url : thousandsOfUrls) {
        executor.submit(() -> fetchUrl(url));   // mostly blocked waiting on network I/O —
    }                                              // virtual threads scale this very well
}

// POOR fit — sustained CPU computation, no blocking at all:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> computeExpensiveHash(largeData));   // pure CPU work —
        // still fundamentally limited by actual CPU CORE count; virtual threads add
        // creation/scheduling overhead here with NONE of their intended benefit
    }
}
// For CPU-bound work, a bounded pool sized close to availableProcessors()
// (e.g. a regular fixed thread pool) remains the appropriate tool, not virtual threads

Related Topics in Java 9 to 21 Features

Module System (JPMS)
The Java Platform Module System (JPMS), introduced in Java 9 under Project Jigsaw, adds a module as a new, higher-level unit of code organization above the package, allowing a JAR-like artifact to explicitly declare which packages it exports for use by other modules, which modules it depends on, and which packages it keeps entirely internal and inaccessible from outside — closing a long-standing gap in the classpath model where every public class in every JAR on the classpath was accessible to every other JAR, regardless of whether that access was intended. JPMS was also the mechanism used to decompose the JDK itself, which had grown into one monolithic rt.jar, into roughly 90 separate modules, enabling smaller custom runtime images and explicit internal-API encapsulation (notably restricting access to sun.* and other internal packages that many libraries had relied on despite warnings). This entry covers the classpath problems JPMS solves, module-info.java syntax and the requires/exports/opens directives, the distinction between the classpath and module path including automatic and unnamed modules, and jlink custom runtime images.
JShell
JShell, introduced in Java 9, is an interactive Read-Eval-Print Loop (REPL) bundled directly with the JDK, allowing Java statements, expressions, method, class, and variable declarations to be entered and evaluated one at a time at an interactive prompt, with immediate feedback — without requiring a class wrapper, a main method, explicit compilation, or a build tool, all of which Java had always required for even the smallest snippet of executable code before Java 9. This solved a long-standing friction point for learning, quick experimentation, and API exploration, where languages like Python or Ruby offered an immediate interactive prompt but Java required the full edit-compile-run cycle even to test a single line. This entry covers what problem JShell solves and how it differs from a script, core REPL usage including implicit variable creation and forward references, snippet management commands, and how to feed JShell external classes and files.
Collection Factory Methods
Collection factory methods — List.of(...), Set.of(...), and Map.of(...)/Map.ofEntries(...) — introduced in Java 9, provide a concise, standard way to create small, fixed-content, genuinely immutable collections in a single expression, replacing the previously common but verbose combination of Arrays.asList(...), Collections.unmodifiableList(...), and manual add() calls, none of which produced a collection that was both concise to write and fully immutable. This entry covers exactly what problem these factories solve compared to the older idioms, the specific immutability and null-rejection guarantees they provide (which are stricter than Collections.unmodifiableXxx), their behavior around duplicate keys/elements, and important caveats around mutability expectations and performance for very small collections.
Private Interface Methods
Private interface methods, introduced in Java 9, allow an interface to declare methods marked private — with a full method body — that are accessible only from within that same interface (from its default methods, its other private methods, and its private static methods), but completely invisible to implementing classes and to any code outside the interface. They close a code-duplication gap left after Java 8's default methods: when an interface has multiple default methods that share common logic, that shared logic previously either had to be duplicated in each default method or exposed as a public default method purely to enable reuse, polluting the interface's actual API surface with a method meant only for internal reuse. This entry covers the specific problem they solve, the distinction between private instance and private static interface methods, the rules governing what they can call and be called from, and how they interact with the broader default-method design Java established in version 8.