Java 21 Virtual Threads: A Simple Path to High Concurrency
What are Java 21 virtual threads (Project Loom), how do they differ from platform threads, and how do they make thread-per-request cheap again? With code, pitfalls and measurement tips.

Virtual threads became stable in Java 21 after years of development under the name Project Loom. In one sentence: you can now run millions of concurrent tasks with the simplicity of the classic thread-per-request model, but without the cost of platform threads.
This post explains what virtual threads are, why they matter, and how to use them in real code — plus the pitfalls to know before you fall into them. For more Java content, see my Java page.
The problem: platform threads are expensive
A classic Java thread (a platform thread) maps one-to-one to an operating-system thread. Each reserves ~1 MB of stack and is scheduled by the OS. We’d love “one thread per request” because the code stays simple and readable — but after a few thousand threads, memory and context-switching costs stop you.
That’s why for years we reached for reactive and asynchronous APIs: CompletableFuture, callback chains, reactive streams… Performant, but hard to read and debug.
What is a virtual thread?
A virtual thread is a lightweight thread scheduled by the JVM. It is not permanently bound to an OS thread; it only mounts onto a carrier platform thread while it is actually doing work (using the CPU). When it blocks on an I/O call (network, file, database), the JVM unmounts it from the carrier and hands the carrier to another virtual thread.
The result: tens of thousands of virtual threads run on a handful of OS threads.
flowchart TD
subgraph JVM
V1[Virtual thread 1] --> C1[Carrier thread]
V2[Virtual thread 2] --> C1
V3[Virtual thread 3] --> C2[Carrier thread]
V4[Virtual thread N] -.waiting on I/O.-> P[(Parked)]
end
C1 --> OS[(OS thread pool)]
C2 --> OSHow to create one
The simplest way to start a virtual thread:
Thread.startVirtualThread(() -> { System.out.println("Hello, virtual world!");});In practice you manage tasks with an ExecutorService. The key is to use the executor that starts a new virtual thread per task.
import java.util.concurrent.Executors;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 10_000; i++) { int taskId = i; executor.submit(() -> { // I/O-bound work — e.g. an HTTP call var result = callRemoteService(taskId); process(result); }); }} // executor.close() waits for all tasks to finishThose 10,000 tasks run without opening 10,000 OS threads, and the code stays fully synchronous and readable.
Step by step: enabling it in Spring Boot
With Spring Boot 3.2+ you can enable virtual threads with a single setting:
Make sure you’re on Java 21+ (
java -version).Add this line to
application.properties:application.properties spring.threads.virtual.enabled=trueStart the app. Tomcat now handles each HTTP request on a virtual thread — with no changes to your code.
When to use it, when to avoid it
Pros
- + I/O-bound, highly concurrent workloads (web servers, API gateways)
- + Synchronous, readable code — no async complexity
- + Works with existing blocking APIs (JDBC, HttpClient)
- + Very cheap: tens of thousands of threads are fine
Cons
- − No benefit for CPU-bound work (a classic pool is better)
- − Long synchronized blocks can cause pinning
- − Overusing ThreadLocal creates memory pressure
- − Profiling/tooling is still maturing
Watch out: the “pinning” trap
If a virtual thread blocks on I/O while inside a synchronized block, it gets pinned to its carrier thread and can’t release it. Lots of pinning can erase the entire benefit of virtual threads.
synchronized (lock) { // ❌ Long I/O under a lock → pinning var data = database.query(sql); cache.put(key, data);}private final ReentrantLock lock = new ReentrantLock();
// ✓ ReentrantLock does not cause pinninglock.lock();try { var data = database.query(sql); cache.put(key, data);} finally { lock.unlock();}Conclusion
Virtual threads make concurrency in Java simple again: write synchronous code, yet scale high. The key rules:
- Start a new virtual thread per task; don’t pool.
- Use them for I/O-bound work; stay on classic pools for CPU-bound work.
- Avoid
synchronized+ long I/O; useReentrantLock. - Always measure.
For more, browse the #java and #concurrency tags, or the curated Java page.
Frequently asked questions
Do virtual threads fully replace platform threads?
No. Virtual threads shine for I/O-bound workloads with many waiting tasks. For CPU-bound work, classic platform threads and pools are still the right choice.
Should I create a thread pool for virtual threads?
No. Virtual threads are cheap; pooling them is an anti-pattern. Start a new virtual thread per task (Executors.newVirtualThreadPerTaskExecutor).
Do synchronized blocks cause problems?
In Java 21, synchronized blocks that perform long I/O can cause "pinning" and block the carrier thread. Prefer ReentrantLock on hot paths; later releases reduce pinning.
Which Java version do I need?
Virtual threads became stable (LTS) in Java 21. They were available as a preview in Java 19/20.