Skip to main content
Java · lesson 36 of 66
Open the Java page →
Java advanced Lesson 36 of 66

Virtual Threads in Java

Learn Java 21 virtual threads by running them: creating them, 100,000 blocking tasks, pinning, structured concurrency and scoped values.

By SkillByExample · Updated

Virtual threads (Java 21, Project Loom) are threads managed by the JVM instead of the operating system. When one blocks — on a sleep, a socket, a lock — the JVM unmounts it from its carrier thread and runs another, so thread-per-task code scales to hundreds of thousands of concurrent tasks. Every example is a single file run with the JDK 21 source launcher; the last two use preview APIs and need --enable-preview --source 21.

Creating virtual threads

import java.util.concurrent.Executors;

public class CreateDemo {
    public static void main(String[] args) throws Exception {
        Thread vt = Thread.ofVirtual().name("worker-1").start(() ->
            System.out.println("in " + Thread.currentThread().getName() + ", virtual=" + Thread.currentThread().isVirtual()));
        vt.join();

        Thread quick = Thread.startVirtualThread(() -> System.out.println("startVirtualThread ran"));
        quick.join();
        System.out.println("daemon=" + quick.isDaemon() + ", main is virtual=" + Thread.currentThread().isVirtual());

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var f = executor.submit(() -> Thread.currentThread().isVirtual() ? "task on a virtual thread" : "platform");
            System.out.println(f.get());
        }
    }
}
$ java CreateDemo.java
in worker-1, virtual=true
startVirtualThread ran
daemon=true, main is virtual=false
task on a virtual thread

Virtual threads use the ordinary Thread API: build one with Thread.ofVirtual(), start one with Thread.startVirtualThread, or — the usual choice for servers — submit tasks to newVirtualThreadPerTaskExecutor, which creates a fresh virtual thread per task. They are always daemon threads, so join (or the executor’s close) is what keeps main from exiting early.

100,000 blocking tasks

import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class ManyThreadsDemo {
    public static void main(String[] args) {
        AtomicInteger done = new AtomicInteger();
        long start = System.nanoTime();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 100_000; i++) {
                executor.submit(() -> {
                    Thread.sleep(Duration.ofSeconds(1));   // stands in for an I/O wait
                    return done.incrementAndGet();
                });
            }
        }                                                  // close() waits for every task
        long ms = (System.nanoTime() - start) / 1_000_000;
        System.out.println("completed: " + done.get());
        System.out.println("finished in under 5 seconds: " + (ms < 5_000));
    }
}
$ java ManyThreadsDemo.java
completed: 100000
finished in under 5 seconds: true

100,000 tasks each blocked for a full second, yet the whole batch finished in a few seconds, because all of them were sleeping at the same time — each on its own virtual thread, sharing a handful of carriers. With 100,000 platform threads the JVM would need gigabytes of stack or fail to create them. (The program prints a comparison instead of milliseconds so the output does not change between machines.)

Platform pool versus virtual threads

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThroughputDemo {
    static long run(ExecutorService executor, int tasks) {
        long start = System.nanoTime();
        try (executor) {
            for (int i = 0; i < tasks; i++)
                executor.submit(() -> { Thread.sleep(100); return null; });
        }
        return (System.nanoTime() - start) / 1_000_000;
    }

    public static void main(String[] args) {
        long platform = run(Executors.newFixedThreadPool(200), 2_000);
        long virtual = run(Executors.newVirtualThreadPerTaskExecutor(), 2_000);
        System.out.println("platform pool needs about 10 rounds of 100 ms: " + (platform >= 1_000));
        System.out.println("virtual threads take about one round: " + (virtual < 500));
        System.out.println("virtual at least 3x faster: " + (platform > 3 * virtual));
    }
}
$ java ThroughputDemo.java
platform pool needs about 10 rounds of 100 ms: true
virtual threads take about one round: true
virtual at least 3x faster: true

A pool of 200 platform threads can only have 200 sleeps in flight, so 2,000 tasks take ten 100 ms rounds — at least a second. The virtual-thread executor starts all 2,000 at once and finishes in roughly one round. The win comes purely from waiting concurrently; a CPU-bound task would not speed up, because the number of carriers is still the number of cores.

Pinning: synchronized versus ReentrantLock

import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;

