Contents
- Platform Threads vs Virtual Threads
- Creating Virtual Threads
- Virtual Thread Executor
- Pinning and Blocking Pitfalls
- When to Use Virtual Threads
A platform thread is a thin wrapper around an OS thread. Creating tens of thousands of them is expensive — each consumes ~1 MB of stack by default. A virtual thread is managed by the JVM and mounted onto a carrier (platform) thread only while it is actually executing CPU work. When it blocks on I/O, it unmounts, freeing the carrier thread for other tasks.
- Platform thread — 1:1 mapping with OS thread, ~1 MB stack, limited to tens of thousands.
- Virtual thread — M:N mapping onto a small pool of carrier threads, ~few KB stack, supports millions.
- Virtual threads use the same Thread API — no new programming model to learn.
- Virtual threads work best for I/O-bound workloads (HTTP, JDBC, files).
// Platform thread — limited, expensive
Thread platform = new Thread(() -> System.out.println("Platform thread"));
platform.start();
// Virtual thread — lightweight, JVM-managed
Thread vt = Thread.ofVirtual().start(() -> System.out.println("Virtual thread"));
vt.join();
System.out.println(vt.isVirtual()); // true
// 1. Thread.ofVirtual().start(Runnable) — fire and forget
Thread t1 = Thread.ofVirtual().start(() -> {
System.out.println("Running in: " + Thread.currentThread());
});
t1.join();
// 2. Thread.ofVirtual().name("my-thread").start(Runnable) — named
Thread t2 = Thread.ofVirtual()
.name("worker-", 0) // creates worker-0, worker-1, etc.
.start(() -> doWork());
// 3. Thread.startVirtualThread(Runnable) — shortcut
Thread t3 = Thread.startVirtualThread(() -> processRequest());
// 4. Thread.ofVirtual().unstarted(Runnable) — create without starting
Thread t4 = Thread.ofVirtual().unstarted(() -> compute());
t4.start();
t4.join();
// Virtual threads are always daemon threads — they don't prevent JVM exit
System.out.println(t1.isDaemon()); // true
The most common way to use virtual threads is through Executors.newVirtualThreadPerTaskExecutor() — it creates a new virtual thread for every submitted task, exactly as if you called Thread.startVirtualThread() for each.
// Simulate 10,000 concurrent "HTTP requests"
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
final int id = i;
futures.add(exec.submit(() -> fetchUser(id)));
}
for (Future<String> f : futures) {
System.out.println(f.get());
}
}
// The executor is AutoCloseable — awaits all tasks on close()
static String fetchUser(int id) throws InterruptedException {
// Simulates a blocking I/O call (e.g., HTTP or JDBC)
Thread.sleep(100);
return "User-" + id;
}
// With platform threads + fixed pool, 10,000 tasks would queue.
// With virtual threads, all 10,000 run "concurrently" in ~100ms total.
Drop-in replacement for Spring Boot: set spring.threads.virtual.enabled=true in application.properties to enable virtual threads for all request handling in Spring Boot 3.2+.
A virtual thread is pinned to its carrier thread when it holds a monitor lock (synchronized block/method) while blocking on I/O. During pinning, the carrier thread is blocked too — reducing throughput.
// PROBLEM — synchronized holds the carrier thread while sleeping/blocking
class PinnedExample {
synchronized void blocksCarrier() throws InterruptedException {
Thread.sleep(1000); // virtual thread is PINNED — carrier can't be reused
}
}
// SOLUTION — replace synchronized with ReentrantLock, which is virtual-thread-aware
class BetterExample {
private final ReentrantLock lock = new ReentrantLock();
void doesNotPin() throws InterruptedException {
lock.lock();
try {
Thread.sleep(1000); // virtual thread unmounts while sleeping — carrier is free
} finally {
lock.unlock();
}
}
}
// Detect pinning with JVM flags:
// -Djdk.tracePinnedThreads=full
// This prints a stack trace whenever a virtual thread is pinned
// ThreadLocal is fine with virtual threads — but be careful:
// A virtual-thread-per-task executor creates a new thread per task,
// so ThreadLocal values don't persist across tasks (unlike thread pools).
Avoid synchronized in code that will run on virtual threads when the synchronized block contains I/O or Thread.sleep(). Use ReentrantLock instead. Many popular libraries (JDBC drivers, etc.) are being updated to address this.
- Use for I/O-bound workloads — HTTP servers, database queries, file processing. Virtual threads shine here.
- Do not use for CPU-bound tasks — creating millions of virtual threads for pure computation won't help; you'll still be limited to available CPU cores. Use a fixed platform-thread pool for CPU work.
- Replace thread-per-request patterns — if you use newCachedThreadPool() or newFixedThreadPool(N) for I/O, swap to newVirtualThreadPerTaskExecutor().
- Semaphores for back-pressure — virtual threads are cheap, but databases still have connection limits. Use a Semaphore to limit simultaneous DB calls.
// Semaphore for back-pressure — limit DB concurrency to 20
Semaphore dbSemaphore = new Semaphore(20);
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (String userId : userIds) {
exec.submit(() -> {
dbSemaphore.acquire();
try {
return db.findUser(userId); // only 20 at a time
} finally {
dbSemaphore.release();
}
});
}
}