Every Java backend that needs to handle high concurrent traffic eventually runs into the same fundamental constraint: operating system threads are expensive. Each one reserves stack memory, and the operating system cannot sustain more than a few thousand of them without degrading. For years, the industry’s answer was migrating to reactive stacks: WebFlux, Reactor, and callbacks—trading simple imperative code for a programming model that is markedly harder to read and debug. Virtual Threads arrived specifically to eliminate that bottleneck without forcing developers to pay that complexity tax, and Java 25 has finally matured this capability far beyond its initial release in 2023.
The Problem with Traditional Threads
A platform thread is a thin wrapper on top of an operating system thread. This incurs heavy costs in two ways:
- Each thread reserves roughly 1 MB of stack memory, meaning a few hundred or thousand threads already put significant pressure on RAM.
- Context switching between OS threads incurs kernel-level overhead, and that cost escalates rapidly with the number of threads competing for CPU time.
In the classic “thread-per-request” model of Spring MVC, this means Tomcat’s thread pool size becomes the hard ceiling for application concurrency. Increasing this pool helps up to a point, but beyond that, the host machine starts suffocating before your business logic even has a chance to become the bottleneck.
What Are Virtual Threads?
Virtual Threads (JEP 444, finalized in Java 21) are lightweight threads managed directly by the JVM rather than the operating system. They run on top of a small pool of platform threads known as carrier threads—by default, a ForkJoinPool with one carrier thread per available CPU core.
The core mechanics work as follows: when a virtual thread blocks on an I/O operation (reading from a socket, querying a database, invoking Thread.sleep), the JVM unmounts that virtual thread from its carrier thread, freeing the carrier to execute another virtual thread while the first one waits. When the blocking operation completes, the virtual thread is remounted onto any available carrier thread to resume execution where it left off.
In practice, this means you can have hundreds of thousands of virtual threads blocked on I/O concurrently while consuming only a handful of real operating system threads—all while writing straightforward, blocking, readable imperative code without ever touching reactive programming.
Enabling in Spring Boot 4
Configuration takes just a single property:
spring.threads.virtual.enabled=true
With this property enabled:
- Embedded Tomcat (or Jetty) immediately processes incoming HTTP requests using virtual threads.
- The
applicationTaskExecutorbean used by@Async,@Scheduled, and Spring MVC asynchronous request processing becomes aSimpleAsyncTaskExecutorthat creates a new virtual thread per task rather than recycling a fixed pool.
To see this in action, here is a simple endpoint that logs the current executing thread:
Controller:
package com.exemplo.pedidos.adapter.in.web;
import com.exemplo.pedidos.application.service.PedidoConsultaService;
import com.exemplo.pedidos.domain.PedidoDetalhes;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/pedidos")
public class PedidoConsultaController {
private final PedidoConsultaService service;
public PedidoConsultaController(PedidoConsultaService service) {
this.service = service;
}
@GetMapping("/{id}")
public PedidoDetalhes buscar(@PathVariable String id) {
return service.buscarComDetalhes(id);
}
}
Service:
package com.exemplo.pedidos.application.service;
import com.exemplo.pedidos.domain.PedidoDetalhes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
public class PedidoConsultaService {
private static final Logger log = LoggerFactory.getLogger(PedidoConsultaService.class);
public PedidoDetalhes buscarComDetalhes(String id) {
log.info("Processando pedido {} na thread {}", id, Thread.currentThread());
// Simula uma chamada bloqueante real: HTTP pra outro serviço, query no banco, fila...
try {
Thread.sleep(Duration.ofMillis(300));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Consulta interrompida", e);
}
return new PedidoDetalhes(id, "PROCESSADO");
}
}
Order details domain model (record):
package com.exemplo.pedidos.domain;
public record PedidoDetalhes(String id, String status) {}
With spring.threads.virtual.enabled=true, the log output displays something like VirtualThread[#48]/runnable@ForkJoinPool-1-worker-3. Without this property, you get the familiar http-nio-8080-exec-3. This is the quickest way to verify that virtual threads are actively handling traffic.
What Still ‘Pins’ a Virtual Thread in Java 25 (and What Was Fixed)
Pinning occurs when a virtual thread blocks but cannot be unmounted from its carrier thread. The carrier thread remains tied down and waiting, behaving exactly like a conventional platform thread. If this occurs frequently, you forfeit the core scalability advantage of virtual threads and can even exhaust all carriers, freezing the entire application.
Until Java 23, the number one culprit behind pinning was synchronized: any blocking operation inside a synchronized block or method pinned the entire virtual thread. This was so widespread that it became a standard maxim: “replace synchronized with ReentrantLock”. Much of the advice found online still repeats this legacy rule.
In Java 25, that rule no longer applies in most cases. JEP 491, delivered in Java 24, redesigned monitor association so that monitors belong to the virtual thread rather than the carrier thread. Today, a virtual thread unmounts normally even when blocking inside a synchronized block, calling Object.wait(), or waiting to acquire an intrinsic monitor.
Here is what still pins a virtual thread in Java 25:
- Native frames: a JNI (Java Native Interface) call or a downcall via the Foreign Function & Memory API that blocks pins the virtual thread, because the JVM cannot migrate a native stack frame across carrier threads. This is unrelated to
synchronizedand was not addressed by JEP 491. - Class initialization: a class’s static initializer runs within native JVM frames. If that static block performs a blocking operation, the virtual thread that triggered class loading remains pinned until initialization completes.
The second case is surprisingly easy to introduce accidentally:
public class ConfiguracaoExterna {
// Isso roda uma única vez, na inicialização da classe e pina
// a virtual thread que primeiro acessar ConfiguracaoExterna.
private static final String CHAVE_API = carregarDeServicoExterno();
private static String carregarDeServicoExterno() {
try {
Thread.sleep(Duration.ofMillis(200)); // I/O bloqueante durante o class loading
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "chave-simulada";
}
public static String chaveApi() {
return CHAVE_API;
}
}
In production, the impact is usually just a slightly slower initial request (the class loads only once). However, under high concurrency during cold startup, this can pin multiple carrier threads simultaneously. The fix is simple: never perform blocking I/O inside static initializers. Load external configurations explicitly in a @PostConstruct or startup step outside the hot concurrent request path.
To diagnose pinning in production, Java Flight Recorder’s jdk.VirtualThreadPinned event remains the definitive diagnostic tool. It triggers whenever a virtual thread is pinned, complete with the full stack trace pinpointing where it occurred.
Context Propagation: Scoped Values Replacing ThreadLocal
Propagating contextual data—such as a correlationId or an authenticated user—across methods without passing it as an explicit parameter has long been the responsibility of ThreadLocal. While ThreadLocal still works with virtual threads, it brings known friction: it is mutable, requires manual remove() invocations to avoid memory leaks between executions, and inheriting context to child threads when splitting parallel work is implicit and error-prone.
Java 25 finalizes Scoped Values (JEP 506), designed from the ground up for this scenario: an immutable value with an explicit, code-visible lifecycle.
package com.exemplo.pedidos.application;
public final class ContextoRequisicao {
public static final ScopedValue<String> CORRELATION_ID = ScopedValue.newInstance();
private ContextoRequisicao() {}
public static String correlationIdAtual() {
return CORRELATION_ID.orElse("desconhecido");
}
}
Updating the controller to bind a correlationId during request processing:
package com.exemplo.pedidos.adapter.in.web;
import com.exemplo.pedidos.application.ContextoRequisicao;
import com.exemplo.pedidos.application.service.PedidoConsultaService;
import com.exemplo.pedidos.domain.PedidoDetalhes;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/pedidos")
public class PedidoConsultaController {
private final PedidoConsultaService service;
public PedidoConsultaController(PedidoConsultaService service) {
this.service = service;
}
@GetMapping("/{id}")
public PedidoDetalhes buscar(@PathVariable String id) {
String correlationId = UUID.randomUUID().toString();
return ScopedValue.where(ContextoRequisicao.CORRELATION_ID, correlationId)
.call(() -> service.buscarComDetalhes(id));
}
}
And retrieving the value anywhere inside that execution scope without passing it as a method parameter—simply update the logging line in PedidoConsultaService:
public PedidoDetalhes buscarComDetalhes(String id) {
log.info("Processando pedido {} | correlationId={}", id, ContextoRequisicao.correlationIdAtual());
try {
Thread.sleep(Duration.ofMillis(300));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Consulta interrompida", e);
}
return new PedidoDetalhes(id, "PROCESSADO");
}
When .call(...) completes, the value is automatically unbound—there is no remove() to forget. One common caveat: this binding is only visible to code executing within the same where(...).run(...) / .call(...) block in the same logical flow (including child tasks spawned via StructuredTaskScope). If you submit a task to a generic ExecutorService from within the block, that task runs on an unlinked thread that does not inherit the value, and invoking CORRELATION_ID.get() there will throw a NoSuchElementException.
When Is It Not Worth It?
Virtual threads solve a very specific problem: code that spends its time blocking on I/O (databases, outgoing HTTP calls, message queues, disk). In that scenario, you achieve the massive concurrency gains historically reserved for reactive programming while retaining simple, readable imperative code.
Where they will not help:
- CPU-bound workloads do not run faster with virtual threads. If your bottleneck is mathematical computation rather than waiting for I/O, your ceiling remains the number of physical CPU cores. A thread crunching pure calculations monopolizes its carrier thread regardless of whether it is virtual or platform.
- The bottleneck shifts downstream. Virtual threads remove the artificial bottleneck of Tomcat’s thread pool, but that merely shifts pressure downstream: database connection pool limits (HikariCP), external API rate limits, and downstream capacity become your real ceilings. These limits must be re-evaluated and properly sized.
- Legacy libraries with blocking native calls can still pin carrier threads. While uncommon in modern standard application code, this should be monitored via JFR before deploying at massive scale.
In conclusion, virtual threads do not make WebFlux obsolete in every scenario—for advanced reactive streaming with fine-grained backpressure, reactive frameworks retain their purpose. But for the vast majority of I/O-intensive CRUD backends and enterprise services, ask yourself this: is your bottleneck network and database I/O, or CPU? If the former, virtual threads grant you the concurrency of reactive stacks without sacrificing code simplicity. If the latter, neither approach alone will solve the issue, and you will need to re-evaluate your architecture accordingly.