Java Virtual Threads (Java 21+)
Virtual Threads (JEP 444, finalized in Java 21) are lightweight threads managed by the JVM rather than the OS. They enable writing simple blocking code that scales to millions of concurrent tasks: no reactive frameworks or callback hell needed. A virtual thread costs about 1 KB of memory versus 1 MB for a platform thread, and creating one is nearly as cheap as creating an object.
Contents
What Are Virtual Threads?
Virtual threads are lightweight threads introduced by Project Loom and finalized as JEP 444
Before virtual threads, scaling a thread-per-request server meant either limiting concurrency to a few thousand OS threads or rewriting everything with reactive/async APIs. Virtual threads eliminate that tradeoff: you write straightforward blocking code, and the JVM handles the scheduling transparently.
/*
The concurrency landscape before and after virtual threads:
Before (Java < 21):
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Option A: Thread-per-request with platform threads
â Simple code, but limited to ~2,000â5,000 concurrent threads
â Each thread consumes ~1 MB of stack memory
Option B: Reactive / async (CompletableFuture, RxJava, WebFlux)
â Scales well, but code becomes callback spaghetti
â Stack traces are useless, debugging is painful
After (Java 21+):
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Virtual threads: Thread-per-request that actually scales
â Simple blocking code (looks like Option A)
â Millions of concurrent threads (scales like Option B)
â Each virtual thread costs ~1 KB of memory
â Full stack traces, easy debugging
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
*/
Creating Virtual Threads
Java 21 provides several ways to create and run virtual threads. The simplest is Thread.startVirtualThread()Thread.ofVirtual()Executors.newVirtualThreadPerTaskExecutor()
// 1. Quick one-liner â create and start immediately
Thread vt = Thread.startVirtualThread(() -> {
System.out.println("Hello from virtual thread: " + Thread.currentThread());
});
vt.join();
// 2. Builder API â name, uncaught exception handler, etc.
Thread named = Thread.ofVirtual()
.name("worker-", 0) // names: worker-0, worker-1, ...
.uncaughtExceptionHandler((t, e) ->
System.err.println(t.getName() + " failed: " + e.getMessage()))
.start(() -> {
System.out.println("Running on: " + Thread.currentThread().getName());
});
named.join();
// 3. Unstarted thread â useful when you need the Thread reference first
Thread unstarted = Thread.ofVirtual()
.name("lazy-worker")
.unstarted(() -> doWork());
unstarted.start(); // start later
unstarted.join();
For production server code, the executor-based approach is usually the best fit. It integrates with existing code that expects an ExecutorService
// 4. Virtual-thread-per-task executor â the production workhorse
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// Submit 100,000 tasks â each gets its own virtual thread
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
final int taskId = i;
futures.add(executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // simulates I/O
return "Result-" + taskId;
}));
}
// Collect results
for (Future<String> f : futures) {
f.get(); // blocks, but that's fine â we're on a virtual thread too
}
}
System.out.println("All 100,000 tasks completed");
Thread.isVirtual()trueVirtual vs Platform Threads
Understanding the differences between virtual and platform threads helps you decide when and where to use each. Platform threads are still the right choice for long-running CPU-bound work, while virtual threads shine for I/O-bound tasks with high concurrency.
/*
Virtual Threads vs Platform Threads â Side by Side
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Property Platform Thread Virtual Thread
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Managed by OS kernel JVM runtime
Stack size ~1 MB (default) ~1 KB (grows as needed)
Creation cost Expensive (~1 ms) Cheap (~1 ÎŒs)
Max practical count ~5,000 per JVM Millions per JVM
Scheduling OS preemptive JVM cooperative (yield at blocking)
Blocking I/O Blocks OS thread Releases carrier thread
CPU-bound work Good (OS time-slicing) Avoid (starves carrier pool)
Thread pooling Essential Unnecessary (create new ones)
ThreadLocal Works (but leaky) Works (prefer ScopedValue)
synchronized Works (but may pin) Works (but may pin â use locks)
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Rule of thumb:
â I/O-bound tasks (HTTP, DB, file I/O) â Virtual threads
â CPU-bound tasks (crypto, compression) â Platform threads
â Mixed workload â Virtual threads for I/O,
platform threads for CPU
*/
// Quick benchmark: creating 100,000 threads
// Platform threads â takes seconds and may crash with OutOfMemoryError
// Virtual threads â completes in milliseconds
long start = System.nanoTime();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
threads.add(Thread.startVirtualThread(() -> {
try { Thread.sleep(Duration.ofSeconds(1)); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}));
}
for (Thread t : threads) { t.join(); }
long elapsed = Duration.ofNanos(System.nanoTime() - start).toMillis();
System.out.println("100K virtual threads completed in " + elapsed + " ms");
// Typical output: 100K virtual threads completed in ~1050 ms
How Virtual Threads Work
Virtual threads use a continuation-based scheduling model. When a virtual thread performs a blocking operation (I/O, sleep, lock acquisition), the JVM unmounts it from its carrier thread, saving its stack as a heap-allocated continuation. The carrier thread is then free to run another virtual thread. When the blocking operation completes, the JVM mounts the virtual thread back onto an available carrier.
The carrier thread pool is a ForkJoinPool-Djdk.virtualThreadScheduler.parallelism=N
/*
How virtual threads are scheduled on carrier threads:
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Carrier threads (OS threads managed by ForkJoinPool):
ââââââââââââ ââââââââââââ ââââââââââââ ââââââââââââ
â Carrier-0â â Carrier-1â â Carrier-2â â Carrier-3â
ââââââââââââ ââââââââââââ ââââââââââââ ââââââââââââ
Virtual threads (millions possible):
VT-1: [run]â[blocked on I/O â unmount]â[I/O done â remount]â[run]â[done]
VT-2: [run]â[sleep â unmount]â[wake â remount]â[run]â[done]
VT-3: [run]â[blocked on lock â unmount]â[lock acquired â remount]â[done]
...
VT-N: [waiting to mount]â[run]â[done]
Timeline on Carrier-0:
âââââââ âââââââ âââââââ âââââââ âââââââ
â VT-1â â VT-5â âVT-12â â VT-1â âVT-99â â different VTs over time
âââââââ âââââââ âââââââ âââââââ âââââââ
Key: When VT-1 blocks, it unmounts. Carrier-0 picks up VT-5.
When VT-1's I/O completes, it remounts on any available carrier.
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
*/
// Observing mounting and unmounting in practice
Thread.startVirtualThread(() -> {
// Carrier info is visible via thread toString()
System.out.println("Before sleep: " + Thread.currentThread());
// Output: VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
Thread.sleep(Duration.ofMillis(100)); // unmounts from carrier
System.out.println("After sleep: " + Thread.currentThread());
// Output: VirtualThread[#21]/runnable@ForkJoinPool-1-worker-3
// Notice: may be on a DIFFERENT carrier after remounting!
}).join();
Blocking Without Guilt
The entire point of virtual threads is that blocking operations become cheap. When a virtual thread calls Thread.sleep()Socket
// A simple HTTP server handling 100K concurrent connections
// using virtual threads â each connection gets its own thread
import java.net.ServerSocket;
import java.net.Socket;
import java.io.*;
import java.util.concurrent.Executors;
public class VirtualThreadHttpServer {
public static void main(String[] args) throws Exception {
try (var serverSocket = new ServerSocket(8080);
var executor = Executors.newVirtualThreadPerTaskExecutor()) {
System.out.println("Server listening on port 8080");
while (true) {
Socket client = serverSocket.accept();
executor.submit(() -> handleRequest(client));
}
}
}
static void handleRequest(Socket client) {
try (client;
var reader = new BufferedReader(new InputStreamReader(client.getInputStream()));
var writer = new PrintWriter(client.getOutputStream(), true)) {
// Read the HTTP request line â blocking call, but cheap on a virtual thread
String requestLine = reader.readLine();
// Simulate a slow database query (500ms)
Thread.sleep(Duration.ofMillis(500));
// Simulate calling an external API (200ms)
Thread.sleep(Duration.ofMillis(200));
// Write response
String body = "{\"status\":\"ok\",\"thread\":\"" + Thread.currentThread() + "\"}";
writer.println("HTTP/1.1 200 OK");
writer.println("Content-Type: application/json");
writer.println("Content-Length: " + body.length());
writer.println();
writer.print(body);
} catch (Exception e) {
System.err.println("Error handling request: " + e.getMessage());
}
}
}
The same pattern applies to database access. Blocking JDBC calls work naturally with virtual threads: each query runs on its own virtual thread, and the carrier threads are freed during I/O waits.
// Database access with virtual threads â no reactive driver needed
public class UserRepository {
private final DataSource dataSource;
public UserRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
public User findById(long id) {
// All blocking calls here â getConnection(), executeQuery(), ResultSet.next()
// Virtual thread unmounts during each blocking I/O operation
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(
"SELECT id, name, email FROM users WHERE id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return new User(rs.getLong("id"),
rs.getString("name"),
rs.getString("email"));
}
}
} catch (SQLException e) {
throw new RuntimeException("Failed to fetch user " + id, e);
}
return null;
}
}
// Fetch 10,000 users concurrently â each on its own virtual thread
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<User>> futures = LongStream.rangeClosed(1, 10_000)
.mapToObj(id -> executor.submit(() -> repo.findById(id)))
.toList();
List<User> users = futures.stream()
.map(f -> { try { return f.get(); } catch (Exception e) { return null; } })
.filter(Objects::nonNull)
.toList();
System.out.println("Fetched " + users.size() + " users concurrently");
}
Pinning: When Virtual Threads Get Stuck
Pinning occurs when a virtual thread cannot unmount from its carrier thread during a blocking operation. This defeats the purpose of virtual threads because the carrier is held hostage. The most common cause is blocking inside a synchronized
// BAD â synchronized pins the virtual thread to its carrier
public class PinningExample {
private final Object lock = new Object();
public void doWork() {
synchronized (lock) {
// Virtual thread is PINNED here â carrier thread is blocked
Thread.sleep(Duration.ofSeconds(1)); // carrier cannot be reused!
callExternalApi(); // still pinned
}
}
}
// GOOD â ReentrantLock allows unmounting during blocking operations
public class NoPinningExample {
private final ReentrantLock lock = new ReentrantLock();
public void doWork() {
lock.lock();
try {
// Virtual thread can unmount here â carrier thread is freed
Thread.sleep(Duration.ofSeconds(1)); // carrier is reused!
callExternalApi(); // not pinned
} finally {
lock.unlock();
}
}
}
To detect pinning in your application, use the JVM diagnostic flag -Djdk.tracePinnedThreads=fullsynchronizedReentrantLock
/*
Detecting pinned threads:
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
JVM flag: -Djdk.tracePinnedThreads=full
Output when pinning occurs:
Thread[#25,ForkJoinPool-1-worker-1,5,CarrierThreads]
com.example.PinningExample.doWork(PinningExample.java:8) <== monitors:1
java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:187)
Options:
-Djdk.tracePinnedThreads=full â full stack trace
-Djdk.tracePinnedThreads=short â one-line summary
Common sources of pinning:
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
1. Your own synchronized blocks â Replace with ReentrantLock
2. Third-party libraries â Check for updated versions
3. JDK internals (rare) â Usually fixed in newer JDK releases
4. Native methods (JNI) â Always pin, no workaround
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
*/
synchronizedStructured Concurrency (Preview)
Structured concurrency (JEP 462StructuredTaskScope
import java.util.concurrent.StructuredTaskScope;
// ShutdownOnFailure â cancel everything if any subtask fails
public record OrderDetails(Order order, User user, Inventory inventory) {}
public OrderDetails fetchOrderDetails(long orderId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Each fork() runs on its own virtual thread
Subtask<Order> orderTask = scope.fork(() -> orderService.findById(orderId));
Subtask<User> userTask = scope.fork(() -> userService.findByOrderId(orderId));
Subtask<Inventory> inventoryTask = scope.fork(() -> inventoryService.check(orderId));
// Wait for all subtasks to complete (or one to fail)
scope.join();
// Propagate the first exception if any subtask failed
scope.throwIfFailed();
// All succeeded â combine results
return new OrderDetails(
orderTask.get(),
userTask.get(),
inventoryTask.get()
);
}
// scope.close() cancels any still-running subtasks
}
ShutdownOnSuccess
// ShutdownOnSuccess â return the first successful result
public User findUserFast(long userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<User>()) {
// Race: cache vs database â whoever answers first wins
scope.fork(() -> cache.getUser(userId)); // might be instant
scope.fork(() -> database.findUser(userId)); // slower but reliable
scope.join();
// Returns the first successful result; the other is cancelled
return scope.result();
}
}
tryclose()Migration Guide
Migrating from thread pools to virtual threads is usually straightforward. The key insight is that virtual threads are cheap and disposable: you do not pool them. Here is a step-by-step guide for common migration scenarios.
// BEFORE: Fixed thread pool â limits concurrency to 200 threads
ExecutorService executor = Executors.newFixedThreadPool(200);
// AFTER: Virtual thread executor â unlimited concurrency
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// The rest of your code stays the same!
executor.submit(() -> handleRequest(request));
While the executor swap is simple, there are several things to watch out for during migration. The following checklist covers the most common pitfalls.
/*
Migration Checklist â Thread Pools to Virtual Threads
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â
DO:
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
⢠Replace Executors.newFixedThreadPool(N) with
Executors.newVirtualThreadPerTaskExecutor()
⢠Replace synchronized blocks (that contain I/O) with ReentrantLock
⢠Use try-with-resources for the executor:
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) { ... }
⢠Keep blocking I/O code as-is â it works great on virtual threads
⢠Use ScopedValue instead of ThreadLocal for request-scoped data
⢠Use semaphores to limit concurrent access to scarce resources:
Semaphore dbPool = new Semaphore(50); // limit DB connections
â DON'T:
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
⢠Don't pool virtual threads â create a new one for each task
⢠Don't use ThreadLocal for object pooling (e.g., SimpleDateFormat)
â millions of virtual threads means millions of pooled objects
⢠Don't run CPU-intensive work on virtual threads â use platform
threads for crypto, compression, or heavy computation
⢠Don't assume thread identity â a virtual thread may run on
different carrier threads over its lifetime
⢠Don't use Thread.yield() â it has no useful effect on virtual threads
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
*/
When your application needs to limit concurrency (e.g., to avoid overwhelming a database connection pool), use a Semaphore
// Limiting concurrency to scarce resources with a Semaphore
public class RateLimitedService {
// Only allow 50 concurrent database connections
private final Semaphore dbPermits = new Semaphore(50);
private final DataSource dataSource;
public RateLimitedService(DataSource dataSource) {
this.dataSource = dataSource;
}
public String queryDatabase(String sql) throws Exception {
dbPermits.acquire(); // virtual thread unmounts while waiting
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
return rs.next() ? rs.getString(1) : null;
} finally {
dbPermits.release();
}
}
}
// Spring Boot â configure virtual threads in application.properties:
// spring.threads.virtual.enabled=true
//
// That single property switches Tomcat to use virtual threads
// for all request handling â no other code changes needed.
spring.threads.virtual.enabled=trueapplication.properties