11 - Concurrencia: bloqueante, reactiva y Loom
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.
1. El problema de la concurrencia
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.
2. Programación tradicional: un hilo por petición
2.1 El modelo
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));
}
2.2 El coste de un hilo de plataforma
Un hilo de plataforma lo gestiona el sistema operativo y sale caro:
- Reserva memoria para su pila: del orden de un megabyte por hilo.
- El SO decide los cambios de contexto, que tienen coste.
- Miles de hilos vivos son inviables.
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.
2.3 El estado y el hilo
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:
- Un campo
staticlo comparten todos los hilos: cualquier petición sobrescribe el valor de las demás y puede leer el de otro usuario. ThreadLocallo 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.
3. Programación reactiva: no bloquear
3.1 El modelo
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.
3.2 En Java y Spring
| 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.
4. Project Loom: hilos virtuales
4.1 El modelo
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.
4.2 En Java y Spring
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
4.3 Los límites
- Pinning: hay casos en que el hilo virtual no se desmonta y retiene al portador mientras espera — las llamadas a código nativo y, hasta JDK 24, los bloques
synchronized. Con suficientes hilos atados, los portadores se agotan; solo la medición lo detecta. - CPU-bound: los hilos virtuales no añaden núcleos; el cálculo intensivo escala igual que antes.
ThreadLocala escala: millones de hilos con contexto propio multiplican la memoria; para el contexto ligado al hilo, JDK 25 incorpora de forma definitiva ScopedValue.- Pools: un hilo virtual por tarea; reciclar hilos virtuales deshace la ventaja.
5. Los tres modelos, cara a cara
| 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.
6. Qué significa para la arquitectura del curso
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:
- Por defecto, el stack de servlets con sus hilos de plataforma: la carga de una API de curso no lo desborda.
- Hilos virtuales cuando la carga crece: activar la propiedad, medir y ajustar; el código no cambia.
- Reactiva solo con una necesidad concreta que la justifique: streaming o un gran número de conexiones simultáneas.
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.
7. Pruebas
| 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.