Series navigation
Written by
Jagdish Salgotra
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
Discover Java's virtual threads revolution with Project Loom. Learn why Java's threading model is changing forever, performance benefits, migration strategies, and real-world implementation examples. Complete guide to lightweight concurrency in Java 21+.
Written by
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
Note This series uses Java 21 as the baseline. Part 1 uses virtual threads only (JEP 444), so no preview flags are needed here. Later parts that use structured concurrency (
StructuredTaskScope, JEP 453) require--enable-preview. See Part 9 for Java 21 -> Java 25 migration guidance.
Virtual threads are easy to oversell. They do not make a database faster, they do not remove the need for timeouts, and they do not turn CPU-bound code into scalable code. What they change is the cost of waiting.
That matters because a lot of server-side Java code waits. It waits for a database connection, a remote HTTP call, a file read, a cache call, or an internal service that is itself waiting on something else. Before virtual threads, Java developers usually protected platform threads with pools. That made sense because platform threads are expensive enough that an application cannot casually create one for every request and every blocking operation.
The problem is that once the pool becomes the shape of the program, the pool also becomes the queue. A request may spend most of its time not doing useful work and not waiting on the downstream dependency. It is just waiting for one of the limited platform threads to become available.
Virtual threads let the code express the simpler model again: one task, one thread, ordinary blocking code.
The companion repository has two small HTTP servers that make the comparison concrete.
PlatformThreadPoolServer.java starts an HTTP server on port 8080 and routes /api work through a fixed platform-thread pool:
private static final int THREAD_POOL_SIZE = 20;
private static final long BLOCKING_SIMULATION_TIME = 200;
ExecutorService threadPoolExecutor = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
server.createContext("/api", exchange -> {
threadPoolExecutor.submit(() -> {
Thread.sleep(BLOCKING_SIMULATION_TIME);
String response = "Platform Thread Ok\n";
exchange.sendResponseHeaders(200, response.length());
exchange.getResponseBody().write(response.getBytes());
exchange.close();
});
});
VirtualThreadPoolServer.java keeps the same endpoint and the same 200ms simulated blocking delay, but it runs each request task on a virtual thread:
private static final long BLOCKING_SIMULATION_TIME = 200;
ExecutorService loomExecutor = Executors.newVirtualThreadPerTaskExecutor();
server.createContext("/api", exchange -> {
loomExecutor.submit(() -> {
Thread.sleep(BLOCKING_SIMULATION_TIME);
String response = "Virtual Thread Ok\n";
exchange.sendResponseHeaders(200, response.length());
exchange.getResponseBody().write(response.getBytes());
exchange.close();
});
});
This is intentionally small. There is no framework magic and no benchmark harness hidden behind the result. The endpoint sleeps for 200ms and returns a short string. The difference is the executor.
The platform-thread version has only 20 worker threads. If 40 requests arrive at the same time, about half of them can sleep immediately and the other half wait for a worker. The virtual-thread version creates a virtual thread for each submitted task, so the sleeping tasks park instead of occupying an expensive platform thread.
That is the whole lesson of Part 1.
This article is learning material. The main branch now builds with OpenJDK 25.0.2 and uses the Java 25 preview structured-concurrency API, with the Java 21 version separately managed in the feature/java-21 branch. Virtual threads themselves are final in Java 21, so a small virtual-thread-only example does not need preview flags. The commands below use --enable-preview because they run inside the current companion repository, where the whole Maven project is configured for Java 25 preview features. Part 9 covers the Java 21 to Java 25 migration.
The measurements below were generated with OpenJDK 25.0.2 and Maven 3.9.12:
mvn clean compile -DskipTests
The build succeeded and compiled 35 source files.
Then I ran the two servers one at a time because both examples bind to port 8080. For the platform-thread version:
java --enable-preview -cp target/classes app.js.PlatformThreadPoolServer
curl -s -w '\nstatus=%{http_code} total=%{time_total}s\n' http://localhost:8080/api
wrk -t4 -c40 -d10s http://localhost:8080/api
The single request returned:
Platform Thread Ok
status=200 total=0.266446s
The 40-connection load run returned:
Running 10s test @ http://localhost:8080/api
4 threads and 40 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 405.93ms 23.75ms 668.97ms 95.91%
Req/Sec 23.72 14.37 60.00 53.92%
979 requests in 10.09s, 90.83KB read
Requests/sec: 97.04
Transfer/sec: 9.00KB
For the virtual-thread version:
java --enable-preview -cp target/classes app.js.VirtualThreadPoolServer
curl -s -w '\nstatus=%{http_code} total=%{time_total}s\n' http://localhost:8080/api
wrk -t4 -c40 -d10s http://localhost:8080/api
The single request returned:
Virtual Thread Ok
status=200 total=0.255624s
The 40-connection load run returned:
Running 10s test @ http://localhost:8080/api
4 threads and 40 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 207.72ms 8.19ms 264.41ms 97.92%
Req/Sec 47.70 5.67 50.00 93.33%
1920 requests in 10.09s, 176.25KB read
Requests/sec: 190.29
Transfer/sec: 17.47KB
Here is the same result as a table:
| Server | Worker model | Simulated work | Load | Average latency | Requests/sec | Total requests |
|---|---|---|---|---|---|---|
PlatformThreadPoolServer | fixed pool of 20 platform threads | 200ms sleep | 40 connections for 10s | 405.93ms | 97.04 | 979 |
VirtualThreadPoolServer | virtual thread per submitted task | 200ms sleep | 40 connections for 10s | 207.72ms | 190.29 | 1,920 |
The platform-thread result is close to what the code predicts. A pool of 20 workers, each spending about 200ms per request, can complete roughly 100 requests per second. The measured run completed 97.04 requests per second.
The virtual-thread result is also close to the shape of the code. With 40 concurrent clients and 200ms of simulated blocking work, the run should be near 200 requests per second if the server can keep all 40 request tasks parked independently. The measured run completed 190.29 requests per second.
That does not prove that virtual threads are twice as fast in general. It proves something narrower and more useful: in this checked-in blocking example, the fixed platform-thread pool becomes the bottleneck before the simulated dependency does, while the virtual-thread version keeps the parent code closer to the dependency latency.
Before virtual threads, Java made blocking code easy to read but expensive to scale. If every request waited on a platform thread, high concurrency quickly became a thread-pool sizing problem. Developers either limited the pool and accepted queueing, or moved to asynchronous styles that kept platform threads free but spread control flow across callbacks, futures, and continuations.
Virtual threads keep the blocking style but change the cost model. A virtual thread is still a Thread. It has a stack, it can run normal Java code, it can call Thread.sleep, and it can block on many JDK I/O operations. The difference is that when it blocks in a Loom-aware operation, the JVM can park the virtual thread and free the carrier platform thread to run something else.
That is why the code above looks almost boring. There is no callback tree around the sleep. The virtual-thread version still says:
Thread.sleep(BLOCKING_SIMULATION_TIME);
For learning purposes, that boring line is the point. Virtual threads are not a new async API. They are a way to keep direct-style code while giving the JVM a cheaper unit of blocking concurrency.
The platform-thread server is not "bad Java." A fixed pool is a reasonable old-style way to avoid creating unbounded platform threads. The pool is doing exactly what it was asked to do: run at most 20 request tasks at a time.
Under a 40-connection load, each task sleeps for 200ms. The first 20 tasks enter the pool and sleep. The next 20 tasks wait in the executor queue. When the first batch wakes up, those queued tasks can finally start their own 200ms sleep. That makes the average latency drift toward two sleep intervals.
The measured average was 405.93ms, which is almost exactly that story.
The virtual-thread server does not need a fixed worker pool to protect platform-thread creation. It creates a virtual thread per submitted request task. When those tasks sleep, they park. The carrier threads are not stuck sleeping for them. Under the same 40-connection run, the average latency stayed at 207.72ms, much closer to the 200ms simulated dependency.
This is the part that changes how server code feels. The question is no longer "how large should my platform-thread pool be so blocking does not crush the JVM?" The better first question is "how much concurrent work should this application allow before it overloads the downstream thing it is calling?"
Virtual threads help with the first problem. They do not solve the second one.
Virtual threads do not remove queueing from a system. They move the conversation to the real constrained resource.
If the database connection pool has 20 connections, starting 2,000 virtual threads that all want a connection still leaves 1,980 tasks waiting somewhere. If a partner API allows 100 requests per second, virtual threads do not raise that limit. If the workload is CPU-bound, creating more threads does not create more cores.
This is why the checked-in benchmark matters. The example isolates one behavior: many request tasks sleeping for 200ms. It is a good fit for virtual threads because sleeping is representative of blocking I/O. It is not evidence that every endpoint in every service will get the same improvement.
CPU-heavy work should still be sized around available cores. Downstream calls still need timeouts, rate limits, bulkheads, and backpressure. Virtual threads make it cheaper to wait, but they do not decide whether the wait should be allowed.
Virtual threads use an M:N scheduling model: many virtual threads are scheduled over a smaller set of carrier platform threads.
In text, the diagram is showing many virtual threads being scheduled by the JVM over a smaller set of carrier platform threads.
The useful mental model is not "virtual threads are free." They are not. They still use heap, stack chunks, scheduler bookkeeping, and any objects your task holds while it waits.
The useful model is "blocking no longer has to monopolize an operating-system thread." When a virtual thread parks, the carrier can run another virtual thread. When the blocked operation is ready again, the virtual thread becomes runnable and continues from the same line of code.
That is why virtual threads pair well with request-per-task server code and blocking I/O APIs. The code stays local. The stack trace still looks like the work. The JVM gets a more scalable unit to park and resume.
The repository also contains LoomThreadTest.java and VirtualThreadFlood.java. They are useful for exploring the shape of thread creation, but I did not use them as evidence for this article.
LoomThreadTest sets n = 10_000_00, which is one million, and tries to create that many platform threads before it tries virtual threads. VirtualThreadFlood tries 10,000 platform threads and then one million virtual threads. Those are stress experiments. They can be useful on a machine you are intentionally testing, but they are not a good default benchmark for a teaching article.
There is another subtle issue: both demos keep every Thread object in a List so they can join later. That is fine for a demonstration, but it also means the measurement includes the cost of retaining a large number of Java objects. A realistic server usually submits tasks to an executor and lets completed tasks be collected.
For Part 1, the HTTP benchmark is the cleaner teaching tool. It has one endpoint, one simulated blocking delay, two executor models, and a result that lines up with the code.
The test needs to make blocking visible. A single request proves only that the endpoint works. It does not show whether the executor model queues work under pressure. Use a load that exceeds the fixed platform pool size, keep the simulated dependency delay stable, and compare the observed latency with what the code predicts. In this article, 40 connections against a 20-thread pool made the queue visible without needing an aggressive benchmark.
The test also needs to avoid pretending that the executor is the whole system. If the endpoint talks to a database, the database pool and query latency belong in the measurement. If it calls an HTTP service, the remote service's rate limits belong in the measurement. Virtual-thread tests should report both parent request throughput and the downstream pressure created by those requests.
For local learning, start with the tiny port-8080 servers. Once the executor behavior is clear, move to the broader microservice examples in later articles.
Virtual threads make blocking server code easier to scale when the work is mostly waiting. The Part 1 benchmark showed that with the smallest possible HTTP example: one fixed platform-thread pool, one virtual-thread-per-task executor, the same 200ms blocking operation, and a load that exposed queueing.
Part 2 moves from the tiny /api endpoint to a broader HTTP service with separate CPU, blocking, and file-reading routes. That is where the next distinction matters: virtual threads help most when the endpoint is waiting, not when it is burning CPU.