Concurrency
Virtual Threads
- Cheap: create one per task, never pool them
- Blocking I/O unmounts the virtual thread from its carrier — the OS thread moves on
Executors.newVirtualThreadPerTaskExecutor()is the drop-in server pattern- They help I/O-bound workloads; CPU-bound work gains nothing
- Limit resources with a
Semaphore, not by pooling threads - Caveats:
ThreadLocalper-"thread" caches multiply; pinning viasynchronizedfixed in JDK 24 (JEP 491)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Price>> results = suppliers.stream()
.map(s -> executor.submit(() -> {
var response = http.send(requestFor(s), BodyHandlers.ofString()); // blocks — cheaply
return parsePrice(response.body());
}))
.toList();
// 1,000 suppliers → 1,000 virtual threads → a handful of carrier threads
}Mechanics: a virtual thread's stack lives on the heap. When it hits a blocking operation the JDK has retrofitted (sockets, locks, sleeps, queues), the continuation unmounts from its carrier (a small FJ pool of OS threads, ~1 per core) and the carrier picks up another virtual thread. Millions of blocked virtual threads cost heap, not OS threads — the C10K problem dissolved into ordinary code.
What changes in design: stop sizing thread pools for I/O concurrency (the pool was the bottleneck), stop contorting into reactive pipelines for scale alone, and bound access to scarce resources explicitly with a Semaphore. What doesn't change: Thread Safety — virtual threads are real threads; every visibility and atomicity rule applies unchanged.