public class PinningDemo {
    static long time(Runnable body) {
        long start = System.nanoTime();
        try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10; i++) ex.submit(body);
        }
        return (System.nanoTime() - start) / 1_000_000;
    }

    static void sleep100() {
        try { Thread.sleep(100); } catch (InterruptedException e) { throw new RuntimeException(e); }
    }

    public static void main(String[] args) {
        long pinned = time(() -> { Object own = new Object(); synchronized (own) { sleep100(); } });
        long free = time(() -> { var own = new ReentrantLock(); own.lock(); try { sleep100(); } finally { own.unlock(); } });
        System.out.println("synchronized: 10 sleeps ran one after another: " + (pinned >= 900));
        System.out.println("ReentrantLock: 10 sleeps overlapped: " + (free < 500));
    }
}
$ java -Djdk.virtualThreadScheduler.parallelism=1 PinningDemo.java
synchronized: 10 sleeps ran one after another: true
ReentrantLock: 10 sleeps overlapped: true

The system property limits the scheduler to one carrier thread to make the effect obvious. Each task locks its own object, so there is no contention — yet the synchronized version took ten full sleeps, because on JDK 21 a virtual thread that blocks inside synchronized stays pinned to its carrier and nothing else can run. With ReentrantLock the sleeping thread unmounts and all ten sleeps overlap. JDK 24 (JEP 491) removed this limitation for synchronized.

Structured concurrency (preview)

import java.util.concurrent.StructuredTaskScope;

public class StructuredDemo {
    record Page(String profile, String orders) {}

    static String slowOrders() throws InterruptedException {
        try {
            Thread.sleep(5_000);
            return "orders";
        } catch (InterruptedException e) {
            System.out.println("orders task cancelled");
            throw e;
        }
    }

    static Page load(boolean profileFails) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            var profile = scope.fork(() -> {
                Thread.sleep(50);
                if (profileFails) throw new IllegalStateException("profile service down");
                return "Alice";
            });
            var orders = scope.fork(() -> profileFails ? slowOrders() : "2 orders");
            scope.join().throwIfFailed();
            return new Page(profile.get(), orders.get());
        }
    }

    public static void main(String[] args) throws Exception {
        System.out.println(load(false));
        long start = System.nanoTime();
        try {
            load(true);
        } catch (Exception e) {
            System.out.println("failed: " + e.getCause().getMessage());
        }
        System.out.println("did not wait for the 5 s task: " + ((System.nanoTime() - start) / 1_000_000 < 2_000));
    }
}
$ java --enable-preview --source 21 StructuredDemo.java
Note: StructuredDemo.java uses preview features of Java SE 21.
Note: Recompile with -Xlint:preview for details.
Page[profile=Alice, orders=2 orders]
orders task cancelled
failed: profile service down
did not wait for the 5 s task: true

fork runs each subtask on its own virtual thread, and join waits for the scope. With ShutdownOnFailure, the first failure shuts the scope down and interrupts the siblings, so the five-second orders call is cancelled instead of leaking, and throwIfFailed rethrows the cause wrapped in an ExecutionException. The Note: lines come from the compiler because the API is a preview in Java 21.

Scoped values instead of ThreadLocal (preview)

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

    static void handle(String id) {
        ScopedValue.where(REQUEST_ID, id).run(() -> {
            log("validating");
            log("saving");
        });
    }

    static void log(String step) {
        System.out.println("[" + REQUEST_ID.get() + "] " + step);
    }

    public static void main(String[] args) throws Exception {
        Thread a = Thread.ofVirtual().start(() -> handle("req-1"));
        a.join();
        Thread b = Thread.ofVirtual().start(() -> handle("req-2"));
        b.join();
        System.out.println("bound outside the scope? " + REQUEST_ID.isBound());
    }
}
$ java --enable-preview --source 21 ScopedValueDemo.java
Note: ScopedValueDemo.java uses preview features of Java SE 21.
Note: Recompile with -Xlint:preview for details.
[req-1] validating
[req-1] saving
[req-2] validating
[req-2] saving
bound outside the scope? false

A ScopedValue is bound for the duration of run and visible to everything called from it, without passing a parameter through every method. Unlike a ThreadLocal it is immutable and is unbound automatically when the scope ends, so there is nothing to clean up — which matters when millions of short-lived virtual threads would each carry their own ThreadLocal copy. Scoped values became final in JDK 25.

In Spring Boot 3.2+, setting spring.threads.virtual.enabled=true runs each web request on a virtual thread, so ordinary blocking code (JDBC, RestClient) gets these benefits unchanged.

