Una pregunta sencilla
Hagamos un ejercicio. Tienes un servicio REST. Cada petición consulta una base de datos y llama a otra API, y cada llamada tarda unos 50 milisegundos. ¿Cuántas peticiones simultáneas puede atender tu servicio?
Si tu respuesta empieza con "depende de cuántos hilos tenga", vas bien. Y si te parece raro que la cantidad de hilos decida cuánta gente atiendes mientras la CPU está casi sin hacer nada, vas todavía mejor. De esa incomodidad trata este artículo.
Pensemos en un restaurante. Un mesero toma el pedido, lo lleva a la cocina y se queda ahí parado hasta que sale el plato. Con 10 mesas funciona. Con 1.000 mesas necesitarías 1.000 meseros, y la mayoría estaría mirando la puerta de la cocina. Un buen mesero deja el pedido y atiende otra mesa mientras tanto. La pregunta de fondo es quién se encarga de esa coordinación: tú, tu framework o la JVM.
Vamos a recorrer el camino completo. Empezamos por el modelo clásico y la salida reactiva, pasamos por los virtual threads y lo que Java 25 añadió encima, y terminamos con las corrutinas de Kotlin y las promesas funcionales. Al final tendrás un criterio claro para elegir.
Primero, los conceptos
Un hilo es una línea de ejecución: sigue tus instrucciones una tras otra. En nuestra analogía, es el mesero.
I/O (entrada/salida) es todo lo que sale de tu proceso: una consulta SQL, una llamada HTTP, leer un archivo. Para la CPU, 50 milisegundos son una eternidad. En ese tiempo podría ejecutar cientos de millones de instrucciones.
Cuando un hilo hace I/O de la forma tradicional, se bloquea. No trabaja ni avanza, solo espera.
Hasta Java 21, cada Thread de Java estaba respaldado por un hilo del sistema operativo. Hoy los llamamos hilos de plataforma, y no son baratos. Cada uno reserva una pila que por defecto ronda 1 MB, y crearlos, destruirlos y alternar entre ellos tiene un costo que paga el sistema operativo. Por eso nadie crea 200.000. Lo habitual era un pool fijo de 100 a 200 hilos que se reutilizan.
El problema: un hilo por petición, y pocos hilos
El modelo tradicional de los servidores Java se llama thread-per-request. Cada petición recibe un hilo del pool, y ese hilo la atiende de principio a fin. Fíjate en lo fácil que se lee:
var cliente = cargarCliente(id); // espera a la base de datos
var pedidos = cargarPedidos(cliente); // espera de nuevo
var precios = cargarPrecios(pedidos); // y otra vez
return crearRespuesta(cliente, pedidos, precios);
Cualquiera entiende este código en diez segundos. Esa es su gran virtud.
Su debilidad aparece con la carga. Un desarrollador contaba que en 2017 tenía un servicio REST con 84 operaciones que pasaban casi todo el tiempo esperando a la base de datos o a otras APIs. Con decenas de peticiones por segundo no había problema. Con cientos, el pool se agotaba.
Hagamos la cuenta. Con 200 hilos y peticiones que pasan 300 ms esperando, el techo teórico ronda las 660 peticiones por segundo, sin importar cuántos núcleos tenga tu máquina. La petición 201 hace fila, aparecen los timeouts y los errores se propagan en cascada. Mientras tanto, la CPU está casi ociosa.
La primera salida: no bloquear nunca
Durante años, el ecosistema respondió cambiando la forma de programar. Netty, Vert.x, RxJava, Reactor y CompletableFuture comparten una idea: un hilo nunca debe quedarse esperando. En lugar de esperar el resultado, describes qué hacer cuando llegue, y el hilo queda libre para otra cosa.
return cargarClienteAsync(id)
.thenCompose(cliente -> cargarPedidosAsync(cliente)
.thenCompose(pedidos -> cargarPreciosAsync(pedidos)
.thenApply(precios -> crearRespuesta(cliente, pedidos, precios))));
Es exactamente la misma lógica de antes. Compárala con la versión secuencial y pregúntate cuál preferirías depurar un viernes a las seis de la tarde.
Este enfoque funciona y escala muy bien con pocos hilos. El precio lo paga quien mantiene el código. La lógica queda partida en etapas, y si algo falla, el stack trace muestra la etapa que falló pero no el camino que llevó hasta ella. Además, conceptos como schedulers, suscripciones o backpressure terminan mezclados con la lógica de negocio. Ganamos escalabilidad y perdimos legibilidad.
Virtual threads: abaratar el hilo
Project Loom tomó el camino contrario. En vez de pedirte que cambies tu código, cambió lo que cuesta un hilo. Los virtual threads llegaron como funcionalidad estable en JDK 21, en septiembre de 2023. Java 25, la versión LTS actual, los trae con varias mejoras que veremos en la siguiente sección.
Un virtual thread es un java.lang.Thread de verdad, pero lo gestiona la JVM y no el sistema operativo, y su pila crece solo cuando la necesita. La JVM mantiene un grupo pequeño de hilos de plataforma, los carrier threads, normalmente uno por núcleo. Cuando un virtual thread se bloquea esperando I/O, la JVM lo desmonta del carrier y guarda su estado en memoria. El carrier queda libre para ejecutar otro. Cuando llega la respuesta, el virtual thread se vuelve a montar y sigue en la línea donde iba. Es el mesero que deja el pedido y atiende otra mesa, solo que esta vez la coordinación la hace la JVM.
No me creas a mí: pruébalo. En Java 25 ni siquiera necesitas declarar una clase, gracias a los archivos fuente compactos (JEP 512), que además importan automáticamente el módulo java.base. Guarda esto como Hilos.java y ejecútalo con java Hilos.java:
void main() {
var inicio = System.nanoTime();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // simula una llamada de I/O
return i;
}));
} // al cerrar, el executor espera a que terminen todas las tareas
var ms = (System.nanoTime() - inicio) / 1_000_000;
IO.println("10.000 tareas terminadas en " + ms + " ms");
}
Diez mil tareas que esperan un segundo cada una terminan en poco más de un segundo. Ahora cambia newVirtualThreadPerTaskExecutor() por newFixedThreadPool(200) y vuelve a ejecutarlo: tardará unos 50 segundos. Mismo código, otro tipo de hilo.
Lo importante es que la lógica de negocio no cambió. cargarCliente, cargarPedidos y cargarPrecios siguen siendo llamadas secuenciales. En Spring Boot 3.2 o superior basta una línea en application.properties:
spring.threads.virtual.enabled=true
Y conservas todo lo que ya conoces: stack traces completos, try/catch normales, el depurador, JFR y librerías como JDBC, sin adaptaciones.
Lo que cambió entre Java 21 y Java 25
Si leíste sobre virtual threads en 2023, quizá te quedaste con algunas advertencias. Varias ya no aplican, y llegaron herramientas nuevas que conviene conocer desde el principio.
El pinning con synchronized quedó resuelto
En Java 21, un virtual thread que se bloqueaba dentro de un bloque synchronized quedaba anclado a su carrier y no podía liberarlo. Si suficientes hilos caían en esa situación, la aplicación se congelaba sin lanzar ningún error. Netflix documentó justo ese caso en servicios con Spring Boot y Tomcat.
JEP 491, incluido en JDK 24, reimplementó synchronized para que el virtual thread pueda desmontarse también ahí. Quedan algunos casos residuales: código nativo, inicialización de clases y lectura de archivos locales en Linux. Para detectarlos, activa el evento de JFR jdk.VirtualThreadPinned en tus pruebas de carga.
Scoped Values ya es definitivo
JEP 506 cerró en Java 25 una alternativa moderna a ThreadLocal para compartir datos de contexto, como el usuario o el identificador de traza de una petición. Un scoped value es inmutable, vive mientras dura un bloque de código y lo heredan automáticamente las subtareas que lances dentro de ese bloque.
static final ScopedValue<String> USUARIO = ScopedValue.newInstance();
void atender(Peticion peticion) {
ScopedValue.where(USUARIO, peticion.usuario())
.run(() -> procesar(peticion));
}
void procesar(Peticion peticion) {
IO.println("Atendiendo a " + USUARIO.get());
}
No hay set(), no hay remove() que olvidar en un finally. Cuando el bloque termina, el valor desaparece.
Concurrencia estructurada, en quinta preview
JEP 505 permite lanzar varias subtareas en paralelo dentro de un bloque que define cuánto viven. Si una falla, las demás se cancelan y nada se queda huérfano:
Perfil cargarPerfil(UserId id) throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
Subtask<Usuario> usuario = scope.fork(() -> usuarios.buscar(id));
Subtask<List<Pedido>> pedidos = scope.fork(() -> pedidosRepo.porUsuario(id));
scope.join(); // espera a ambas; si una falla, cancela la otra y lanza la excepción
return new Perfil(usuario.get(), pedidos.get());
}
}
Cada fork corre en su propio virtual thread, y el usuario que guardaste en el scoped value llega a ambas subtareas sin hacer nada extra. Como la API sigue en preview, necesitas --enable-preview para compilar y ejecutar, y puede cambiar entre versiones; en Java 27 ya va por la séptima preview. Vale la pena aprenderla, pero todavía no la expondría en una API pública que otros equipos consuman.
Kotlin coroutines: la espera dentro del lenguaje
Kotlin resolvió el mismo problema años antes que Loom, desde otro ángulo. No abarató el hilo: añadió la suspensión al lenguaje. Una función suspend puede pausarse mientras espera y devolver el hilo que la ejecutaba.
suspend fun cargarCliente(id: Long): Cliente =
client.get("/clientes/$id")
El compilador transforma esa función en una máquina de estados que sabe retomar la ejecución donde quedó. El código se lee secuencial, aunque por debajo sea asíncrono.
La diferencia de fondo con los virtual threads está en dónde queda la complejidad. Con virtual threads la absorbe la JVM. Con corrutinas forma parte de tu código: scopes, dispatchers, contexto y reglas de cancelación que todo el equipo debe entender.
Hay un par de trampas que conviene tener presentes. Marcar con suspend una función que llama a Thread.sleep() no la vuelve asíncrona; el hilo sigue bloqueado, y lo correcto es delay(). Además, suspend "colorea" las funciones: una función normal no puede llamar a una suspendida sin un puente, y esa marca se propaga por toda la arquitectura.
A cambio, Kotlin ofrece desde hace años, y en producción, lo que Java todavía tiene en preview: una concurrencia estructurada madura.
suspend fun construirPagina() = coroutineScope {
val cliente = async { cargarCliente() }
val ofertas = async { cargarOfertas() }
Pagina(cliente.await(), ofertas.await())
}
Compara este bloque con el de StructuredTaskScope: la idea es la misma, y Kotlin la tiene estable. Un detalle que confunde a muchos: Kotlin en la JVM también puede usar virtual threads. Elegir el lenguaje y elegir el modelo de concurrencia son dos decisiones distintas.
Promesas funcionales: componer en vez de esperar
Hay una tercera vía, menos conocida. La librería Pragmatica Lite ofrece Promise<T>, un valor que tarde o temprano será un éxito o un fallo, implementado sobre virtual threads. Si ya usas Optional o streams, el estilo te va a resultar familiar: encadenas transformaciones que se aplican cuando el valor está listo.
Promise<Factura> facturar(UserId id) {
return usuarios.buscar(id)
.flatMap(pedidos::porUsuario)
.flatMap(facturas::crear)
.onSuccess(correo::enviarFactura)
.onFailure(causa -> log.error("Falló la factura", causa));
}
Aquí map() transforma el valor, flatMap() encadena otra operación que puede fallar, y onSuccess() u onFailure() ejecutan efectos secundarios sin alterar el resultado. La librería incluye patrones listos para usar. Promise.all() combina resultados de tipos distintos y Promise.any() se queda con la primera respuesta exitosa. Para los fallos tienes recover(), que devuelve un valor alternativo, y las clases Retry y CircuitBreaker.
El autor recomienda mantener un solo nivel de abstracción por método. Es buen consejo para cualquier código funcional: si una lambda necesita más de una línea, probablemente merece un nombre propio.
Lo que los virtual threads no resuelven por ti
Los hilos ahora son baratos. Tu base de datos no.
Antes, el pool de 200 hilos hacía dos trabajos: ejecutar peticiones y, sin que nadie lo planeara, limitar cuántas llegaban a la vez a la base de datos. Quita ese pool y el cuello de botella se mueve a otro lugar. Un benchmark publicado en InfoQ en julio de 2026 lo muestra con claridad. Al activar virtual threads, el throughput subió 147 %, pero la tasa de errores se quedó igual, alrededor de 37 %, porque el pool de conexiones de HikariCP se saturaba en los dos casos.
La solución recomendada no es volver a meter los virtual threads en un pool. Es limitar el recurso escaso de forma explícita:
private final Semaphore permisos = new Semaphore(50);
Respuesta consultarProveedor() throws InterruptedException {
permisos.acquire();
try {
return proveedor.consultar();
} finally {
permisos.release();
}
}
Hay otras tres cosas que conviene saber antes de activar la propiedad en producción:
- Usar
ThreadLocalcomo caché deja de funcionar. Los virtual threads no se reutilizan, así que unThreadLocal.withInitial()que antes creaba un objeto por hilo ahora lo crea en cada petición. En el mismo benchmark, una caché deSimpleDateFormatpasó de 200 inicializaciones a más de 440.000. No lanza ningún error; solo genera basura. Para datos de contexto, usa Scoped Values. - El cálculo intensivo no mejora. Los virtual threads ayudan mientras esperas I/O. Si tu tarea consume CPU, crear más hilos no la acelera.
- No reemplazan la backpressure. Si haces streaming, SSE o WebSockets y necesitas que el productor frene cuando el consumidor se atrasa, WebFlux sigue siendo la herramienta indicada. Ten en cuenta también que la propiedad de Spring afecta a Tomcat y Jetty, no a Netty.
¿Cuál elijo?
Para un servicio nuevo en Java que habla con una base de datos y con otros servicios, mi punto de partida hoy son los virtual threads sobre Java 25. Las demás opciones necesitan un motivo concreto.
| Enfoque | Estilo del código | Lo mejor | Lo que cuesta | Úsalo cuando |
|---|---|---|---|---|
| Virtual threads (Java 25) | Secuencial | Escala sin reescribir; diagnósticos de siempre | Hay que acotar los recursos a mano | Empiezas un servicio nuevo de tipo petición/respuesta |
| Kotlin coroutines | Secuencial con suspend | Concurrencia estructurada y cancelación maduras | Más conceptos; funciones coloreadas | Usas Ktor o tu equipo ya trabaja con corrutinas |
| Reactivo (Reactor, WebFlux) | Cadenas de operadores | Backpressure y streaming | Difícil de leer y depurar | Necesitas streaming real o backpressure |
| Promesas funcionales | Cadenas con map/flatMap | all, any, retry y circuit breaker incluidos | Estilo poco difundido en Java | Tu equipo prefiere el estilo funcional |
| Hilos de plataforma con pool | Secuencial | Simple y conocido | Se agota con mucha espera de I/O | La carga es baja o el trabajo es de CPU |
El framework también pesa. Spring Boot soporta bien tanto virtual threads como corrutinas. Quarkus, Micronaut y Vert.x permiten exponer virtual threads aunque por dentro sean reactivos, y Ktor está construido sobre corrutinas.
Y si tienes una aplicación con corrutinas o con Reactor que funciona bien, no la reescribas solo porque existe algo nuevo. Que funcione y que el equipo la entienda es un argumento de peso.
Para cerrar
Durante casi veinte años, escalar en la JVM obligaba a elegir entre código fácil de leer y código que aguantara la carga. Los frameworks se volvieron cada vez más ingeniosos para evitar los hilos.
Los virtual threads cambiaron la pregunta. Si el hilo es barato, podemos volver a usar uno por petición, y Java 25 quitó la mayoría de las asperezas de la primera versión. Lo que queda es trabajo de diseño: saber cuántas conexiones aguanta tu base de datos, revisar tus ThreadLocal y medir antes de llegar a producción.
Si tuviera que resumir el artículo en una idea, sería esta. Escribe código secuencial que un compañero entienda a la primera, deja que la JVM se encargue de esperar y pon límites explícitos a lo que de verdad es escaso. Cuando algo falle a las 2 de la mañana, vas a agradecer tener un stack trace que se lee de arriba abajo.
Fuentes
- Asynchronous Processing in Java with Promises (Pragmatica Lite).
- Daniel Witkowski, Kotlin Coroutines vs Java Virtual Threads: Which One Is Easier to Live With?, 12 de octubre de 2026.
- Java's Thread Problem, Solved, 29 de septiembre de 2026.
- Sandeep Bharadwaj, Virtual Threads after JDK 24: What Changed for Production Java, InfoQ, 31 de julio de 2026.
- JEP 491: Synchronize Virtual Threads without Pinning
- JEP 505: Structured Concurrency (Fifth Preview)
- JEP 506: Scoped Values
- Oracle, Structured Concurrency, Java SE 25 Core Libraries