Java 21 llegó con una de esas features que cambian la conversación: virtual threads. Desde entonces, cada vez que un servicio tiene latencia alta, aparece la misma pregunta: «¿y si activamos virtual threads?».
La respuesta corta es depende. La respuesta útil es entender qué problema resuelven, dónde no aplican y cómo demostrar con datos que la migración vale la pena.
Qué cambia realmente
Un hilo de plataforma es una abstracción uno-a-uno sobre un hilo del sistema operativo. Reservar miles de ellos para esperar respuestas de red es caro: memoria, contexto y planificación. Los virtual threads, en cambio, son gestionados por la JVM y se montan sobre un conjunto pequeño de carrier threads cuando necesitan ejecutar CPU.
La consecuencia práctica es simple: puedes escribir código bloqueante y legible, y aun así escalar a decenas de miles de tareas concurrentes que pasan la mayor parte del tiempo esperando I/O.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Respuesta>> futures = ids.stream()
.map(id -> executor.submit(() -> cliente.consultar(id)))
.toList();
for (Future<Respuesta> future : futures) {
procesar(future.get());
}
}
En Spring Boot 3.2+ la activación es explícita:
spring.threads.virtual.enabled=true
Pero activar la propiedad no es una decisión de arquitectura. Es una decisión de rendimiento que debe validarse.
Dónde brillan
- Servicios con muchas llamadas a bases de datos, APIs o colas.
- Gateways y proxies con alta concurrencia y poca CPU por petición.
- Procesos batch que hoy mantienen pools complejos solo para esperar.
- Código nuevo donde la simplicidad del modelo bloqueante importa.
Dónde no aportan (o pueden empeorar)
- Cargas CPU-bound: la JVM no puede regalar núcleos. Agregar hilos solo agrega contención.
- Librerías que usan pools internos con límites propios; el cuello de botella no desaparece, se mueve.
- Acceso a bases de datos con pools pequeños: si el pool tiene 20 conexiones, tener 100.000 virtual threads no genera 100.000 consultas simultáneas.
- Código con
synchronizedalrededor de operaciones bloqueantes en Java 21. El virtual thread queda pinned al carrier y pierde la ventaja.
Cómo medirlo sin engañarte
La métrica que importa no es «cuántos hilos tengo», sino cuánto trabajo útil entrego por unidad de recurso. Antes de migrar, define una línea base:
| Métrica | Por qué importa |
|---|---|
| p95 / p99 de latencia | Detecta colas ocultas y degradación bajo carga |
| Throughput (req/s) | Confirma que no solo movimos el cuello de botella |
| CPU y memoria nativa | Los virtual threads son baratos, pero no gratuitos |
| GC pause time | Cambios de asignación pueden afectar la pausa |
| Errores y timeouts | Un pool saturado puede fallar de forma distinta |
Prueba con el mismo tráfico, la misma base de datos y el mismo hardware. Si no puedes reproducir producción, al menos documenta las diferencias. Un benchmark sin condiciones comparables es una anécdota.
Checklist de adopción
- Identifica si el cuello de botella es I/O o CPU.
- Revisa pools de conexiones, límites de proveedores y rate limits.
- Busca
synchronizeden rutas calientes. - Ejecuta pruebas de carga con y sin virtual threads.
- Mide latencia, throughput, CPU y memoria nativa.
- Despliega de forma incremental, con feature flag si es posible.
- Documenta la decisión y el contexto en un ADR.
El criterio que uso
Adopto virtual threads cuando el servicio pasa la mayor parte del tiempo esperando y el código es más claro sin gestión manual de pools. Los evito cuando la carga es computacional o cuando el límite real está en otro recurso.
La tecnología es excelente. Pero la arquitectura sigue siendo decidir dónde aplicarla, no activar una propiedad y esperar magia.