Practice

  1. Start 5 virtual threads in a loop, collect their names into a thread-safe list, join them, and print the sorted list.
Solution
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class FiveThreads {
    public static void main(String[] args) throws InterruptedException {
        List<String> names = Collections.synchronizedList(new ArrayList<>());
        List<Thread> threads = new ArrayList<>();
        for (int i = 1; i <= 5; i++)
            threads.add(Thread.ofVirtual().name("v-" + i).start(() -> names.add(Thread.currentThread().getName())));
        for (Thread t : threads) t.join();
        Collections.sort(names);
        System.out.println(names);
    }
}
$ java FiveThreads.java
[v-1, v-2, v-3, v-4, v-5]

The threads finish in an unpredictable order, so the list is sorted before printing.

  1. Limit concurrency to 3 simultaneous “database calls” with a Semaphore while still using one virtual thread per task, and print the maximum concurrency observed.
Solution
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;

public class SemaphoreLimit {
    public static void main(String[] args) {
        Semaphore permits = new Semaphore(3);
        AtomicInteger inFlight = new AtomicInteger(), max = new AtomicInteger();
        try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 50; i++) {
                ex.submit(() -> {
                    permits.acquire();
                    try {
                        max.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
                        Thread.sleep(20);
                    } finally {
                        inFlight.decrementAndGet();
                        permits.release();
                    }
                    return null;
                });
            }
        }
        System.out.println("max concurrent calls: " + max.get());
    }
}
$ java SemaphoreLimit.java
max concurrent calls: 3

This is the virtual-thread way to protect a scarce resource: cheap threads for every task, and a Semaphore around just the part that must be limited.

  1. Replace the sleep in ThroughputDemo with a CPU-bound loop and check whether virtual threads still win.
Solution
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CpuBound {
    static volatile long sink;

    static long run(ExecutorService executor) {
        long start = System.nanoTime();
        try (executor) {
            for (int i = 0; i < 64; i++)
                executor.submit(() -> { long s = 0; for (long j = 0; j < 50_000_000L; j++) s += j; sink = s; });
        }
        return (System.nanoTime() - start) / 1_000_000;
    }

    public static void main(String[] args) {
        int cores = Runtime.getRuntime().availableProcessors();
        long platform = run(Executors.newFixedThreadPool(cores));
        long virtual = run(Executors.newVirtualThreadPerTaskExecutor());
        System.out.println("virtual more than 2x faster? " + (virtual * 2 < platform));
    }
}
$ java CpuBound.java
virtual more than 2x faster? false

A busy loop never blocks, so a virtual thread never unmounts; with as many carriers as cores, both versions have the same compute capacity and finish in similar time.

Frequently Asked Questions

What is the difference between a virtual thread and a platform thread?
A platform thread wraps one operating-system thread, which reserves a large stack and is expensive to create, so applications pool a few hundred of them. A virtual thread is scheduled by the JVM onto a small pool of carrier platform threads and costs only a few kilobytes, so millions can exist. Both are java.lang.Thread and use the same API.
Do virtual threads make Java code faster?
They increase throughput for code that spends most of its time blocked on I/O, such as HTTP calls and database queries, because a blocked virtual thread releases its carrier thread. They do not make CPU-bound work faster, since the number of carriers still matches the CPU cores. Use parallel streams or a ForkJoinPool for CPU-heavy computation.
What is pinning in virtual threads?
A virtual thread is pinned when it cannot unmount from its carrier while blocking, so the carrier is blocked too. In Java 21 to 23 this happens when blocking inside a synchronized block or method and during native calls. Replacing synchronized with ReentrantLock avoids it; JDK 24 (JEP 491) removed pinning for synchronized, but native calls can still pin.
Should I pool virtual threads?
No. Virtual threads are cheap enough to create one per task, which is what Executors.newVirtualThreadPerTaskExecutor() does. Pooling them adds complexity without benefit. If you need to limit concurrency, for example to protect a database, use a Semaphore around the limited resource instead of a fixed-size pool.
What is structured concurrency in Java?
Structured concurrency, through StructuredTaskScope, treats a group of concurrent subtasks as one unit: they are forked inside a scope, the scope waits for them, and when one fails the others are cancelled before the scope closes. It is a preview API in Java 21 that has been revised in later releases, so it needs --enable-preview.