Virtual threads

Java · Advanced · 12 min

Virtual threads let plain, readable blocking code scale like async code — the biggest change to Java concurrency in years.

A traditional platform thread is a thin wrapper over an operating-system thread: each costs around a megabyte of stack and a kernel scheduling slot, so servers top out at a few thousand. A request that spends most of its time waiting on a database or HTTP call holds that scarce thread the whole time.

A virtual thread (final in Java 21) is managed by the JVM. It runs on a small pool of carrier platform threads, and when it blocks on I/O, a lock or sleep, the JVM unmounts it and frees the carrier for another virtual thread. You can have millions of them.

Creating them:

  • Executors.newVirtualThreadPerTaskExecutor() — one new virtual thread per task.
  • Thread.ofVirtual().start(runnable) or Thread.startVirtualThread(runnable).

The programming model is ordinary blocking code: write var body = client.send(request, ...), not callbacks. Stack traces, debuggers and profilers work as usual.

Guidelines:

  • Don't pool virtual threads. They are cheap; create one per task. To limit concurrency against a fragile resource, use a Semaphore, not a small pool.
  • They help I/O-bound workloads with many concurrent tasks. CPU-bound work gains nothing — there are still only as many cores.
  • In Java 21, blocking inside a synchronized block pins the virtual thread to its carrier, so frequent long blocking under synchronized can starve the carriers. Prefer ReentrantLock around blocking calls on Java 21; JDK 24 removed most pinning.
  • Be careful with ThreadLocal caches of expensive objects — with a million threads you get a million copies.

Frameworks such as Spring Boot, Helidon and Quarkus can run request handling on virtual threads with a configuration switch.

Example

import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

public class Virtual {
    static final Semaphore DB_PERMITS = new Semaphore(1_000); // limit concurrency, don't pool
    static String query(int id) throws InterruptedException {
        DB_PERMITS.acquire();
        try { Thread.sleep(Duration.ofMillis(100)); return "row " + id; } // simulated blocking I/O
        finally { DB_PERMITS.release(); }
    }

    public static void main(String[] args) {
        Instant start = Instant.now();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                int id = i;
                executor.submit(() -> query(id));             // one virtual thread per task
            }
        } // waits for all tasks
        // ~1 s: 10,000 calls x 100 ms with at most 1,000 in flight (sequential: ~17 min)
        System.out.println(Duration.between(start, Instant.now()).toSeconds() + " s");
    }
}

Remember

  • Virtual threads are cheap JVM-managed threads that unmount while blocked
  • Create one per task; limit access to resources with a Semaphore, not a pool
  • On Java 21, avoid long blocking inside synchronized (pinning); use ReentrantLock

Watch out for

  • Using virtual threads for CPU-heavy computation and expecting a speed-up.

Check yourself

  1. What happens when a virtual thread blocks on a socket read (outside synchronized)?
  2. You need at most 20 concurrent calls to a rate-limited API from virtual threads. Best tool?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.oracle.com/en/java/javase/21/core/virtual-threads.html