Si llevas tiempo programando en Java, seguro que has oído la queja: Java no escala bien con muchas conexiones concurrentes. Y durante años fue cierta. El motivo está en cómo funciona un thread clásico de Java, que se llama platform thread.
Cada platform thread mapea directamente a un thread del sistema operativo. El SO asigna un stack de entre 1 y 2 MB a cada uno. Si tienes un pool de 200 threads, ya estás consumiendo entre 200 y 400 MB solo en stacks, antes de que el código haga nada útil. Y lo peor no es la memoria: es que cuando ese thread hace I/O (una query a la base de datos, una llamada HTTP a otra API), se queda bloqueado esperando. El thread está parado, ocupando memoria y recursos del SO, sin hacer nada.
La solución clásica era añadir más threads al pool. Pero el SO no puede gestionar eficientemente miles de threads a la vez, así que el límite práctico ronda los pocos miles. A partir de ahí, el rendimiento se degrada. Por eso surgieron soluciones como la programación reactiva: no bloquees threads, encadena callbacks. Funciona, pero el código se vuelve complicado de leer, de debuggear y de mantener.
Java 21 resuelve esto de raíz con los virtual threads, que son la propuesta principal de Project Loom (JEP 444).
Qué son los virtual threads y por qué cambian las cosas
Un virtual thread no es un thread del SO. Es un thread gestionado directamente por la JVM. Esto tiene varias consecuencias importantes:
- Crearlos cuesta microsegundos, no milisegundos.
- Consumen unos 100 bytes de heap, sin un stack fijo asignado de antemano.
- Puedes crear millones de ellos sin que el sistema se caiga.
La JVM gestiona internamente un número pequeño de platform threads, que se llaman carrier threads. Cuando un virtual thread necesita esperar (por I/O, por ejemplo), la JVM lo aparta del carrier thread, libera ese carrier para otro virtual thread, y cuando la operación termina, reanuda el virtual thread donde lo dejó. Todo esto pasa de forma transparente para tu código.
// Crear un millón de virtual threads: perfectamente válido en Java 21
for (int i = 0; i < 1_000_000; i++) {
Thread.ofVirtual().start(() -> hacerTrabajo());
}
Con platform threads esto simplemente no es viable. Con virtual threads, sí.
Cómo crear virtual threads en Java 21
La API es muy sencilla y no rompe nada de lo que ya tenías. Tienes varias formas según el contexto:
Directamente con Thread
// Crear y arrancar un virtual thread
Thread vt = Thread.ofVirtual().start(() -> System.out.println("Hola desde un virtual thread"));
// Con nombre, útil para debugging
Thread.ofVirtual().name("mi-vt").start(() -> procesarPeticion());
// Atajo disponible desde Java 21
Thread.startVirtualThread(() -> hacerAlgo());
Con un ExecutorService
// Executor que crea un virtual thread por tarea
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> buscarEnBaseDeDatos(id));
executor.submit(() -> llamarAPIExterna(url));
} // el try-with-resources espera a que terminen todas
Este es el patrón más habitual si migras desde un ExecutorService clásico: simplemente cambias cómo creas el executor y el resto del código no cambia.
El bloqueo es transparente: esa es la clave
Lo que hace especiales a los virtual threads no es solo que sean baratos de crear. Es que el código síncrono de toda la vida funciona sin cambios y sin bloquear carrier threads.
// Esto NO bloquea un platform thread en Java 21
// La JVM gestiona la espera internamente
Thread.ofVirtual().start(() -> {
Thread.sleep(1000); // espera sin ocupar un carrier thread
String resultado = jdbc.query("SELECT ...", ...); // I/O de BBDD, igual
System.out.println(resultado);
});
Lo mismo aplica a sockets, ficheros, llamadas JDBC y la mayoría de operaciones de I/O de la biblioteca estándar. El código parece síncrono porque lo es: tú escribes Thread.sleep() o haces una query normal, y la JVM se encarga de no desperdiciar un carrier thread mientras espera.
El pinning: el único punto a vigilar
Hay un caso donde los virtual threads sí se quedan fijados al carrier thread sin poder liberarlo: cuando el código dentro del virtual thread usa un bloque synchronized y dentro de ese bloque hace algo que bloquea. Esto se llama pinning.
En Java 21 la solución es sencilla: en secciones críticas donde pueda haber bloqueo, usa ReentrantLock en lugar de synchronized. El comportamiento es equivalente pero no causa pinning.
// Problemático si hay I/O dentro del synchronized
synchronized (lock) {
resultado = hacerQueryLenta(); // puede causar pinning
}
// Mejor con ReentrantLock
lock.lock();
try {
resultado = hacerQueryLenta(); // sin pinning
} finally {
lock.unlock();
}
Virtual threads vs programación reactiva
La programación reactiva (Spring WebFlux, Project Reactor, RxJava) surgió para resolver exactamente el mismo problema que los virtual threads: evitar que el I/O bloquee threads y así escalar con menos recursos. Funciona, pero a un coste de complejidad que cualquiera que haya depurado un stack trace de Reactor conoce bien.
Con virtual threads puedes tener el mismo rendimiento en aplicaciones I/O-bound con código síncrono normal. Sin Mono, sin Flux, sin flatMap encadenados, sin cambiar el modelo mental de ejecución.
Esto no significa que WebFlux sea malo ni que debas migrar lo que ya tienes. Si tienes una aplicación reactiva que funciona, no hay motivo urgente para cambiarla. Pero para proyectos nuevos, los virtual threads son la opción más directa si el cuello de botella está en I/O.
Puedes ver más sobre esto en el artículo sobre concurrencia en Java: condiciones de carrera y cómo evitarlas.
Spring Boot y virtual threads: una línea de configuración
Si usas Spring Boot 3.2 o superior, activar los virtual threads es trivial. Añade esto a tu application.properties:
spring.threads.virtual.enabled=true
Con esa línea, Spring Boot configura automáticamente Tomcat, el executor de @Async y el scheduler de tareas para que usen virtual threads. No hay que tocar los controladores, los servicios ni las queries. El código HTTP síncrono de siempre pasa a tener concurrencia masiva sin más cambios.
Para bases de datos, el acceso concurrente sin bloqueos con virtual threads en Redis y Java también mejora de forma directa cuando se combinan con esta configuración.
StructuredTaskScope: el siguiente paso (preview)
Java 21 incluye en preview la concurrencia estructurada con StructuredTaskScope (JEP 453). La idea es gestionar tareas concurrentes como si fueran un bloque de código: cuando el bloque termina, todas las tareas han terminado o se han cancelado.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var usuario = scope.fork(() -> buscarUsuario(id));
var pedidos = scope.fork(() -> buscarPedidos(id));
scope.join() // espera a que terminen las dos
.throwIfFailed(); // si una falla, lanza excepción
// las dos han terminado con éxito
mostrar(usuario.get(), pedidos.get());
}
Si buscarUsuario() lanza una excepción, el scope cancela automáticamente buscarPedidos(). No hace falta gestionar el ciclo de vida manualmente. Todavía está en preview, pero la API ya es muy estable y es probable que llegue a GA en Java 23 o 24.
Cuándo los virtual threads no ayudan
Los virtual threads no son la solución a todo. Hay dos casos donde no van a mejorar nada:
Trabajo CPU-bound. Si tu código procesa imágenes, hace criptografía o entrena modelos, el cuello de botella es la CPU, no el I/O. Meter más virtual threads no acelera nada porque el problema no es que estén esperando: es que están calculando. Para eso necesitas paralelismo real con múltiples cores, usando ForkJoinPool o streams paralelos.
Código con pinning intensivo. Si usas librerías con bloques synchronized largos que no puedes cambiar (JNI, drivers viejos), los virtual threads pueden quedarse fijados al carrier thread sin poder liberarlo. En ese caso el beneficio se reduce mucho.
La regla general: si el tiempo de espera lo domina el I/O (base de datos, APIs externas, ficheros, red), los virtual threads son la herramienta adecuada. Si domina el cálculo, el paralelismo con ForkJoinPool es lo que necesitas.
Vale la pena el cambio
Los virtual threads no son un parche ni una característica experimental. Llevan años en desarrollo dentro de OpenJDK, llegaron como GA en Java 21 (una versión LTS) y la adopción en librerías y frameworks es amplia. Spring Boot, Quarkus, Micronaut y los principales drivers JDBC ya los soportan.
Lo más valioso no es el rendimiento en sí, aunque también mejora. Es que puedes escribir código síncrono legible, con manejo de errores normal, con stack traces que se entienden, y escalar a decenas de miles de conexiones concurrentes sin tocar la arquitectura. Para la mayoría de aplicaciones web que hacen I/O intensivo, eso es suficiente para dejar de quejarse de la concurrencia en Java.
Imagen: Pexels / Leonid Altman
