Spring Boot 3 + Java 21: virtual threads y lo que cambia en producción
Java 21 trajo virtual threads como feature estable. Spring Boot 3.2 los activa con una línea de configuración. El marketing dice que "resuelven la concurrencia". La realidad es más matizada y más interesante.
Este post es sobre qué cambia de verdad en una API Spring cuando activás virtual threads, qué no cambia, y qué podés romper si no entendés los límites.
El problema que resuelven
En el modelo tradicional de Spring MVC, cada request HTTP ocupa un thread del pool durante toda su duración. Si ese request hace una llamada a base de datos que tarda 50ms, el thread está bloqueado esos 50ms — no puede atender otro request.
Con un pool de 200 threads (el default de Tomcat), tu API puede manejar 200 requests concurrentes antes de empezar a encolar. Si cada request espera 100ms en I/O, tu throughput real es 200 / 0.1s = 2000 req/seg máximo, aunque el procesamiento CPU sea mínimo.
La solución hasta ahora era Reactor y WebFlux: programación reactiva que libera el thread mientras espera I/O. Funciona, pero tiene un costo cognitivo alto — código asíncrono, Mono/Flux, debugging complicado, stacktraces inútiles.
Virtual threads resuelven el mismo problema de otra manera: en lugar de liberar el thread del OS, la JVM crea millones de threads virtuales livianos. Cuando un virtual thread hace una operación bloqueante de I/O, la JVM lo "desmonta" del thread del OS y monta otro. El thread del OS nunca bloquea. Tu código sigue siendo síncrono y secuencial — la JVM maneja la complejidad.
Qué cambia con Spring Boot 3.2 + Java 21
Activación
Una sola propiedad en application.properties:
spring.threads.virtual.enabled=true
Con esto, Tomcat usa virtual threads para atender requests. Cada request corre en su propio virtual thread. El pool de 200 threads del OS deja de ser el cuello de botella.
Concurrencia I/O-bound sin Reactor
El cambio más importante para APIs típicas: ya no necesitás WebFlux para manejar alta concurrencia I/O-bound. Una API Spring MVC clásica con JDBC, RestTemplate o cualquier cliente bloqueante puede manejar miles de requests concurrentes sin agotar threads del OS.
Antes:
// Para alta concurrencia, necesitabas WebFlux
public Mono<UserDto> getUser(Long id) {
return userRepository.findById(id)
.map(this::toDto);
}
Ahora con virtual threads:
// Spring MVC clásico, misma concurrencia
public UserDto getUser(Long id) {
return userRepository.findById(id)
.map(this::toDto)
.orElseThrow();
}
Mismo throughput, código síncrono. Para equipos que luchaban con la curva de aprendizaje de Reactor, esto es significativo.
Thread-per-request vuelve a ser razonable
Con virtual threads, crear un thread por request no es caro. La JVM puede manejar millones de virtual threads con memoria y overhead mínimos. El modelo mental de "un thread = un request" vuelve a funcionar a escala.
Lo que los virtual threads NO arreglan
CPU-bound sigue siendo CPU-bound
Virtual threads son útiles cuando el tiempo se pasa esperando I/O. Si tu código pasa el tiempo haciendo cómputo — procesamiento de imágenes, criptografía, cálculos intensivos — virtual threads no ayudan. El thread del OS sigue ocupado durante el cómputo.
Para trabajo CPU-bound, el límite sigue siendo la cantidad de cores. No hay magia aquí.
Pinning: el enemigo silencioso
El problema más importante de los virtual threads en producción se llama pinning. Ocurre cuando un virtual thread no puede ser desmontado del thread del OS porque está dentro de un bloque synchronized o un método nativo.
// Esto causa pinning
synchronized (lock) {
result = jdbcTemplate.query(...);
}
Durante el pinning, el thread del OS queda bloqueado igual que antes. Si tenés muchos virtual threads pinneados simultáneamente, tu concurrencia real se degrada al número de threads del OS disponibles.
Dónde aparece en código Spring típico:
- Drivers JDBC viejos que usan
synchronizedinternamente. - Código legacy con
synchronizedque envuelve operaciones de I/O. - Algunas implementaciones de connection pools.
Cómo detectarlo: -Djdk.tracePinnedThreads=full en staging. Cuando ocurre pinning, la JVM loguea el stacktrace.
Connection pools siguen siendo necesarios
Un error común: pensar que ya no necesitás connection pool porque "hay threads para todos". El connection pool no limita threads — limita conexiones a la base de datos. Si creás 10.000 virtual threads que todos intentan conectarse simultáneamente, la BD sigue siendo el cuello de botella. HikariCP con virtual threads funciona bien — calibrá el pool size correctamente.
Configuración recomendada para producción
spring.threads.virtual.enabled=true
spring.datasource.hikari.maximum-pool-size=20
server.tomcat.threads.max=200
Para detectar pinning en staging:
-Djdk.tracePinnedThreads=short
¿WebFlux sigue teniendo sentido?
Sí, en casos específicos:
- Streaming de datos: WebFlux con backpressure maneja streaming de grandes volúmenes mejor.
- Ecosistema reactivo consolidado: si ya tenés R2DBC, clientes reactivos y el equipo domina el modelo, no hay razón para migrar.
- Latencia ultra-baja con alta concurrencia: en escenarios extremos, el modelo reactivo sigue teniendo ventajas de overhead por thread.
Para una API CRUD típica con PostgreSQL, Redis y servicios externos: virtual threads con Spring MVC es la opción más simple y suficientemente performante.
Conclusión
Virtual threads en Java 21 + Spring Boot 3.2 hacen que el modelo síncrono clásico sea viable para alta concurrencia I/O-bound. Para la mayoría de las APIs Spring, WebFlux ya no es obligatorio para escalar.
Lo que no cambia: CPU-bound sigue necesitando cores, connection pools siguen siendo necesarios, y el pinning con synchronized puede convertirse en tu nuevo cuello de botella.
Activar virtual threads toma un minuto. Entender sus límites toma este post.