Java 21 Virtual Threads

July 29, 20265 min readUpdated 8/20/2026

A virtual thread is a thread that costs almost nothing to create. You can have millions of them. That single change removes the reason most server code was written asynchronously, and it is the biggest thing to happen to Java concurrency in twenty years.

The problem

A platform thread — the only kind Java had until 21 — is a wrapper around an operating system thread. It costs about a megabyte of stack, takes real time to create, and the OS can only schedule so many. So servers pooled them: a few hundred threads, shared across many requests.

That works until a request blocks on I/O. A thread waiting for a database is doing nothing, but it is still occupying a pool slot. With 200 threads, 200 slow queries means the 201st request waits — even though the machine is idle.

The industry's answer was asynchronous code: callbacks, then futures, then reactive streams. It works, and it costs you readable stack traces, straightforward debugging, and the ability to write try/catch around a sequence of operations.

The change

class Demo {
    void run() throws InterruptedException {
        // A platform thread — expensive
        Thread platform = Thread.ofPlatform().start(() -> System.out.println("platform"));

        // A virtual thread — cheap
        Thread virtual = Thread.ofVirtual().start(() -> System.out.println("virtual"));

        platform.join();
        virtual.join();
    }
}

A virtual thread is scheduled by the JVM onto a small pool of platform threads. When it blocks, the JVM unmounts it — parks its stack on the heap and gives the carrier thread to someone else. When the blocking call returns, it is remounted, possibly on a different carrier.

The crucial part: your code does not change. A blocking call still looks like a blocking call. The unmounting happens underneath.

class Demo {
    void run() throws InterruptedException {
        // A million threads. On platform threads this would exhaust memory immediately.
        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 1_000_000; i++) {
            threads.add(Thread.ofVirtual().start(() -> {
                try {
                    Thread.sleep(Duration.ofSeconds(1));
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }));
        }
        for (Thread t : threads) {
            t.join();
        }
    }
}

How you will actually use them

Rarely by creating threads directly. The executor is the normal route, and it is a one-line change:

class Demo {
    void run() throws Exception {
        // A thread PER TASK, not a pool. Closing waits for every task to finish.
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                int id = i;
                executor.submit(() -> {
                    Thread.sleep(Duration.ofMillis(100));   // blocking is fine now
                    return id;
                });
            }
        }   // AutoCloseable since Java 19 — no shutdown()/awaitTermination() dance
    }
}

Note there is no pool size. Pooling exists to share expensive objects, and virtual threads are not expensive — pooling them would reintroduce the queueing you are trying to remove.

In a Spring Boot or Jakarta application you usually turn them on with one property rather than writing any of this, and every request handler gets its own virtual thread.

Write blocking code again

record User(String id, String name) { }
record Order(String id) { }

class Service {
    User loadUser(String id) { return new User(id, "Folau"); }
    List<Order> loadOrders(String id) { return List.of(new Order("o-1")); }

    // Straight-line, debuggable, and on a virtual thread it scales
    String summary(String id) {
        User user = loadUser(id);
        List<Order> orders = loadOrders(id);
        return user.name() + " has " + orders.size() + " orders";
    }
}

That method has a real stack trace, steps in a debugger, and can be wrapped in try/catch. The equivalent CompletableFuture chain has none of those properties as naturally. Virtual threads make the simple version the scalable one.

What they do not fix

They do not make CPU-bound work faster. A virtual thread still needs a carrier thread to run on, and there are only as many of those as you have cores. Virtual threads help when threads are waiting, not when they are computing.

Pinning. A virtual thread inside a synchronized block cannot unmount — it pins its carrier for the duration. If that block does I/O, you have blocked a carrier thread, and enough of those starve the whole scheduler:

class Demo {
    private final Object lock = new Object();
    private final ReentrantLock better = new ReentrantLock();

    void pins() throws Exception {
        synchronized (lock) {
            Thread.sleep(1000);          // pins the carrier thread
        }
    }

    void doesNot() throws Exception {
        better.lock();
        try {
            Thread.sleep(1000);          // unmounts cleanly
        } finally {
            better.unlock();
        }
    }
}

Replacing synchronized with ReentrantLock around blocking calls is the fix. This mattered enough that later releases have been working to remove the limitation entirely.

ThreadLocal. Still works, but a million threads each holding a ThreadLocal value is a million objects. Scoped values are the intended replacement.

Structured concurrency

Virtual threads make it cheap to fan out, and fanning out raises the question of what happens when one branch fails. Structured concurrency — a preview API alongside virtual threads — ties the lifetimes of related tasks together so that cancelling or failing one cancels the rest:

record User(String id) { }
record Orders(int count) { }

class Service {
    User loadUser(String id) { return new User(id); }
    Orders loadOrders(String id) { return new Orders(3); }

    // The shape, using plain virtual threads. Both start together; if either
    // fails, join() surfaces it rather than leaving the other running unnoticed.
    String summary(String id) throws Exception {
        try (ExecutorService scope = Executors.newVirtualThreadPerTaskExecutor()) {
            Future<User> user = scope.submit(() -> loadUser(id));
            Future<Orders> orders = scope.submit(() -> loadOrders(id));
            return user.get().id() + ": " + orders.get().count();
        }
    }
}

The principle is worth knowing even before the API stabilises: a task that spawns subtasks should not outlive them, and an error in one subtask should not leave its siblings running. That is the guarantee unstructured thread-spawning never gave you, and the reason "just start a thread" was bad advice even when threads were cheap.

The rule

Use virtual threads for anything that waits — HTTP calls, database queries, file and queue operations. Keep a small fixed pool of platform threads for CPU-bound work. And when you adopt them, audit your synchronized blocks for blocking calls, because that is the one place the abstraction leaks.

Next

Pattern matching for switch is next — the feature that finishes what instanceof pattern matching started.