Cualquier backend en Java que necesite soportar un alto volumen de solicitudes simultáneas se enfrenta al mismo obstáculo fundamental: los hilos del sistema operativo son costosos. Cada uno reserva memoria en la pila (stack), y el sistema operativo no puede gestionar más de unos pocos miles sin degradar su rendimiento. Durante años, la respuesta de la industria fue migrar a stacks reactivos como WebFlux, Reactor y callbacks, reemplazando el código imperativo simple por un modelo mucho más complejo de leer y depurar. Los Hilos Virtuales (Virtual Threads) llegaron precisamente para resolver este cuello de botella sin pagar ese precio, y Java 25 ha madurado esta tecnología mucho más allá de su lanzamiento inicial en 2023.

El problema de los hilos tradicionales

Un hilo de plataforma es una capa delgada sobre un hilo del sistema operativo. Esto resulta costoso de dos formas:

En el modelo clásico de un hilo por solicitud de Spring MVC, esto significa que el tamaño del grupo de hilos (thread pool) de Tomcat se convierte en el límite máximo de concurrencia de la aplicación. Aumentar este grupo ayuda hasta cierto punto, pero más allá de eso, el propio servidor empieza a sufrir antes de que el código de negocio llegue a ser el verdadero cuello de botella.

¿Qué son los Hilos Virtuales?

Los Hilos Virtuales (JEP 444, finalizada en Java 21) son hilos ligeros gestionados directamente por la propia JVM y no por el sistema operativo. Se ejecutan sobre un pequeño grupo de hilos de plataforma denominados hilos portadores (carrier threads); por defecto, un ForkJoinPool con un hilo portador por cada núcleo de CPU disponible.

La mecánica central funciona así: cuando un hilo virtual se bloquea en una operación de E/S (lectura de socket, consulta a base de datos, Thread.sleep), la JVM desmonta ese hilo virtual de su hilo portador y libera al portador para ejecutar otro hilo virtual mientras espera. Cuando la operación bloqueante finaliza, el hilo virtual se remonta en cualquier portador disponible para continuar donde se quedó.

En la práctica, esto significa que puedes tener cientos de miles de hilos virtuales bloqueados en E/S simultáneamente ocupando solo un puñado de hilos reales del sistema operativo, y continuar escribiendo código imperativo, bloqueante y fácil de leer, sin tener que migrar a programación reactiva.

Habilitación en Spring Boot 4

La configuración requiere una sola línea:

spring.threads.virtual.enabled=true

Con esta propiedad habilitada:

Para comprobar esto en ejecución, creamos un endpoint sencillo que registra en qué hilo se está ejecutando:

Controlador:

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);
    }
}

Servicio:

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");
    }
}

Dominio de detalles del pedido (record):

package com.exemplo.pedidos.domain;

public record PedidoDetalhes(String id, String status) {}

Con spring.threads.virtual.enabled=true, los registros muestran algo como VirtualThread[#48]/runnable@ForkJoinPool-1-worker-3. Sin la propiedad, lo habitual: http-nio-8080-exec-3. Es la forma más rápida de confirmar que la configuración está activa.

Qué ‘fija’ (pinning) todavía a un hilo virtual en Java 25 (y qué ya se solucionó)

La fijación (pinning) ocurre cuando un hilo virtual se bloquea pero no puede desmontarse de su hilo portador. El portador queda retenido esperando, comportándose exactamente como un hilo de plataforma tradicional. Si esto ocurre con frecuencia, se pierde el beneficio principal de los hilos virtuales y la aplicación puede llegar a bloquearse si todos los portadores quedan fijados al mismo tiempo.

Hasta Java 23, el principal causante de esto era synchronized: cualquier bloqueo dentro de un método o bloque synchronized fijaba el hilo virtual completo. Era tan común que se convirtió en una regla generalizada: “reemplaza synchronized por ReentrantLock”. Gran parte de la literatura disponible aún repite esta recomendación.

En Java 25, esa regla ya no se aplica en la mayoría de los casos. La JEP 491, introducida en Java 24, reescribió la asociación de monitores de modo que ahora pertenecen al hilo virtual y no al portador. Esto significa que hoy en día un hilo virtual se desmonta con normalidad incluso bloqueándose dentro de un synchronized, de un Object.wait(), o esperando para entrar en un monitor.

Lo que todavía causa fijación (pinning) en Java 25:

El segundo caso es el más fácil de introducir inadvertidamente:

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;
    }
}

En entornos reales, el efecto suele ser solo una primera solicitud más lenta (la clase solo se carga una vez). Sin embargo, en sistemas con alta concurrencia durante el inicio en frío, esto puede fijar múltiples portadores simultáneamente. La solución es simple: no ejecutes E/S bloqueante dentro de inicializadores estáticos. Carga esas configuraciones explícitamente en un @PostConstruct o fase de inicio, fuera del camino crítico de las solicitudes concurrentes.

Para monitorizar el pinning en producción, el evento de Java Flight Recorder jdk.VirtualThreadPinned sigue siendo la herramienta adecuada. Se dispara cada vez que un hilo virtual queda fijado, proporcionando la traza de pila exacta del suceso.

Contexto entre llamadas: Scoped Values en sustitución de ThreadLocal

Propagar datos contextuales —como un correlationId o el usuario autenticado— entre métodos sin pasarlos como parámetros ha sido históricamente la función de ThreadLocal. Sigue funcionando con hilos virtuales, pero presenta fricciones conocidas: es mutable, requiere un remove() manual para no filtrar datos entre ejecuciones, y su propagación a hilos secundarios cuando se divide el trabajo en paralelo es implícita y propensa a errores.

Java 25 finaliza los Scoped Values (JEP 506), diseñados desde el inicio para este propósito: un valor inmutable con un ciclo de vida explícito y visible en el propio código.

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");
    }
}

Actualizando el controlador para vincular un correlationId durante la ejecución de la solicitud:

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));
    }
}

Y leyendo el valor en cualquier método invocado dentro de ese ámbito, sin necesidad de recibirlo como parámetro: basta con actualizar la línea de log de 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");
}

Cuando termina .call(...), el valor se desvincula automáticamente; no hay ningún remove() que olvidar. Un detalle importante: esta vinculación solo es visible para el código que se ejecuta dentro del mismo bloque where(...).run(...) / .call(...), en el mismo flujo lógico (incluidas las subtareas creadas mediante StructuredTaskScope). Si envías una tarea a un ExecutorService convencional desde dentro del bloque, esa tarea correrá en un hilo independiente que no heredará el valor, y ejecutar CORRELATION_ID.get() allí lanzará NoSuchElementException.

¿Cuándo no vale la pena?

Los hilos virtuales resuelven un problema específico: código que se bloquea esperando operaciones de E/S (base de datos, llamadas HTTP a otros servicios, colas, disco). En ese escenario, obtienes la concurrencia que antes solo se conseguía con programación reactiva, manteniendo un código imperativo fácil de leer y seguir.

Dónde no aportan ventajas:

En conclusión, los hilos virtuales no sustituyen a WebFlux en todos los escenarios: para flujos de streaming con contrapresión (backpressure) muy fina o control de concurrencia avanzado, el modelo reactivo conserva su valor. Pero para la inmensa mayoría de backends transaccionales y CRUD con integraciones, la pregunta inicial es: ¿tu cuello de botella es la red y la base de datos, o la CPU? Si es lo primero, los hilos virtuales te ofrecen el rendimiento de la programación reactiva sin sacrificar la simplicidad del código. Si es lo segundo, ninguna de las dos soluciones lo resolverá por sí sola y será necesario analizar la arquitectura detenidamente antes de decidir.