Los temas anteriores han convivido con la concurrencia sin explicarla: Tomcat reparte las peticiones entre un pool de hilos y el MDC (tema 09) aprovecha que cada petición vive en un hilo; el procesamiento asíncrono de eventos (tema 08) salta a otro hilo. Este tema explica qué sostiene esa concurrencia y los tres modelos disponibles hoy: el tradicional con hilos de plataforma, la programación reactiva y los hilos virtuales de Project Loom.
Un servidor web recibe peticiones simultáneas: mientras atiende una, llegan otras. Concurrencia significa que muchas están en marcha a la vez; paralelismo, que se ejecutan a la vez, solo posible con varios núcleos.
El tipo de trabajo decide el problema:
| Trabajo | Característica | Qué limita el rendimiento |
|---|---|---|
| CPU-bound | Procesa datos: cálculo, transformación | Los núcleos disponibles |
| E/S bloqueante | Espera resultados externos: BD, HTTP, ficheros | Nada: mientras espera, el hilo queda ocioso |
En una API típica, la mayor parte del tiempo de una petición es espera oculta. Toda la concurrencia del servidor se juega ahí: no en la CPU, que sobra, sino en no despilfarrar hilos para esperar.
Cada petición se atiende en un hilo de plataforma — un hilo del sistema operativo — de principio a fin. Crear hilos es caro, así que el servidor mantiene un pool de tamaño fijo y reutiliza sus hilos; en Tomcat el máximo por defecto es 200.
El código es secuencial y directo; la llamada bloqueante devuelve cuando tiene el dato:
@GetMapping("/pedidos/{id}")
ResponseEntity<PedidoResponse> find(@PathVariable Long id) {
// El hilo del pool queda atado durante toda la espera
var pedido = pedidoService.findById(id);
return ResponseEntity.ok(mapper.toResponse(pedido));
}
Un hilo de plataforma lo gestiona el sistema operativo y sale caro:
El pool es pequeño y el escalado queda atado a su tamaño: con 200 hilos y peticiones que esperan 50 ms de base de datos, el servidor atiende unos 4.000 por segundo; si la espera sube a 1 s — una llamada lenta a un servicio externo —, caen a 200. La CPU no se ha inquietado en ningún caso.
Como el hilo acompaña a la petición de principio a fin, es tentador guardar el estado en él. Dos errores habituales muestran por qué no:
static lo comparten todos los hilos: cualquier petición sobrescribe el valor de las demás y puede leer el de otro usuario.ThreadLocal lo aísla dentro del hilo… hasta que el hilo vuelve al pool: sin limpieza, la petición siguiente hereda el dato del usuario anterior.
class CurrentUser {
private static final ThreadLocal<String> USER = new ThreadLocal<>();
static void set(String user) { USER.set(user); }
static String get() { return USER.get(); }
static void clear() { USER.remove(); } // el hilo se reutiliza
}
SecurityContext en Spring Security (tema 07) y el MDC (tema 09) funcionan exactamente así: contexto ligado al hilo y limpieza garantizada al terminar la petición.
La programación reactiva ataca el problema por el otro extremo: si esperar es lo caro, nadie espera. Quien lanza una E/S no queda bloqueado: registra qué hacer con el resultado y el hilo pasa a otra conexión. Un bucle de eventos (*event loop*) con muy pocos hilos procesa lo que está listo y recoge los resultados cuando llegan.
Las operaciones se componen en flujos asíncronos: transformaciones que se aplican a los datos a medida que estos aparecen. La especificación Reactive Streams fija el contrato (Publisher y Subscriber) y añade el backpressure: el consumidor pide lo que puede procesar y el productor no lo desborda.
| Pieza | Papel |
|---|---|
| Project Reactor | Implementación de Reactive Streams: Mono (0 o 1 elemento) y Flux (0..n) |
| Spring WebFlux | Stack web no bloqueante sobre Netty; alternativa al MVC de servlets |
| R2DBC | Acceso relacional no bloqueante; JDBC y JPA son bloqueantes |
Flux<PedidoResponse> abiertos() {
return repository.findByEstado(ABIERTO) // emite cuando llegan: el hilo no queda atado
.take(10)
.map(mapper::toResponse);
}
La reactiva compensa cuando hay miles de conexiones simultáneas y lentas: streaming (SSE), tiempo real, flujos largos. En una API CRUD convencional suele ser un coste sin beneficio.
Project Loom (hilos virtuales definitivos en Java 21) aporta la tercera vía: cambiar el runtime sin cambiar el estilo. Un hilo virtual lo gestiona la JVM, no el sistema operativo: cuesta kilobytes y pueden crearse millones. Cuando su código bloquea en E/S, la JVM lo desmonta de su hilo portador — un hilo de plataforma que solo ejecuta lo que está listo — y este pasa a otro hilo virtual; cuando el dato llega, el hilo virtual se monta de nuevo en cualquier portador libre.
El código resulta idéntico al tradicional: llamadas bloqueantes ordinarias, pilas legibles y las herramientas de siempre.
static void main() {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var id = 1; id <= 10_000; id++) {
executor.submit(() -> {
var pedido = client.findById(id); // E/S bloqueante, como siempre
return IO.println(pedido + " en " + Thread.currentThread());
});
}
} // close() espera a que terminen las tareas
}
En Spring Boot, la propiedad convierte los hilos de Tomcat y el executor de tareas (los @Async del tema 08) en hilos virtuales. La aplicación no cambia: mismo MVC, mismas anotaciones, mismo ecosistema bloqueante — ahora cada espera libera a su portador.
spring:
threads:
virtual:
enabled: true
synchronized. Con suficientes hilos atados, los portadores se agotan; solo la medición lo detecta.ThreadLocal a escala: millones de hilos con contexto propio multiplican la memoria; para el contexto ligado al hilo, JDK 25 incorpora de forma definitiva ScopedValue.| Tradicional | Reactiva | Loom | |
|---|---|---|---|
| Hilos | De plataforma, pool acotado | Muy pocos, bucle de eventos | Virtuales, uno por tarea |
| La espera de E/S | El hilo queda ocupado | Nadie espera | El portador queda libre |
| Escalado con E/S | Limitado por el pool | Alto | Alto |
| Estilo del código | Secuencial, directo | Flujos y callbacks | Secuencial, directo |
| Ecosistema | El estándar: JDBC, JPA, MVC | Exige stack no bloqueante completo | El estándar funciona sin cambios |
| Riesgo | Cola en el pool bajo carga | Complejidad y trazas | Pinning puntual, ThreadLocal a escala |
| Encaja en | CPU-bound o carga baja | Miles de conexiones simultáneas, streaming | La API típica con E/S |
Reactiva y Loom resuelven el mismo problema desde extremos opuestos: la reactiva cambia el estilo del código para no bloquear; Loom cambia el runtime para que bloquear deje de importar.
La concurrencia pertenece a la plataforma de ejecución, no al negocio: el dominio se escribe bloqueante y secuencial — el estilo más legible y testeable — y la infraestructura elige cómo ejecutarlo:
ThreadLocal sin limpiar se diluye; pero su coste de memoria crece con el número de hilos. Y en el procesamiento asíncrono de eventos (tema 08) sigue vigente el límite del tema 09: el contexto ligado al hilo no viaja solo.
| Qué probar | Cómo |
|---|---|
| Lógica de negocio | Pruebas unitarias como siempre: código secuencial, sin hilos y sin levantar Spring |
| Componente con estado interno | Test de concurrencia: N tareas con hilos virtuales y afirmar la invariante final |
| Comportamiento bajo carga | Fuera de la suite: throughput e hilos del proceso con JMeter y VisualVM |
@Test
void concurrentIncrementsAreNotLost() {
var counter = new AtomicCounter();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var i = 0; i < 1_000; i++) {
executor.submit(counter::increment);
}
} // close() espera las 1.000 tareas: sin sleeps ni esperas arbitrarias
assertEquals(1_000, counter.value());
}
El contador usa AtomicInteger y la prueba valida la decisión bajo 1.000 tareas concurrentes. El truco está en el close(): determinista, sin depender del timing — las aserciones sobre nombres de hilo o tiempos concretos convierten la prueba en una ruleta.