Millions of cheap threads — how Project Loom changes Java server-side concurrency.
Published September 21, 2026
A traditional Java thread maps 1:1 to an OS thread. OS threads are expensive:
This is why Node.js and reactive frameworks (WebFlux) were invented — to handle more concurrent requests without more threads.
Virtual threads are lightweight, JVM-managed threads:
// 100,000 virtual threads — this works
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // blocks virtual thread, not OS thread
System.out.println("Done: " + i);
})
);
}
// Completes in ~1 second — all 100K sleep concurrently
spring:
threads:
virtual:
enabled: true
With this property, Spring Boot replaces its Tomcat thread pool with virtual threads. Each incoming HTTP request runs on its own virtual thread.
✅ I/O-bound workloads: REST calls, DB queries, file reads — virtual threads shine here
❌ CPU-bound workloads: image processing, cryptography — virtual threads don't help because the carrier thread is occupied the entire time
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<User> user = scope.fork(() -> fetchUser(userId));
Future<Order> order = scope.fork(() -> fetchOrder(orderId));
scope.join(); // wait for both
scope.throwIfFailed();
return new Response(user.resultNow(), order.resultNow());
}
Structured concurrency ensures child tasks are cleaned up when the parent scope exits — no more fire-and-forget threads.
Thread vt = Thread.ofVirtual()
.name("worker-", 0) // name prefix + auto-incrementing counter
.unstarted(() -> doWork());
vt.start();
// Or start immediately:
Thread.ofVirtual().start(() -> doWork());
Executors.newVirtualThreadPerTaskExecutor() (shown above) is the pool-shaped entry point; Thread.ofVirtual() is the lower-level builder for creating individual virtual threads directly, useful when you want a named, one-off virtual thread outside an executor's task-submission model.
A virtual thread is pinned to its carrier thread (can't be unmounted while blocked) in two situations: while executing inside a synchronized block or method, and while executing a native method or a foreign-function call. A pinned virtual thread blocks its carrier for the duration — if enough virtual threads pin simultaneously, you can exhaust the small carrier-thread pool and lose the scalability benefit entirely, silently. The fix for the synchronized case is switching to ReentrantLock (see synchronized and Locks), which does not pin, since it's implemented without relying on the JVM monitor mechanism that causes pinning.
Thread-pool-sizing formulas (see the CPU-bound vs I/O-bound formula in ExecutorService & Thread Pools) assume threads are an expensive, limited resource to be carefully rationed — that assumption is specifically why platform-thread pools are capped. Virtual threads invert this: they're cheap enough (~1 KB each) that you're meant to create one per task, uncapped, rather than pooling and reusing them — newVirtualThreadPerTaskExecutor() deliberately does not limit concurrency the way newFixedThreadPool(n) does. Code that was written assuming ExecutorService submission implies bounded concurrency (a natural backpressure mechanism) can silently lose that backpressure when swapped to a virtual-thread executor — if unbounded task submission was relying on the pool's fixed size to throttle callers, that throttling needs to be reintroduced explicitly (e.g. a Semaphore) rather than assumed from the executor.
"Virtual threads eliminate the need for reactive programming for I/O-bound workloads. They let you write blocking-style synchronous code that performs like async code — without the complexity of CompletableFuture chains or WebFlux."
Key caveat: avoid synchronised blocks with virtual threads. synchronized pins the virtual thread to its carrier thread, negating the benefit. Use ReentrantLock instead.