When Should You Use Virtual Threads?
Virtual Threads (VT) are not a replacement for regular threads, but a specialized tool for specific tasks. They provide benefits when the application spends most of its time wai...
Junior Level
Basic Understanding
Virtual Threads (VT) are not a replacement for regular threads, but a specialized tool for specific tasks. They provide benefits when the application spends most of its time waiting (I/O): DB responses, microservices, reading from S3, HTTP requests.
Why VT help specifically with I/O: when a regular thread calls a blocking operation (e.g., socket.read()), it sleeps and occupies 1MB of OS memory. A virtual thread on the same operation “unmounts” — the JVM saves its stack to the heap (several KB) and gives the Carrier Thread to another virtual thread. When I/O completes, the VT “mounts” back and continues.
Ideal Scenarios for VT
1. I/O-bound Loads (Waiting)
If the application spends 90% of time waiting (DB response, microservice, reading from S3):
// VT — the best choice
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request request : requests) {
executor.submit(() -> {
String data = httpClient.get(url); // Wait for network
Result result = db.query(data); // Wait for DB
s3.upload(result); // Wait for S3
return result;
});
}
}
// While one VT waits — Carrier Thread processes other VTs
// Carrier Thread — this is the real OS thread on which tens of thousands of VTs "sit"
2. Thread-per-Request Model
// Simple and clear code instead of reactive
public void handleRequest(HttpServletRequest req, HttpServletResponse resp) {
String data = database.query(req.getParameter("id")); // Blocking call
String processed = externalApi.call(data); // Blocking call
resp.getWriter().write(processed); // Blocking call
}
// With VT: a million such requests are handled in parallel!
When You MUST NOT Use VT
1. CPU-bound Tasks (Computations)
// BAD: VT for computations
Thread.ofVirtual().start(() -> {
calculatePi(1_000_000); // No blocking — VT won't unmount
});
// JVM scheduler overhead with no benefit
// GOOD: FixedThreadPool
ExecutorService cpuPool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() + 1
);
cpuPool.submit(() -> calculatePi(1_000_000));
2. Resource Limiting (Throttling)
// BAD: VT without limiting to DB
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
database.execute("UPDATE ..."); // A million DB queries at once!
});
}
}
// DB will crash!
// GOOD: Semaphore for limiting
Semaphore dbSemaphore = new Semaphore(20); // Max 20 DB connections
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
dbSemaphore.acquire();
try {
database.execute("UPDATE ...");
} finally {
dbSemaphore.release();
}
});
}
}
Middle Level
Spring Boot 3.2+ with VT
# application.yml
spring:
threads:
virtual:
enabled: true
// All of Tomcat/Undertow switches to VT automatically
@RestController
public class MyController {
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
// Each request — a separate VT
return userService.findById(id); // Blocking call — OK!
}
}
Pinning Problem
A virtual thread “sticks” to a Carrier Thread and doesn’t unmount:
| Cause | Impact | Solution |
|---|---|---|
synchronized |
Carrier Thread blocked | ReentrantLock |
| Native methods (JNI) | Carrier Thread blocked | Separate thread |
// BAD: synchronized in VT
public synchronized Data loadData() {
return httpClient.get(url); // VT "stuck" — Carrier Thread busy
}
// GOOD: ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public Data loadData() {
lock.lock();
try {
return httpClient.get(url); // VT unmounts — Carrier is free
} finally {
lock.unlock();
}
}
ThreadLocal Problem
// Million VT = million ThreadLocal copies = Memory Problem!
ThreadLocal<Context> context = ThreadLocal.withInitial(Context::new);
// Solution: Scoped Values (Java 21 Preview)
static final ScopedValue<Context> CONTEXT = new ScopedValue<>();
ScopedValue.runWhere(CONTEXT, new Context(), () -> {
process(); // CONTEXT.get() is available
});
Dependency Audit
Before switching to VT:
# Check libraries for synchronized blocks
java -Djdk.tracePinnedThreads=full -jar app.jar
# Look in logs for:
# "VirtualThread pinning" — points to problem areas
Typical problematic libraries:
- Old JDBC drivers (use
synchronized) java.text.SimpleDateFormat(insidesynchronized)- Some old HTTP clients
Senior Level
Under the Hood: Why Pinning Happens
synchronized Does Not Support Unmount
VT enters synchronized:
1. Acquires monitor (as usual)
2. Blocking operation inside
3. JVM CANNOT unmount — must hold the monitor
4. VT "sticks" to Carrier Thread
5. Carrier Thread busy → other VTs wait
Result: commonPool clogged → all parallel streams slow down
Native Methods
VT calls native method:
1. Transition to native code (JNI)
2. JVM doesn't know when native code finishes
3. Cannot unmount — stack is in native code
4. VT "sticks" until native returns
Throttling via Semaphore
public class ThrottledExecutor {
private final Semaphore semaphore;
private final ExecutorService virtualExecutor;
public ThrottledExecutor(int maxConcurrent) {
this.semaphore = new Semaphore(maxConcurrent);
this.virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
}
public Future<?> submit(Runnable task) {
return virtualExecutor.submit(() -> {
semaphore.acquire();
try {
task.run();
} finally {
semaphore.release();
}
});
}
}
// Usage:
ThrottledExecutor dbExecutor = new ThrottledExecutor(20); // Max 20 to DB
ThrottledExecutor apiExecutor = new ThrottledExecutor(100); // Max 100 to API
Performance and Benchmarks
Scenario: 10,000 HTTP requests (latency 100ms each)
Platform Threads (100 pool):
- Time: ~10 seconds
- Threads: 100
- Memory: ~100MB
Virtual Threads:
- Time: ~1 second
- Threads: 10,000
- Memory: ~50MB
- Carrier Threads: 8
Diagnostics
-Djdk.tracePinnedThreads=full
# Prints full stack trace on every pinning
java -Djdk.tracePinnedThreads=full MyApp
# Output:
# VirtualThread pinning detected at:
# at java.net.SocketInputStream.socketRead0(Native Method)
# at com.example.MyClass.synchronizedMethod(MyClass.java:42)
JMX Monitoring
// Metric: number of pinned threads
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
ObjectName name = new ObjectName("jdk:type=VirtualThread");
Long pinned = (Long) mbs.getAttribute(name, "VirtualThreadPinned");
if (pinned > 0) {
log.warn("Pinned virtual threads detected: {}", pinned);
}
Thread Dumps
# jstack is not efficient for a million threads
jstack <pid> # Can take minutes
# Use instead:
jcmd <pid> Thread.dump_to_file -format=json threads.json
# JSON format — faster and more efficient
When VT Provide Maximum Benefit
| Scenario | Benefit |
|---|---|
| API Gateway (proxying) | 10x-100x throughput |
| Web Scraper (many HTTP requests) | 5x-50x throughput |
| Microservice with DB + HTTP calls | 3x-10x throughput |
| File processor (I/O) | 5x-20x throughput |
When VT Are Useless or Harmful
| Scenario | Problem | Solution |
|---|---|---|
| Computations (ML, crypto) | No blocking | FixedThreadPool |
| synchronized-heavy legacy | Pinning | ReentrantLock |
| Limited resources (DB) | Need throttling | Semaphore |
| ThreadLocal-heavy | Memory | Scoped Values |
Best Practices
- VT for I/O-bound — API gateway, web server, proxy
- ReentrantLock instead of synchronized — avoid pinning
- Semaphore for limiting — instead of pool size limits
- Scoped Values instead of ThreadLocal — for context
- Dependency audit — check libraries for synchronized
- -Djdk.tracePinnedThreads=full — during development
- Update JDBC/HTTP drivers — VT-compatible versions
- Not for CPU-bound — use FixedThreadPool for computation
- Monitor VirtualThreadPinned — via JMX/JFR
- Spring Boot 3.2+ —
spring.threads.virtual.enabled=true
Interview Cheat Sheet
Must know:
- VT ideal for I/O-bound: web servers, API gateway, proxy, microservices with DB + HTTP calls
- VT NOT suitable for CPU-bound (computations, ML, cryptography) — use FixedThreadPool(N+1)
- When working with limited resources (DB pool of 20 connections) — VT + Semaphore
- Pinning: synchronized and JNI don’t allow VT to unmount; replace synchronized with ReentrantLock
- ThreadLocal + million VT = Memory Problem; alternative — Scoped Values (Java 21 Preview)
- Dependency audit is mandatory: old JDBC drivers, SimpleDateFormat use synchronized
- Benefits: API Gateway 10-100x throughput, Web Scraper 5-50x, microservice 3-10x
Frequent follow-up questions:
- How to limit simultaneous DB queries when using VT? — Semaphore with a limit, wrapped in VT executor
- Why does Spring Boot 3.2+ simplify VT migration? —
spring.threads.virtual.enabled=trueswitches all of Tomcat to VT automatically, without changing controller code - How to check if a library is VT-compatible? — Run with
-Djdk.tracePinnedThreads=fulland check logs for “VirtualThread pinning” - What to do if a dependency uses synchronized internally? — Wrap the call in a separate Platform Thread or replace the library
Red flags (DO NOT say):
- “VT are always faster — should be used everywhere” — VT are slower for CPU-bound and add overhead at low contention
- “A million VT to DB without limits — is fine” — DB will crash, need Semaphore
- “VT migration is free” — requires auditing dependencies for synchronized, updating JDBC/HTTP drivers
- “VT solve the ThreadLocal problem” — VT make the problem worse: million VT = million ThreadLocal copies
Related topics:
- [[23. What are Virtual Threads in Java 21]]
- [[24. What are the advantages of Virtual Threads over regular threads]]
- [[26. What is structured concurrency]]
- [[20. How to prevent deadlock]]