Virtual Threads
Use cheap virtual threads for blocking I/O, limit concurrency with semaphores, and know when they do not help.
try (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
Semaphoreor the connection pool itself. - They are always daemon threads and have no name unless you give them one.
- Be careful with
ThreadLocalcaches. 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
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.
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: trueLimiting 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.
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: falseWhich 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.
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