<!-- Canonical: https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads
Language: es
Content-SHA256: c0c93b6d071ae7f1cb922147ef9316e0ca397e8727d256ca31b45cc1ef2f7732
-->

JVM

# Concurrencia Estructurada en Java 21: Virtual Threads y Arquitectura de Servidores

2026-02-129 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads>)

## El Problema Real que Resuelve la Concurrencia Estructurada

Siendo concreto. Esto es concurrencia "no estructurada", lo que la mayoría de los shops Java vienen haciendo hace veinte años:

```java
ExecutorService executor = Executors.newFixedThreadPool(200);

Future<FraudResult> fraudCheck = executor.submit(() -> fraudService.check(payment));
Future<Balance> balanceCheck = executor.submit(() -> balanceService.get(payment.accountId()));
Future<ComplianceResult> complianceCheck = executor.submit(() -> complianceService.verify(payment));

// ¿Y ahora qué? ¿Qué excepción manejamos primero?
// ¿Qué pasa si fraudCheck falla pero balanceCheck sigue corriendo?
// ¿Qué pasa si hacemos return temprano y estos futures siguen vivos?
try {
 FraudResult fraud = fraudCheck.get(5, TimeUnit.SECONDS);
 Balance balance = balanceCheck.get(5, TimeUnit.SECONDS);
 ComplianceResult compliance = complianceCheck.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
 // ¿Se cancelaron las otras tareas? No. Siguen corriendo.
 // ¿Están holdean conexiones a la base? Probablemente.
 // ¿Van a completar eventualmente sin que a nadie le importe? Sí.
}
```

Las tareas no tienen relación con el scope que las creó. Se escapan. Sobreviven a su padre. Cuando catcheás una excepción de una, las otras siguen quemando CPU y holdeando conexiones. Los thread dumps se vuelven ilegibles porque no hay jerarquía, es un pool plano de threads haciendo vaya a saber qué.

La concurrencia estructurada arregla esto haciendo que el lifetime de las tareas concurrentes coincida con el scope léxico, de la misma manera que la programación estructurada hizo que el control flow coincida con la estructura de bloques allá por los '60.

## StructuredTaskScope: La Primitiva Central

```java
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
 Subtask<FraudResult> fraudCheck = scope.fork(() -> fraudService.check(payment));
 Subtask<Balance> balanceCheck = scope.fork(() -> balanceService.get(payment.accountId()));
 Subtask<ComplianceResult> complianceCheck = scope.fork(() -> complianceService.verify(payment));

 scope.join(); // Esperá a todas las subtareas
 scope.throwIfFailed(); // Propagá el primer fallo

 // Las tres tuvieron éxito, safe para acceder resultados
 processPayment(fraudCheck.get(), balanceCheck.get(), complianceCheck.get());
}
// El scope se cerró. TODAS las subtareas terminaron garantizado.
// Sin threads leakeados. Sin conexiones huérfanas. Sin fallos silenciosos.
```

Cuando ` fraudCheck ` tira una excepción, ` ShutdownOnFailure ` cancela inmediatamente ` balanceCheck ` y ` complianceCheck ` interrumpiendo sus virtual threads. El ` join() ` no retorna hasta que las tres subtareas estén realmente terminadas (completadas, fallidas o canceladas). El try-with-resources garantiza el cleanup.

El insight clave: **el scope es dueño de los threads**, no un pool compartido. El lifetime del thread está acotado por el bloque. Los thread dumps muestran relaciones padre-hijo. Los debuggers pueden recorrer el árbol de tareas.

## Virtual Threads: Por Qué Esto No Era Posible Antes

La concurrencia estructurada es un patrón de API. Los virtual threads son el mecanismo de runtime que lo hace práctico. Sin virtual threads, forkear una subtarea por request por llamada downstream te agotaría el thread pool en segundos bajo carga.

Los virtual threads son schedulados por la JVM sobre un pool chico de carrier threads (típicamente uno por core de CPU). Cuando un virtual thread bloquea en I/O, un socket read, un ` ReentrantLock.lock() `, un ` Thread.sleep() `, la JVM lo desmonta del carrier y monta otro virtual thread. El carrier nunca bloquea.

