Skip to content
← All posts
· 3 min read Java

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.

Java 21 Virtual Threads: A Simple Path to High Concurrency

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 --> OS

How to create one

The simplest way to start a virtual thread:

Simple 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.

One 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 finish

Those 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:

  1. Make sure you’re on Java 21+ (java -version).

  2. Add this line to application.properties:

    application.properties
    spring.threads.virtual.enabled=true
  3. Start 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.

Avoid: synchronized + I/O
synchronized (lock) {
// ❌ Long I/O under a lock → pinning
var data = database.query(sql);
cache.put(key, data);
}
Prefer: ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
// ✓ ReentrantLock does not cause pinning
lock.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; use ReentrantLock.
  • Always measure.

For more, browse the #java and #concurrency tags, or the curated Java page.

Share

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.