{"schemaVersion":1,"id":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads","title":"Concurrencia Estructurada en Java 21: Virtual Threads y Arquitectura de Servidores","description":"Un análisis de la concurrencia estructurada de Project Loom, virtual threads, scoped values y StructuredTaskScope, y cómo cambian la arquitectura de servidores JVM para siempre.","language":"es","datePublished":"2026-02-12","dateModified":null,"contentFormat":"text/markdown","content":"JVM\n\n# Concurrencia Estructurada en Java 21: Virtual Threads y Arquitectura de Servidores\n\n2026-02-129 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads>)\n\n## El Problema Real que Resuelve la Concurrencia Estructurada\n\nSiendo concreto. Esto es concurrencia \"no estructurada\", lo que la mayoría de los shops Java vienen haciendo hace veinte años:\n\n```java\nExecutorService executor = Executors.newFixedThreadPool(200);\n\nFuture<FraudResult> fraudCheck = executor.submit(() -> fraudService.check(payment));\nFuture<Balance> balanceCheck = executor.submit(() -> balanceService.get(payment.accountId()));\nFuture<ComplianceResult> complianceCheck = executor.submit(() -> complianceService.verify(payment));\n\n// ¿Y ahora qué? ¿Qué excepción manejamos primero?\n// ¿Qué pasa si fraudCheck falla pero balanceCheck sigue corriendo?\n// ¿Qué pasa si hacemos return temprano y estos futures siguen vivos?\ntry {\n FraudResult fraud = fraudCheck.get(5, TimeUnit.SECONDS);\n Balance balance = balanceCheck.get(5, TimeUnit.SECONDS);\n ComplianceResult compliance = complianceCheck.get(5, TimeUnit.SECONDS);\n} catch (TimeoutException e) {\n // ¿Se cancelaron las otras tareas? No. Siguen corriendo.\n // ¿Están holdean conexiones a la base? Probablemente.\n // ¿Van a completar eventualmente sin que a nadie le importe? Sí.\n}\n```\n\nLas 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é.\n\nLa 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.\n\n## StructuredTaskScope: La Primitiva Central\n\n```java\ntry (var scope = new StructuredTaskScope.ShutdownOnFailure()) {\n Subtask<FraudResult> fraudCheck = scope.fork(() -> fraudService.check(payment));\n Subtask<Balance> balanceCheck = scope.fork(() -> balanceService.get(payment.accountId()));\n Subtask<ComplianceResult> complianceCheck = scope.fork(() -> complianceService.verify(payment));\n\n scope.join(); // Esperá a todas las subtareas\n scope.throwIfFailed(); // Propagá el primer fallo\n\n // Las tres tuvieron éxito, safe para acceder resultados\n processPayment(fraudCheck.get(), balanceCheck.get(), complianceCheck.get());\n}\n// El scope se cerró. TODAS las subtareas terminaron garantizado.\n// Sin threads leakeados. Sin conexiones huérfanas. Sin fallos silenciosos.\n```\n\nCuando ` 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.\n\nEl 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.\n\n## Virtual Threads: Por Qué Esto No Era Posible Antes\n\nLa 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.\n\nLos 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.\n\n```java\n// Esto spawnea 100.000 llamadas HTTP concurrentes.\n// Con platform threads, necesitarías 100.000 OS threads (~100 GB de stack).\n// Con virtual threads, usa ~200 MB de heap.\ntry (var scope = new StructuredTaskScope.ShutdownOnFailure()) {\n List<Subtask<Response>> tasks = urls.stream()\n .map(url -> scope.fork(() -> httpClient.send(\n HttpRequest.newBuilder(URI.create(url)).build(),\n HttpResponse.BodyHandlers.ofString()\n )))\n .toList();\n\n scope.join();\n scope.throwIfFailed();\n\n List<Response> responses = tasks.stream()\n .map(Subtask::get)\n .toList();\n}\n```\n\nBenchmarkeé 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.\n\n## Scoped Values: El Reemplazo de ThreadLocal\n\nEste patrón lo conoce todo desarrollador Spring:\n\n```java\npublic class RequestContext {\n private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();\n\n public static void set(String traceId) { TRACE_ID.set(traceId); }\n public static String get() { return TRACE_ID.get(); }\n public static void clear() { TRACE_ID.remove(); } // Prescindir de esto y leakeás\n}\n```\n\nThreadLocal 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.\n\n` ScopedValue ` arregla ambos:\n\n```java\nprivate static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();\n\nScopedValue.runWhere(TRACE_ID, \"abc-123\", () -> {\n // Cualquier código aquí (incluyendo virtual threads forkeados) ve \"abc-123\"\n System.out.println(TRACE_ID.get()); // \"abc-123\"\n\n try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {\n scope.fork(() -> {\n // Se hereda automáticamente. Costo de copia: cero.\n System.out.println(TRACE_ID.get()); // \"abc-123\"\n return someService.call();\n });\n scope.join();\n scope.throwIfFailed();\n }\n});\n// TRACE_ID no está bindeado aquí. No hay cleanup. No hay leak posible.\n```\n\nEl 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.\n\n## Impacto Arquitectónico Real: Tirando el Stack Reactivo\n\nAsí era mi servicio de pagos en WebFlux:\n\n```java\npublic Mono<PaymentResult> processPayment(Payment payment) {\n return Mono.zip(\n fraudService.check(payment)\n .timeout(Duration.ofSeconds(5))\n .onErrorResume(e -> Mono.just(FraudResult.UNKNOWN)),\n balanceService.get(payment.accountId())\n .timeout(Duration.ofSeconds(3)),\n complianceService.verify(payment)\n .timeout(Duration.ofSeconds(5))\n ).flatMap(tuple -> {\n FraudResult fraud = tuple.getT1();\n Balance balance = tuple.getT2();\n ComplianceResult compliance = tuple.getT3();\n\n if (fraud.isRisky()) {\n return Mono.error(new FraudException(fraud));\n }\n return paymentRepository.save(payment.withStatus(APPROVED))\n .flatMap(saved -> eventPublisher.publish(new PaymentApproved(saved))\n .thenReturn(PaymentResult.success(saved)));\n }).onErrorResume(BalanceException.class, e ->\n Mono.just(PaymentResult.insufficientFunds()))\n .onErrorResume(e ->\n Mono.just(PaymentResult.error(e.getMessage())));\n}\n```\n\nY aquí la versión con virtual threads:\n\n```java\npublic PaymentResult processPayment(Payment payment) {\n try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {\n var fraudCheck = scope.fork(() -> fraudService.check(payment));\n var balanceCheck = scope.fork(() -> balanceService.get(payment.accountId()));\n var complianceCheck = scope.fork(() -> complianceService.verify(payment));\n\n scope.join().throwIfFailed();\n\n FraudResult fraud = fraudCheck.get();\n if (fraud.isRisky()) throw new FraudException(fraud);\n\n Balance balance = balanceCheck.get();\n ComplianceResult compliance = complianceCheck.get();\n\n Payment saved = paymentRepository.save(payment.withStatus(APPROVED));\n eventPublisher.publish(new PaymentApproved(saved));\n return PaymentResult.success(saved);\n\n } catch (BalanceException e) {\n return PaymentResult.insufficientFunds();\n } catch (Exception e) {\n return PaymentResult.error(e.getMessage());\n }\n}\n```\n\nMisma 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 `.\n\n## Scopes Custom para Políticas Sofisticadas\n\n` ShutdownOnFailure ` y ` ShutdownOnSuccess ` son dos políticas built-in. Puede escribir scopes custom para comportamientos más matizados:\n\n```java\npublic class QuorumScope<T> extends StructuredTaskScope<T> {\n private final int quorum;\n private final AtomicInteger successCount = new AtomicInteger(0);\n private final ConcurrentLinkedQueue<T> results = new ConcurrentLinkedQueue<>();\n\n public QuorumScope(int quorum) {\n this.quorum = quorum;\n }\n\n @Override\n protected void handleComplete(Subtask<? extends T> subtask) {\n if (subtask.state() == Subtask.State.SUCCESS) {\n results.add(subtask.get());\n if (successCount.incrementAndGet() >= quorum) {\n shutdown(); // Tenemos suficientes respuestas, cancelá el resto\n }\n }\n }\n\n public List<T> results() {\n return List.copyOf(results);\n }\n}\n```\n\nUsé 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.\n\n## El Problema del Pinning\n\nNo 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.\n\n```java\n// MAL: Pinnea el virtual thread al carrier\nsynchronized (lock) {\n connection.executeQuery(sql); // I/O dentro de synchronized = pinneado\n}\n\n// BIEN: ReentrantLock desmonta el virtual thread correctamente\nlock.lock();\ntry {\n connection.executeQuery(sql); // El virtual thread se desmonta durante I/O\n} finally {\n lock.unlock();\n}\n```\n\nEjecutar 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.\n\n## Spring Boot 3.2+ y la Integración\n\nSpring Boot 3.2 agregó soporte first-class de virtual threads. Una property:\n\n```properties\nspring.threads.virtual.enabled=true\n```\n\nEsto 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.\n\nPero no te quedes ahí. Utilizar ` StructuredTaskScope ` explícitamente para patrones fan-out:\n\n```java\n@RestController\npublic class ProductController {\n @GetMapping(\"/product/{id}\")\n public ProductDetail getProduct(@PathVariable String id) {\n try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {\n var product = scope.fork(() -> productService.findById(id));\n var reviews = scope.fork(() -> reviewService.getForProduct(id));\n var inventory = scope.fork(() -> inventoryService.check(id));\n\n scope.join().throwIfFailed();\n\n return new ProductDetail(product.get(), reviews.get(), inventory.get());\n } catch (Exception e) {\n throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());\n }\n }\n}\n```\n\nTres 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.\n\n## Checklist de Migración\n\nDespués de migrar tres servicios, este es mi checklist probado en batalla:\n\n1. **Auditar bloques ` synchronized `**, reemplazar con ` ReentrantLock ` donde haya I/O adentro\n2. **Actualizar drivers JDBC**, PostgreSQL 42.7+, verificar los otros por fixes de pinning\n3. **Borrar tuning de thread pool**, ` server.tomcat.threads.max=200 ` no tiene sentido con VTs\n4. **Reemplazar ` ThreadLocal ` con ` ScopedValue `**, especialmente para request context, MDC, trace IDs\n5. **Agregar ` -Djdk.tracePinnedThreads=short `** a los argumentos JVM de test\n6. **Reemplazar fan-out con ` CompletableFuture `** por ` StructuredTaskScope `\n7. **Remover dependencias reactivas** si las adoptaste puramente por throughput (no por backpressure)\n\nLa 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.\n\n## El Panorama Completo\n\nLa 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.\n\nCombinado 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/>).\n\nLa 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.\n\n## Preguntas frecuentes\n\n### ¿Los virtual threads reemplazan frameworks reactivos como WebFlux?\n\nPara 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.\n\n### ¿Cuántos virtual threads puedo spawnear en una sola JVM?\n\nMillones. 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.\n\n### ¿Los virtual threads funcionan con drivers JDBC existentes?\n\nSí, 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.\n\n## Tips\n\n1. **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.\n2. **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.\n3. **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.\n4. **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.\n\n- java\n- virtual-threads\n- project-loom\n- concurrencia-estructurada\n- jvm\n- scoped-values\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)","headings":[{"level":1,"text":"Concurrencia Estructurada en Java 21: Virtual Threads y Arquitectura de Servidores","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#title"},{"level":2,"text":"El Problema Real que Resuelve la Concurrencia Estructurada","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#el-problema-real-que-resuelve-la-concurrencia-estructurada"},{"level":2,"text":"StructuredTaskScope: La Primitiva Central","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#structuredtaskscope-la-primitiva-central"},{"level":2,"text":"Virtual Threads: Por Qué Esto No Era Posible Antes","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#virtual-threads-por-qu-esto-no-era-posible-antes"},{"level":2,"text":"Scoped Values: El Reemplazo de ThreadLocal","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#scoped-values-el-reemplazo-de-threadlocal"},{"level":2,"text":"Impacto Arquitectónico Real: Tirando el Stack Reactivo","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#impacto-arquitectnico-real-tirando-el-stack-reactivo"},{"level":2,"text":"Scopes Custom para Políticas Sofisticadas","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#scopes-custom-para-polticas-sofisticadas"},{"level":2,"text":"El Problema del Pinning","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#el-problema-del-pinning"},{"level":2,"text":"Spring Boot 3.2+ y la Integración","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#spring-boot-32-y-la-integracin"},{"level":2,"text":"Checklist de Migración","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#checklist-de-migracin"},{"level":2,"text":"El Panorama Completo","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#el-panorama-completo"},{"level":2,"text":"Preguntas frecuentes","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#questions"},{"level":3,"text":"¿Los virtual threads reemplazan frameworks reactivos como WebFlux?","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#question-1"},{"level":3,"text":"¿Cuántos virtual threads puedo spawnear en una sola JVM?","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#question-2"},{"level":3,"text":"¿Los virtual threads funcionan con drivers JDBC existentes?","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#article-tips"}],"sections":[{"heading":"Concurrencia Estructurada en Java 21: Virtual Threads y Arquitectura de Servidores","level":1,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads","content":"JVM"},{"heading":"Concurrencia Estructurada en Java 21: Virtual Threads y Arquitectura de Servidores","level":1,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#title","content":"2026-02-129 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads>)"},{"heading":"El Problema Real que Resuelve la Concurrencia Estructurada","level":2,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#el-problema-real-que-resuelve-la-concurrencia-estructurada","content":"Siendo concreto. Esto es concurrencia \"no estructurada\", lo que la mayoría de los shops Java vienen haciendo hace veinte años:\n\n```java\nExecutorService executor = Executors.newFixedThreadPool(200);\n\nFuture<FraudResult> fraudCheck = executor.submit(() -> fraudService.check(payment));\nFuture<Balance> balanceCheck = executor.submit(() -> balanceService.get(payment.accountId()));\nFuture<ComplianceResult> complianceCheck = executor.submit(() -> complianceService.verify(payment));\n\n// ¿Y ahora qué? ¿Qué excepción manejamos primero?\n// ¿Qué pasa si fraudCheck falla pero balanceCheck sigue corriendo?\n// ¿Qué pasa si hacemos return temprano y estos futures siguen vivos?\ntry {\n FraudResult fraud = fraudCheck.get(5, TimeUnit.SECONDS);\n Balance balance = balanceCheck.get(5, TimeUnit.SECONDS);\n ComplianceResult compliance = complianceCheck.get(5, TimeUnit.SECONDS);\n} catch (TimeoutException e) {\n // ¿Se cancelaron las otras tareas? No. Siguen corriendo.\n // ¿Están holdean conexiones a la base? Probablemente.\n // ¿Van a completar eventualmente sin que a nadie le importe? Sí.\n}\n```\n\nLas 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é.\n\nLa 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."},{"heading":"StructuredTaskScope: La Primitiva Central","level":2,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#structuredtaskscope-la-primitiva-central","content":"```java\ntry (var scope = new StructuredTaskScope.ShutdownOnFailure()) {\n Subtask<FraudResult> fraudCheck = scope.fork(() -> fraudService.check(payment));\n Subtask<Balance> balanceCheck = scope.fork(() -> balanceService.get(payment.accountId()));\n Subtask<ComplianceResult> complianceCheck = scope.fork(() -> complianceService.verify(payment));\n\n scope.join(); // Esperá a todas las subtareas\n scope.throwIfFailed(); // Propagá el primer fallo\n\n // Las tres tuvieron éxito, safe para acceder resultados\n processPayment(fraudCheck.get(), balanceCheck.get(), complianceCheck.get());\n}\n// El scope se cerró. TODAS las subtareas terminaron garantizado.\n// Sin threads leakeados. Sin conexiones huérfanas. Sin fallos silenciosos.\n```\n\nCuando ` 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.\n\nEl 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."},{"heading":"Virtual Threads: Por Qué Esto No Era Posible Antes","level":2,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#virtual-threads-por-qu-esto-no-era-posible-antes","content":"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.\n\nLos 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.\n\n```java\n// Esto spawnea 100.000 llamadas HTTP concurrentes.\n// Con platform threads, necesitarías 100.000 OS threads (~100 GB de stack).\n// Con virtual threads, usa ~200 MB de heap.\ntry (var scope = new StructuredTaskScope.ShutdownOnFailure()) {\n List<Subtask<Response>> tasks = urls.stream()\n .map(url -> scope.fork(() -> httpClient.send(\n HttpRequest.newBuilder(URI.create(url)).build(),\n HttpResponse.BodyHandlers.ofString()\n )))\n .toList();\n\n scope.join();\n scope.throwIfFailed();\n\n List<Response> responses = tasks.stream()\n .map(Subtask::get)\n .toList();\n}\n```\n\nBenchmarkeé 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."},{"heading":"Scoped Values: El Reemplazo de ThreadLocal","level":2,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#scoped-values-el-reemplazo-de-threadlocal","content":"Este patrón lo conoce todo desarrollador Spring:\n\n```java\npublic class RequestContext {\n private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();\n\n public static void set(String traceId) { TRACE_ID.set(traceId); }\n public static String get() { return TRACE_ID.get(); }\n public static void clear() { TRACE_ID.remove(); } // Prescindir de esto y leakeás\n}\n```\n\nThreadLocal 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.\n\n` ScopedValue ` arregla ambos:\n\n```java\nprivate static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();\n\nScopedValue.runWhere(TRACE_ID, \"abc-123\", () -> {\n // Cualquier código aquí (incluyendo virtual threads forkeados) ve \"abc-123\"\n System.out.println(TRACE_ID.get()); // \"abc-123\"\n\n try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {\n scope.fork(() -> {\n // Se hereda automáticamente. Costo de copia: cero.\n System.out.println(TRACE_ID.get()); // \"abc-123\"\n return someService.call();\n });\n scope.join();\n scope.throwIfFailed();\n }\n});\n// TRACE_ID no está bindeado aquí. No hay cleanup. No hay leak posible.\n```\n\nEl 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."},{"heading":"Impacto Arquitectónico Real: Tirando el Stack Reactivo","level":2,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#impacto-arquitectnico-real-tirando-el-stack-reactivo","content":"Así era mi servicio de pagos en WebFlux:\n\n```java\npublic Mono<PaymentResult> processPayment(Payment payment) {\n return Mono.zip(\n fraudService.check(payment)\n .timeout(Duration.ofSeconds(5))\n .onErrorResume(e -> Mono.just(FraudResult.UNKNOWN)),\n balanceService.get(payment.accountId())\n .timeout(Duration.ofSeconds(3)),\n complianceService.verify(payment)\n .timeout(Duration.ofSeconds(5))\n ).flatMap(tuple -> {\n FraudResult fraud = tuple.getT1();\n Balance balance = tuple.getT2();\n ComplianceResult compliance = tuple.getT3();\n\n if (fraud.isRisky()) {\n return Mono.error(new FraudException(fraud));\n }\n return paymentRepository.save(payment.withStatus(APPROVED))\n .flatMap(saved -> eventPublisher.publish(new PaymentApproved(saved))\n .thenReturn(PaymentResult.success(saved)));\n }).onErrorResume(BalanceException.class, e ->\n Mono.just(PaymentResult.insufficientFunds()))\n .onErrorResume(e ->\n Mono.just(PaymentResult.error(e.getMessage())));\n}\n```\n\nY aquí la versión con virtual threads:\n\n```java\npublic PaymentResult processPayment(Payment payment) {\n try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {\n var fraudCheck = scope.fork(() -> fraudService.check(payment));\n var balanceCheck = scope.fork(() -> balanceService.get(payment.accountId()));\n var complianceCheck = scope.fork(() -> complianceService.verify(payment));\n\n scope.join().throwIfFailed();\n\n FraudResult fraud = fraudCheck.get();\n if (fraud.isRisky()) throw new FraudException(fraud);\n\n Balance balance = balanceCheck.get();\n ComplianceResult compliance = complianceCheck.get();\n\n Payment saved = paymentRepository.save(payment.withStatus(APPROVED));\n eventPublisher.publish(new PaymentApproved(saved));\n return PaymentResult.success(saved);\n\n } catch (BalanceException e) {\n return PaymentResult.insufficientFunds();\n } catch (Exception e) {\n return PaymentResult.error(e.getMessage());\n }\n}\n```\n\nMisma 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 `."},{"heading":"Scopes Custom para Políticas Sofisticadas","level":2,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#scopes-custom-para-polticas-sofisticadas","content":"` ShutdownOnFailure ` y ` ShutdownOnSuccess ` son dos políticas built-in. Puede escribir scopes custom para comportamientos más matizados:\n\n```java\npublic class QuorumScope<T> extends StructuredTaskScope<T> {\n private final int quorum;\n private final AtomicInteger successCount = new AtomicInteger(0);\n private final ConcurrentLinkedQueue<T> results = new ConcurrentLinkedQueue<>();\n\n public QuorumScope(int quorum) {\n this.quorum = quorum;\n }\n\n @Override\n protected void handleComplete(Subtask<? extends T> subtask) {\n if (subtask.state() == Subtask.State.SUCCESS) {\n results.add(subtask.get());\n if (successCount.incrementAndGet() >= quorum) {\n shutdown(); // Tenemos suficientes respuestas, cancelá el resto\n }\n }\n }\n\n public List<T> results() {\n return List.copyOf(results);\n }\n}\n```\n\nUsé 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."},{"heading":"El Problema del Pinning","level":2,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#el-problema-del-pinning","content":"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.\n\n```java\n// MAL: Pinnea el virtual thread al carrier\nsynchronized (lock) {\n connection.executeQuery(sql); // I/O dentro de synchronized = pinneado\n}\n\n// BIEN: ReentrantLock desmonta el virtual thread correctamente\nlock.lock();\ntry {\n connection.executeQuery(sql); // El virtual thread se desmonta durante I/O\n} finally {\n lock.unlock();\n}\n```\n\nEjecutar 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."},{"heading":"Spring Boot 3.2+ y la Integración","level":2,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#spring-boot-32-y-la-integracin","content":"Spring Boot 3.2 agregó soporte first-class de virtual threads. Una property:\n\n```properties\nspring.threads.virtual.enabled=true\n```\n\nEsto 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.\n\nPero no te quedes ahí. Utilizar ` StructuredTaskScope ` explícitamente para patrones fan-out:\n\n```java\n@RestController\npublic class ProductController {\n @GetMapping(\"/product/{id}\")\n public ProductDetail getProduct(@PathVariable String id) {\n try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {\n var product = scope.fork(() -> productService.findById(id));\n var reviews = scope.fork(() -> reviewService.getForProduct(id));\n var inventory = scope.fork(() -> inventoryService.check(id));\n\n scope.join().throwIfFailed();\n\n return new ProductDetail(product.get(), reviews.get(), inventory.get());\n } catch (Exception e) {\n throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());\n }\n }\n}\n```\n\nTres 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."},{"heading":"Checklist de Migración","level":2,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#checklist-de-migracin","content":"Después de migrar tres servicios, este es mi checklist probado en batalla:\n\n1. **Auditar bloques ` synchronized `**, reemplazar con ` ReentrantLock ` donde haya I/O adentro\n2. **Actualizar drivers JDBC**, PostgreSQL 42.7+, verificar los otros por fixes de pinning\n3. **Borrar tuning de thread pool**, ` server.tomcat.threads.max=200 ` no tiene sentido con VTs\n4. **Reemplazar ` ThreadLocal ` con ` ScopedValue `**, especialmente para request context, MDC, trace IDs\n5. **Agregar ` -Djdk.tracePinnedThreads=short `** a los argumentos JVM de test\n6. **Reemplazar fan-out con ` CompletableFuture `** por ` StructuredTaskScope `\n7. **Remover dependencias reactivas** si las adoptaste puramente por throughput (no por backpressure)\n\nLa 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."},{"heading":"El Panorama Completo","level":2,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#el-panorama-completo","content":"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.\n\nCombinado 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/>).\n\nLa 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."},{"heading":"Preguntas frecuentes","level":2,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#questions","content":""},{"heading":"¿Los virtual threads reemplazan frameworks reactivos como WebFlux?","level":3,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#question-1","content":"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."},{"heading":"¿Cuántos virtual threads puedo spawnear en una sola JVM?","level":3,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#question-2","content":"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."},{"heading":"¿Los virtual threads funcionan con drivers JDBC existentes?","level":3,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#question-3","content":"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."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#article-tips","content":"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.\n2. **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.\n3. **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.\n4. **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.\n\n- java\n- virtual-threads\n- project-loom\n- concurrencia-estructurada\n- jvm\n- scoped-values\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)"}],"links":[{"text":"English","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads"},{"text":"DigitalOcean","url":"https://digitalocean.com/"},{"text":"AWS","url":"https://aws.com/"},{"text":"Volver al Blog","url":"https://lucioduran.com/es/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads"},"headline":"Concurrencia Estructurada en Java 21: Virtual Threads y Arquitectura de Servidores","description":"Un análisis de la concurrencia estructurada de Project Loom, virtual threads, scoped values y StructuredTaskScope, y cómo cambian la arquitectura de servidores JVM para siempre.","datePublished":"2026-02-12","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads","inLanguage":"es","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"JVM","keywords":"java, virtual-threads, project-loom, concurrencia-estructurada, jvm, scoped-values","wordCount":1667,"timeRequired":"PT9M","workTranslation":[{"@type":"BlogPosting","inLanguage":"en","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Inicio","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/es/blog"},{"@type":"ListItem","position":3,"name":"Concurrencia Estructurada en Java 21: Virtual Threads y Arquitectura de Servidores","item":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"¿Los virtual threads reemplazan frameworks reactivos como WebFlux?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"¿Cuántos virtual threads puedo spawnear en una sola JVM?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"¿Los virtual threads funcionan con drivers JDBC existentes?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}]}],"contentHash":"c0c93b6d071ae7f1cb922147ef9316e0ca397e8727d256ca31b45cc1ef2f7732","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":72975,"contentBytes":16470}}