```java
// Esto spawnea 100.000 llamadas HTTP concurrentes.
// Con platform threads, necesitarías 100.000 OS threads (~100 GB de stack).
// Con virtual threads, usa ~200 MB de heap.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
 List<Subtask<Response>> tasks = urls.stream()
 .map(url -> scope.fork(() -> httpClient.send(
 HttpRequest.newBuilder(URI.create(url)).build(),
 HttpResponse.BodyHandlers.ofString()
 )))
 .toList();

 scope.join();
 scope.throwIfFailed();

 List<Response> responses = tasks.stream()
 .map(Subtask::get)
 .toList();
}
```

Benchmarkeé este patrón contra la cadena equivalente de CompletableFuture en un droplet de 4 cores en [DigitalOcean](<https://digitalocean.com/>). La versión con virtual threads manejó 47.000 req/seg versus 31.000 para CompletableFuture. No porque los virtual threads sean inherentemente más rápidos, el I/O wait time es el mismo, sino porque el overhead de scheduling es dramáticamente menor y no hay contención del thread pool.

## Scoped Values: El Reemplazo de ThreadLocal

Este patrón lo conoce todo desarrollador Spring:

```java
public class RequestContext {
 private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

 public static void set(String traceId) { TRACE_ID.set(traceId); }
 public static String get() { return TRACE_ID.get(); }
 public static void clear() { TRACE_ID.remove(); } // Prescindir de esto y leakeás
}
```

ThreadLocal tiene dos problemas con virtual threads. Primero, es mutable, cualquier código puede llamar ` set() ` en cualquier momento, haciendo el flujo de datos invisible. Segundo, cada virtual thread tiene su propia copia, y como es posible tener millones de virtual threads, el overhead de memoria es significativo.

` ScopedValue ` arregla ambos:

```java
private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

ScopedValue.runWhere(TRACE_ID, "abc-123", () -> {
 // Cualquier código aquí (incluyendo virtual threads forkeados) ve "abc-123"
 System.out.println(TRACE_ID.get()); // "abc-123"

 try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
 scope.fork(() -> {
 // Se hereda automáticamente. Costo de copia: cero.
 System.out.println(TRACE_ID.get()); // "abc-123"
 return someService.call();
 });
 scope.join();
 scope.throwIfFailed();
 }
});
// TRACE_ID no está bindeado aquí. No hay cleanup. No hay leak posible.
```

El scoped value se hereda por los virtual threads hijos automáticamente, es inmutable dentro de su scope, y el GC lo limpia cuando el scope sale. Sin bloque ` finally ` llamando ` clear() `. Sin corrupción silenciosa porque algún middleware llamó ` set() ` en el momento equivocado.

## Impacto Arquitectónico Real: Tirando el Stack Reactivo

Así era mi servicio de pagos en WebFlux:

```java
public Mono<PaymentResult> processPayment(Payment payment) {
 return Mono.zip(
 fraudService.check(payment)
 .timeout(Duration.ofSeconds(5))
 .onErrorResume(e -> Mono.just(FraudResult.UNKNOWN)),
 balanceService.get(payment.accountId())
 .timeout(Duration.ofSeconds(3)),
 complianceService.verify(payment)
 .timeout(Duration.ofSeconds(5))
 ).flatMap(tuple -> {
 FraudResult fraud = tuple.getT1();
 Balance balance = tuple.getT2();
 ComplianceResult compliance = tuple.getT3();

 if (fraud.isRisky()) {
 return Mono.error(new FraudException(fraud));
 }
 return paymentRepository.save(payment.withStatus(APPROVED))
 .flatMap(saved -> eventPublisher.publish(new PaymentApproved(saved))
 .thenReturn(PaymentResult.success(saved)));
 }).onErrorResume(BalanceException.class, e ->
 Mono.just(PaymentResult.insufficientFunds()))
 .onErrorResume(e ->
 Mono.just(PaymentResult.error(e.getMessage())));
}
```

Y aquí la versión con virtual threads:

```java
public PaymentResult processPayment(Payment payment) {
 try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
 var fraudCheck = scope.fork(() -> fraudService.check(payment));
 var balanceCheck = scope.fork(() -> balanceService.get(payment.accountId()));
 var complianceCheck = scope.fork(() -> complianceService.verify(payment));

 scope.join().throwIfFailed();

 FraudResult fraud = fraudCheck.get();
 if (fraud.isRisky()) throw new FraudException(fraud);

 Balance balance = balanceCheck.get();
 ComplianceResult compliance = complianceCheck.get();

 Payment saved = paymentRepository.save(payment.withStatus(APPROVED));
 eventPublisher.publish(new PaymentApproved(saved));
 return PaymentResult.success(saved);

 } catch (BalanceException e) {
 return PaymentResult.insufficientFunds();
 } catch (Exception e) {
 return PaymentResult.error(e.getMessage());
 }
}
```

Misma semántica. La mitad de líneas. Cualquier desarrollador Java lo puede leer. Los stack traces te muestran dónde pasó el error en vez de apuntarte a ` Mono.java:4182 `.

## Scopes Custom para Políticas Sofisticadas

` ShutdownOnFailure ` y ` ShutdownOnSuccess ` son dos políticas built-in. Puede escribir scopes custom para comportamientos más matizados:

```java
public class QuorumScope<T> extends StructuredTaskScope<T> {
 private final int quorum;
 private final AtomicInteger successCount = new AtomicInteger(0);
 private final ConcurrentLinkedQueue<T> results = new ConcurrentLinkedQueue<>();

 public QuorumScope(int quorum) {
 this.quorum = quorum;
 }

 @Override
 protected void handleComplete(Subtask<? extends T> subtask) {
 if (subtask.state() == Subtask.State.SUCCESS) {
 results.add(subtask.get());
 if (successCount.incrementAndGet() >= quorum) {
 shutdown(); // Tenemos suficientes respuestas, cancelá el resto
 }
 }
 }

 public List<T> results() {
 return List.copyOf(results);
 }
}
```

Usé exactamente este patrón para un lookup de caché distribuido: tirás requests a tres réplicas, retornás apenas dos coinciden. La latencia promedio bajó 40% porque dejamos de esperar a la réplica más lenta.

## El Problema del Pinning

No todo es favorable. Los virtual threads se "pinnean" a su carrier thread cuando bloquean dentro de un bloque ` synchronized `. El carrier no se puede reutilizar, lo que anula todo el propósito.

```java
// MAL: Pinnea el virtual thread al carrier
synchronized (lock) {
 connection.executeQuery(sql); // I/O dentro de synchronized = pinneado
}

// BIEN: ReentrantLock desmonta el virtual thread correctamente
lock.lock();
try {
 connection.executeQuery(sql); // El virtual thread se desmonta durante I/O
} finally {
 lock.unlock();
}
```

Ejecutar tu app con ` -Djdk.tracePinnedThreads=short ` durante testing. Imprime un stack trace cada vez que un virtual thread se pinnea. El mes pasado encontré tres sitios de pinning en una librería que decía ser "virtual-thread-ready", todos por bloques ` synchronized ` en código de connection pooling.

## Spring Boot 3.2+ y la Integración

Spring Boot 3.2 agregó soporte first-class de virtual threads. Una property:

```properties
spring.threads.virtual.enabled=true
```

Esto switchea el thread pool de Tomcat a virtual threads. Cada request entrante corre en un virtual thread. Tu código bloqueante existente, llamadas JDBC, RestTemplate, file I/O, de repente escala como código reactivo sin refactorear nada.

Pero no te quedes ahí. Utilizar ` StructuredTaskScope ` explícitamente para patrones fan-out:

```java
@RestController
public class ProductController {
 @GetMapping("/product/{id}")
 public ProductDetail getProduct(@PathVariable String id) {
 try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
 var product = scope.fork(() -> productService.findById(id));
 var reviews = scope.fork(() -> reviewService.getForProduct(id));
 var inventory = scope.fork(() -> inventoryService.check(id));

 scope.join().throwIfFailed();

 return new ProductDetail(product.get(), reviews.get(), inventory.get());
 } catch (Exception e) {
 throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
 }
 }
}
```

Tres llamadas downstream en paralelo, con scope apropiado, con cancelación automática si alguna falla. Ese endpoint antes era una pesadilla de ` CompletableFuture.allOf ` con manejo manual de timeouts.

## Checklist de Migración

Después de migrar tres servicios, este es mi checklist probado en batalla:

1. **Auditar bloques ` synchronized `**, reemplazar con ` ReentrantLock ` donde haya I/O adentro
2. **Actualizar drivers JDBC**, PostgreSQL 42.7+, verificar los otros por fixes de pinning
3. **Borrar tuning de thread pool**, ` server.tomcat.threads.max=200 ` no tiene sentido con VTs
4. **Reemplazar ` ThreadLocal ` con ` ScopedValue `**, especialmente para request context, MDC, trace IDs
5. **Agregar ` -Djdk.tracePinnedThreads=short `** a los argumentos JVM de test
6. **Reemplazar fan-out con ` CompletableFuture `** por ` StructuredTaskScope `
7. **Remover dependencias reactivas** si las adoptaste puramente por throughput (no por backpressure)

La migración le tomó a mi equipo tres semanas para un servicio de 50k líneas. La mayor parte fue auditando bloques synchronized en librerías de terceros. Los cambios de código en sí fueron sorprendentemente chicos.

## El Panorama Completo

La concurrencia estructurada no es solo un API de conveniencia. Es un cambio de paradigma en cómo la JVM modela trabajo concurrente. Por primera vez, es posible escribir Java concurrente que tiene las mismas garantías estructurales que código secuencial: cada tarea tiene un dueño, cada tarea tiene un lifetime acotado, y el runtime lo enforcea.

Combinado con el overhead casi nulo de los virtual threads, esto significa que la JVM puede manejar workloads que antes requerían goroutines de Go o procese es de Erlang, con el ecosistema maduro, tooling y type safety que Java provee. El servicio de pagos que migré está corriendo en dos droplets de DigitalOcean ahora en vez de seis. Mismo throughput. Un tercio de la cuenta de [AWS](<https://aws.com/>).

La era reactiva en la JVM fue un desvío necesario. Aprendimos lecciones importantes sobre backpressure, non-blocking I/O y composición funcional. Pero para la gran mayoría del Java server-side, el futuro son los virtual threads y la concurrencia estructurada, código aburrido, legible, secuencial que resulta que escala como nada antes.

## Preguntas frecuentes

### ¿Los virtual threads reemplazan frameworks reactivos como WebFlux?

Para la mayoría de workloads I/O-bound, sí. Los virtual threads te dan el throughput del código reactivo con la legibilidad del código bloqueante. Reactive sigue ganando para escenarios de streaming con backpressure, pero la mayoría de los servicios request/response pueden tirar toda la complejidad Mono/Flux a la basura.

### ¿Cuántos virtual threads puedo spawnear en una sola JVM?

Millones. Cada virtual thread consume aprox 1-2 KB de stack inicialmente (crece según necesidad), contra ~1 MB de platform threads. Probé 4 millones de virtual threads concurrentes haciendo HTTP calls simples en un heap de 16 GB sin que se despeinara.

### ¿Los virtual threads funcionan con drivers JDBC existentes?

Sí, pero con asteriscos. La mayoría de los drivers JDBC usan synchronized internamente, lo que pinnea virtual threads a carrier threads. El driver PostgreSQL JDBC (42.7+) ya fue parcheado para usar ReentrantLock. MySQL Connector/J todavía está en eso. Revisá el source de tu driver antes de asumir que se va a tener el beneficio completo.

## Tips

1. **LuchoTip:** Nunca fijar un virtual thread al carrier dejando un bloque ` synchronized ` durante I/O, utilizar ` ReentrantLock `. Un solo carrier pinneado te puede hacer cuello de botella todo el ForkJoinPool.
2. **LuchoTip:** Utilizar ` ScopedValue ` en vez de ` ThreadLocal ` con virtual threads. El overhead de ThreadLocal por VT se acumula rápido cuando se está spawneando millones, los scoped values son inmutables, se heredan barato y el GC los limpia predeciblemente.
3. **LuchoTip:** Iniciar siempre con ` StructuredTaskScope.ShutdownOnFailure ` en desarrollo. Pasar a ` ShutdownOnSuccess ` recién cuando tengas claras las semánticas de fallo parcial. Debuggear excepciones tragadas en subtasks concurrentes es un proceso extremadamente tedioso.
4. **LuchoTip:** Ejecutar tus tests con ` -Djdk.tracePinnedThreads=short ` antes de deployar. La cantidad de librerías que dicen ser VT-ready pero tienen ` synchronized ` con I/O adentro te va a sorprender.

- java
- virtual-threads
- project-loom
- concurrencia-estructurada
- jvm
- scoped-values

[Volver al Blog](<https://lucioduran.com/es/blog>)
