Virtual Threads
Lightweight threads from Project Loom. Java 21+.
At a Glance
- What — Virtual threads are lightweight, JVM-managed threads that are scheduled onto a small pool of platform (OS) threads. You can create millions of them without running out of OS resources.
- Why — Traditional thread-per-request models hit OS thread limits (typically a few thousand). Virtual threads let you keep the simple blocking-I/O programming model while scaling to millions of concurrent tasks.
- When to use — I/O-bound workloads: HTTP servers, database calls, file I/O, external API calls. Anywhere you'd otherwise need async/reactive code just for scalability.
- When NOT to use — CPU-bound work. Virtual threads don't make computation faster — they reduce the cost of waiting. For CPU-heavy tasks, use a fixed-size
ForkJoinPoolor platform thread pool. - Pinning — A virtual thread gets "pinned" to its carrier (platform) thread inside
synchronizedblocks or native/JNI calls. PreferReentrantLockoversynchronizedin virtual-thread-heavy code to avoid pinning.
Creating Virtual Threads
// Start a virtual thread directly
Thread vt = Thread.startVirtualThread(() -> doWork());
// Builder API
Thread vt = Thread.ofVirtual()
.name("my-vthread")
.start(() -> doWork());
// Factory — useful for executors
ThreadFactory factory = Thread.ofVirtual()
.name("worker-", 0) // worker-0, worker-1, ...
.factory();
Thread t = factory.newThread(() -> doWork());
t.start(); Virtual Thread Executor
The most common way to use virtual threads — one virtual thread per task.
// New virtual thread for every submitted task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// Submit many concurrent tasks — each gets its own virtual thread
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
futures.add(executor.submit(() -> fetchData()));
}
for (Future<String> f : futures) {
System.out.println(f.get());
}
} // executor shuts down here, waits for all tasks Virtual vs Platform Threads
| Platform Threads | Virtual Threads | |
|---|---|---|
| Backed by | OS thread (1:1) | JVM-scheduled onto carrier threads (M:N) |
| Memory | ~1 MB stack per thread | ~few KB (grows as needed) |
| Creation cost | Expensive (OS syscall) | Cheap (JVM object) |
| Max count | Thousands | Millions |
| Blocking I/O | Blocks OS thread | Unmounts from carrier, carrier freed for other work |
| CPU-bound | Good — one OS thread per core | No benefit — still limited by carrier threads |
| Pooling | Required (expensive to create) | Do not pool — create a new one per task |
synchronized | Fine | Causes pinning — prefer ReentrantLock |
| ThreadLocal | Common pattern | Works but expensive at scale — prefer scoped values |
Pinning
When a virtual thread cannot unmount from its carrier.
// BAD — synchronized pins the virtual thread to its carrier
synchronized (lock) {
connection.query(sql); // blocking I/O while pinned
}
// GOOD — ReentrantLock does not pin
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
connection.query(sql); // virtual thread can unmount during I/O
} finally {
lock.unlock();
}
// Detect pinning at runtime
// JVM flag: -Djdk.tracePinnedThreads=short
// or: -Djdk.tracePinnedThreads=full Scoped Values (Preview)
Lightweight alternative to ThreadLocal for virtual threads.
// Declare a scoped value
private static final ScopedValue<String> USER = ScopedValue.newInstance();
// Bind a value for the duration of a Runnable
ScopedValue.runWhere(USER, "alice", () -> {
handleRequest(); // USER.get() returns "alice" here
});
// Read it deeper in the call stack
void handleRequest() {
String user = USER.get(); // "alice"
// ...
}
// Rebinding (nested scope)
ScopedValue.runWhere(USER, "bob", () -> {
// USER.get() is "bob" in this scope
});
// USER.get() is "alice" again here ThreadLocal | ScopedValue | |
|---|---|---|
| Mutability | Mutable (get/set anytime) | Immutable once bound |
| Lifecycle | Until removed or thread dies | Scoped to a runWhere block |
| Inheritance | InheritableThreadLocal | Inherited by child threads in StructuredTaskScope |
| Memory | Per-thread map (can leak) | Stack-like (auto cleanup) |
| Virtual threads | Works but expensive at scale | Designed for millions of threads |
Structured Concurrency (Preview)
Treat concurrent subtasks as a unit — if one fails, cancel the rest.
// ShutdownOnFailure — cancel all if any subtask fails
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> user = scope.fork(() -> fetchUser(id));
Subtask<String> order = scope.fork(() -> fetchOrder(id));
scope.join(); // wait for all
scope.throwIfFailed(); // propagate first failure
// Both succeeded
return new Response(user.get(), order.get());
}
// ShutdownOnSuccess — return first successful result, cancel the rest
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> fetchFromPrimary());
scope.fork(() -> fetchFromReplica());
scope.join();
return scope.result(); // first successful result
} Common Patterns
HTTP Server (thread-per-request)
// Each request gets its own virtual thread — no pooling needed
var server = HttpServer.create(new InetSocketAddress(8080), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/", exchange -> {
// blocking I/O is fine — virtual thread unmounts during wait
String data = fetchFromDb();
byte[] response = data.getBytes();
exchange.sendResponseHeaders(200, response.length);
exchange.getResponseBody().write(response);
exchange.close();
});
server.start(); Fan-out I/O
// Fetch many URLs concurrently
List<String> urls = List.of("url1", "url2", "url3", /* ... thousands */);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = urls.stream()
.map(url -> executor.submit(() -> httpGet(url)))
.toList();
List<String> results = new ArrayList<>();
for (Future<String> f : futures) {
results.add(f.get());
}
}