Virtual Threads in Java
Master Project Loom's virtual threads (Java 21) — lightweight concurrency, structured concurrency, and migrating from platform threads.
The Problem with Platform Threads
Traditional Java servers create one platform thread per request. Platform threads map 1:1 to OS threads, which are expensive:
- Each OS thread consumes ~1 MB of stack memory
- Context switching between OS threads has overhead
- A typical server tops out at ~10,000 concurrent threads
// Classic thread-per-request — does not scale beyond ~10k concurrent requests
ExecutorService pool = Executors.newFixedThreadPool(200);
pool.submit(() -> {
// This thread BLOCKS while waiting for the DB response
// The OS thread is parked, wasting memory, for the entire wait
var result = database.query("SELECT ...");
processResult(result);
});
Virtual threads solve this: when a virtual thread blocks on I/O, the JVM unmounts it from its carrier thread. The carrier thread is free to run other virtual threads. When the I/O completes, the virtual thread is remounted on any available carrier.
Creating Virtual Threads (Java 21)
// 1. Thread.ofVirtual() — same API as platform threads
Thread vt = Thread.ofVirtual()
.name("my-virtual-thread")
.start(() -> System.out.println("Running in: " + Thread.currentThread()));
vt.join();
// 2. Thread.startVirtualThread() — shortcut
Thread vt2 = Thread.startVirtualThread(() -> {
System.out.println("Virtual: " + Thread.currentThread().isVirtual()); // true
});
// 3. Executor backed by virtual threads — the recommended way for servers
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
// Each task gets its own virtual thread — 100k threads, no problem
Thread.sleep(Duration.ofMillis(100));
return "done";
});
}
} // executor.close() waits for all tasks, then shuts down
Throughput Demo — Virtual vs Platform Threads
import java.time.*;
import java.util.concurrent.*;
public class ThroughputDemo {
static void simulateRequest() throws InterruptedException {
Thread.sleep(100); // simulate DB/HTTP wait
}
public static void main(String[] args) throws Exception {
int tasks = 10_000;
// Platform thread pool — limited parallelism
long platformMs = time(() -> {
try (var exec = Executors.newFixedThreadPool(200)) {
var futures = new java.util.ArrayList<Future<?>>();
for (int i = 0; i < tasks; i++) {
futures.add(exec.submit(() -> { simulateRequest(); return null; }));
}
for (var f : futures) f.get();
}
});
// Virtual thread executor — one virtual thread per task
long virtualMs = time(() -> {
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = new java.util.ArrayList<Future<?>>();
for (int i = 0; i < tasks; i++) {
futures.add(exec.submit(() -> { simulateRequest(); return null; }));
}
for (var f : futures) f.get();
}
});
System.out.println("Platform threads (200 pool): " + platformMs + " ms");
// ~5000 ms (10000 tasks / 200 threads * 100ms each)
System.out.println("Virtual threads: " + virtualMs + " ms");
// ~110 ms (all 10000 run concurrently)
}
static long time(ThrowingRunnable r) throws Exception {
long start = System.currentTimeMillis();
r.run();
return System.currentTimeMillis() - start;
}
@FunctionalInterface interface ThrowingRunnable { void run() throws Exception; }
}
Virtual Threads with HTTP Clients
import java.net.http.*;
import java.net.URI;
import java.util.*;
import java.util.concurrent.*;
public class ParallelHttpDemo {
public static void main(String[] args) throws Exception {
var client = HttpClient.newBuilder()
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
List<String> urls = List.of(
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1"
);
// All three requests run concurrently on virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = urls.stream()
.map(url -> executor.submit(() ->
client.send(
HttpRequest.newBuilder(URI.create(url)).build(),
HttpResponse.BodyHandlers.ofString()
).statusCode()
))
.toList();
for (var f : futures) {
System.out.println("Status: " + f.get());
}
}
// All three complete in ~1 second, not 3
}
}
Avoiding Pinning
Virtual threads are “pinned” to their carrier thread when inside a synchronized block. Use ReentrantLock instead:
import java.util.concurrent.locks.*;
public class PinningDemo {
// BAD — synchronized pins the virtual thread to the carrier
private final Object lock = new Object();
private int counter = 0;
public void incrementBad() {
synchronized (lock) { // virtual thread pinned here
counter++;
doSlowIo(); // carrier thread blocked while waiting!
}
}
// GOOD — ReentrantLock does not pin
private final ReentrantLock reentrantLock = new ReentrantLock();
public void incrementGood() throws InterruptedException {
reentrantLock.lock();
try {
counter++;
doSlowIo(); // virtual thread unmounts here, carrier is free
} finally {
reentrantLock.unlock();
}
}
private void doSlowIo() {
try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
Structured Concurrency (Java 21+)
StructuredTaskScope ensures child tasks cannot outlive their enclosing scope. If any child fails, the others are cancelled:
import java.util.concurrent.*;
import java.util.concurrent.StructuredTaskScope.*;
record UserProfile(long id, String name) {}
record UserOrders(long userId, List<String> orders) {}
record UserPage(UserProfile profile, UserOrders orders) {}
public class StructuredConcurrencyDemo {
static UserProfile fetchProfile(long userId) throws InterruptedException {
Thread.sleep(50); // simulate API call
return new UserProfile(userId, "Alice");
}
static UserOrders fetchOrders(long userId) throws InterruptedException {
Thread.sleep(80); // simulate DB query
return new UserOrders(userId, List.of("ORD-1", "ORD-2"));
}
public static UserPage loadUserPage(long userId)
throws InterruptedException, ExecutionException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Fork both tasks — they run concurrently on virtual threads
Subtask<UserProfile> profileTask = scope.fork(() -> fetchProfile(userId));
Subtask<UserOrders> ordersTask = scope.fork(() -> fetchOrders(userId));
scope.join(); // wait for both
scope.throwIfFailed(); // propagate any exception
// Both succeeded — combine results
return new UserPage(profileTask.get(), ordersTask.get());
}
// scope close() cancels any still-running tasks
}
public static void main(String[] args) throws Exception {
long start = System.currentTimeMillis();
UserPage page = loadUserPage(42);
long ms = System.currentTimeMillis() - start;
System.out.println(page.profile().name()); // Alice
System.out.println(page.orders().orders()); // [ORD-1, ORD-2]
System.out.println("Time: " + ms + " ms"); // ~80 ms, not 130 ms
}
}
ShutdownOnSuccess — First Result Wins
// Return the result of whichever source responds first
static String fastestSource(List<String> urls) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
for (String url : urls) {
scope.fork(() -> fetchUrl(url));
}
scope.join();
return scope.result(); // result of the first successful subtask
}
}
Thread-Local Variables and Virtual Threads
Traditional ThreadLocal works with virtual threads but can lead to memory leaks if virtual threads are short-lived and ThreadLocals hold large objects. Prefer scoped values (Java 21):
import java.util.concurrent.*;
// ScopedValue — immutable, inheritable, no cleanup needed
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
static void handleRequest(String requestId) throws Exception {
ScopedValue.where(REQUEST_ID, requestId).run(() -> {
// REQUEST_ID.get() returns requestId within this scope
processStep1();
processStep2();
});
// REQUEST_ID is unbound after run() exits
}
static void processStep1() {
System.out.println("Step 1, request: " + REQUEST_ID.get());
}
Spring Boot Integration
Spring Boot 3.2+ has first-class virtual thread support:
# application.properties
spring.threads.virtual.enabled=true
This single property switches the embedded Tomcat/Jetty to use a virtual thread per request — no code changes needed.
@RestController
public class UserController {
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable long id) {
// This runs on a virtual thread automatically
// Blocking calls (JPA, RestTemplate, JDBC) are fine here
return userService.findById(id);
}
}