Virtual Threads

Use cheap virtual threads for blocking I/O, limit concurrency with semaphores, and know when they do not help.

Syntaxtry (var executor = Executors.newVirtualThreadPerTaskExecutor()) { Future<T> f = executor.submit(callable); } Thread vt = Thread.ofVirtual().name("req-", 1).start(runnable);

Virtual threads, final since Java 21, are threads managed by the JVM rather than the operating system. When a virtual thread blocks on I/O, a lock or sleep, the JVM unmounts it from its carrier (a small pool of platform threads) and runs another one. A virtual thread keeps its stack on the heap and it grows only as needed, instead of reserving a large native stack, so a service can have hundreds of thousands of them.

Why back-end developers care

A typical request handler spends most of its time waiting: for the database, another HTTP service, a cache. With platform threads, throughput was capped by the size of the thread pool, which pushed people towards reactive code. Virtual threads let you keep simple, blocking, one-request-per-thread code and still handle very high concurrency. Spring Boot can run its request handling on them with spring.threads.virtual.enabled=true.

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> handle(request));
}
Thread.ofVirtual().name("request-", 1).start(task);
Thread.startVirtualThread(task);

Rules that differ from platform threads

  • Do not pool them. They are cheap to create; make a new one per task. Pooling only limits concurrency, which is better done explicitly.
  • Limit access to scarce resources. Ten thousand virtual threads can all try to use a database with twenty connections. Bound that with a Semaphore or the connection pool itself.
  • They are always daemon threads and have no name unless you give them one.
  • Be careful with ThreadLocal caches. Caching an expensive object per thread made sense with 200 pooled threads. With one virtual thread per request, you create one per request and gain nothing.

When they do not help

Virtual threads make waiting cheap; they do not make computation faster. CPU-bound work such as image resizing or hashing still needs as many cores as before, and a fixed pool sized to the core count suits it better.

In Java 21 a virtual thread blocking inside a synchronized block stayed pinned to its carrier, which could starve the carrier pool. Java 24 (JEP 491) removed that limitation for synchronized, so on Java 25 you no longer need to rewrite synchronized to ReentrantLock for this reason. Pinning still happens while a virtual thread is running native code.

If you suspect pinning, record with Java Flight Recorder and look for the jdk.VirtualThreadPinned event.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Handle each incoming HTTP request on its own virtual thread so blocking JDBC calls do not cap throughput.
  • Fan out calls to dozens of downstream services from a batch job without managing a pool size.
  • Guard a database that allows twenty connections with a Semaphore while thousands of virtual threads wait cheaply.

More examples

Creating virtual threads directly

Virtual threads have an empty name unless you set one, and they are always daemon threads, so join them or use an executor that waits.

Example · java
void main() throws InterruptedException {
    Thread vt = Thread.ofVirtual().name("request-", 1).start(() ->
        IO.println(Thread.currentThread().getName() + " virtual=" + Thread.currentThread().isVirtual()));
    vt.join();
    Thread quick = Thread.startVirtualThread(() ->
        IO.println("unnamed: '" + Thread.currentThread().getName() + "'"));
    quick.join();
    IO.println("daemon: " + vt.isDaemon());
}

// Output:
// request-1 virtual=true
// unnamed: ''
// daemon: true

Limiting database access with a Semaphore

Twenty virtual threads start, but the semaphore lets only three into the query at once, so the in-flight count never passes the permit count. This is how you protect a database or a rate-limited API.

Example · java
final int permits = 3;
Semaphore dbPermits = new Semaphore(permits);
AtomicInteger inFlight = new AtomicInteger();
AtomicBoolean overLimit = new AtomicBoolean();

String query(int id) throws InterruptedException {
    dbPermits.acquire();   // waits cheaply when three queries are running
    try {
        if (inFlight.incrementAndGet() > permits) overLimit.set(true);
        Thread.sleep(50);
        return "row " + id;
    } finally {
        inFlight.decrementAndGet();
        dbPermits.release();
    }
}

void main() throws Exception {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<Future<String>> rows = new ArrayList<>();
        for (int i = 1; i <= 20; i++) {
            int id = i;
            rows.add(executor.submit(() -> query(id)));
        }
        IO.println(rows.getLast().get());
    }
    IO.println("tasks: 20, more than " + permits + " queries at once: " + overLimit.get());
}

// Output:
// row 20
// tasks: 20, more than 3 queries at once: false

Which kind of thread runs the task

Switching executors is often the only code change needed. The main thread of a program launched with the java launcher is a platform thread.

Example · java
void main() throws Exception {
    Callable<String> where = () -> Thread.currentThread().isVirtual() ? "virtual" : "platform";
    try (ExecutorService fixed = Executors.newFixedThreadPool(2);
         ExecutorService perTask = Executors.newVirtualThreadPerTaskExecutor()) {
        IO.println("fixed pool: " + fixed.submit(where).get());
        IO.println("virtual per task: " + perTask.submit(where).get());
    }
    IO.println("main: " + where.call());
}

// Output:
// fixed pool: platform
// virtual per task: virtual
// main: platform

Discussion

  • Be the first to comment on this lesson.