{"schemaVersion":1,"id":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads","title":"Structured Concurrency in Java 21: Virtual Threads and Server Architecture","description":"An examination of Project Loom's structured concurrency, virtual threads, scoped values, and StructuredTaskScope, and how they fundamentally alter JVM server architecture.","language":"en","datePublished":"2026-02-12","dateModified":null,"contentFormat":"text/markdown","content":"JVM\n\n# Structured Concurrency in Java 21: Virtual Threads and Server Architecture\n\n2026-02-129 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads>)\n\n## The Problem Structured Concurrency Actually Solves\n\nLet me be concrete. Here's what \"unstructured\" concurrency looks like in practice, the thing most Java shops have been doing for two decades:\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// Now what? Which exceptions do we handle first?\n// What if fraudCheck fails but balanceCheck is still running?\n// What if we return early and these futures keep running?\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 // ...process\n} catch (TimeoutException e) {\n // Are the other tasks cancelled? No. They're still running.\n // Are they holding database connections? Probably.\n // Will they eventually complete and nobody cares? Yes.\n}\n```\n\nThe tasks have no relationship to the scope that spawned them. They leak. They outlive their parent. When you ` catch ` an exception from one, the others keep burning CPU and holding connections. Thread dumps become unreadable because there's no hierarchy, just a flat pool of threads doing who-knows-what.\n\nStructured concurrency fixes this by making concurrent task lifetime match lexical scope, the same way structured programming made control flow match block structure back in the 1960s.\n\n## StructuredTaskScope: The Core Primitive\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(); // Wait for all subtasks\n scope.throwIfFailed(); // Propagate first failure\n\n // All three succeeded, safe to access results\n processPayment(fraudCheck.get(), balanceCheck.get(), complianceCheck.get());\n}\n// Scope is closed. ALL subtasks are guaranteed finished.\n// No leaked threads. No orphaned connections. No silent failures.\n```\n\nWhen ` fraudCheck ` throws, ` ShutdownOnFailure ` immediately cancels ` balanceCheck ` and ` complianceCheck ` by interrupting their virtual threads. The ` join() ` call won't return until all three subtasks are actually done (either completed, failed, or cancelled). The try-with-resources block guarantees cleanup.\n\nThis is the key insight: **the scope owns the threads**, not a shared pool. Thread lifetime is bounded by the block. Thread dumps show parent-child relationships. Debuggers can walk the task tree.\n\n## Virtual Threads: Why This Wasn't Possible Before\n\nStructured concurrency is an API pattern. Virtual threads are the runtime mechanism that makes it practical. Without virtual threads, forking a subtask per request per downstream call would exhaust your thread pool in seconds under load.\n\nVirtual threads are scheduled by the JVM onto a small pool of carrier threads (typically one per CPU core). When a virtual thread blocks on I/O, a socket read, a ` ReentrantLock.lock() `, a ` Thread.sleep() `, the JVM unmounts it from the carrier and mounts another virtual thread. The carrier never blocks.\n\n```java\n// This spawns 100,000 concurrent HTTP calls.\n// With platform threads, you'd need 100,000 OS threads (~100 GB of stack).\n// With virtual threads, this uses ~200 MB of 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\nI benchmarked this pattern against the equivalent CompletableFuture chain on a 4-core [DigitalOcean](<https://digitalocean.com/>) droplet. The virtual thread version handled 47,000 requests/sec versus 31,000 for CompletableFuture. Not because virtual threads are inherently faster, the I/O wait time is the same, but because the scheduling overhead is dramatically lower and there's no thread pool contention.\n\n## Scoped Values: ThreadLocal's Replacement\n\nHere's a pattern every Spring developer knows:\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(); } // Forget this and you leak\n}\n```\n\nThreadLocal has two problems with virtual threads. First, it's mutable, any code can call ` set() ` at any time, making the data flow invisible. Second, each virtual thread gets its own copy, and since you might have millions of virtual threads, the memory overhead is significant.\n\n` ScopedValue ` fixes both:\n\n```java\nprivate static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();\n\n// Bind it for a scope, immutable within that scope\nScopedValue.runWhere(TRACE_ID, \"abc-123\", () -> {\n // Any code here (including in forked virtual threads) sees \"abc-123\"\n System.out.println(TRACE_ID.get()); // \"abc-123\"\n\n try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {\n scope.fork(() -> {\n // Inherited automatically. Zero copy cost.\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 is unbound here. No cleanup needed. No leak possible.\n```\n\nThe scoped value is inherited by child virtual threads automatically, it's immutable within its bound scope, and it's garbage-collected when the scope exits. No ` finally ` block calling ` clear() `. No silent corruption from some middleware calling ` set() ` at the wrong time.\n\n## Real Architecture Impact: Ditching the Reactive Stack\n\nHere's what my payment service looked like in 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\nAnd here's the virtual thread version:\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\nSame semantics. Half the lines. Every Java developer can read it. Stack traces actually show you where the error happened instead of pointing at ` Mono.java:4182 `.\n\n## Custom Scopes for Sophisticated Policies\n\n` ShutdownOnFailure ` and ` ShutdownOnSuccess ` are just two built-in policies. You can write custom scopes for nuanced behavior:\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(); // Got enough responses, cancel the rest\n }\n }\n }\n\n public List<T> results() {\n return List.copyOf(results);\n }\n}\n```\n\nI used this exact pattern for a distributed cache lookup: fire requests at three replicas, return as soon as two agree. Average latency dropped 40% because we stopped waiting for the slowest replica.\n\n## The Pinning Problem\n\nNot everything is smooth. Virtual threads get \"pinned\" to their carrier thread when they block inside a ` synchronized ` block. The carrier can't be reused, which defeats the whole purpose.\n\n```java\n// BAD: Pins the virtual thread to the carrier\nsynchronized (lock) {\n connection.executeQuery(sql); // I/O inside synchronized = pinned\n}\n\n// GOOD: ReentrantLock unmounts the virtual thread properly\nlock.lock();\ntry {\n connection.executeQuery(sql); // Virtual thread unmounts during I/O\n} finally {\n lock.unlock();\n}\n```\n\nRun your app with ` -Djdk.tracePinnedThreads=short ` during testing. It prints a stack trace every time a virtual thread gets pinned. I found three pinning sites in a \"virtual-thread-ready\" library last month, all from ` synchronized ` blocks in connection pooling code.\n\n## Spring Boot 3.2+ Integration\n\nSpring Boot 3.2 added first-class virtual thread support. One property:\n\n```properties\nspring.threads.virtual.enabled=true\n```\n\nThis switches Tomcat's thread pool to virtual threads. Every incoming request runs on a virtual thread. Your existing blocking code, JDBC calls, RestTemplate, file I/O, suddenly scales like reactive code without any refactoring.\n\nBut don't stop there. Use ` StructuredTaskScope ` explicitly for fan-out patterns:\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\nThree downstream calls in parallel, properly scoped, with automatic cancellation on failure. That endpoint used to be a ` CompletableFuture.allOf ` nightmare with manual timeout handling.\n\n## Monitoring and Observability\n\nVirtual threads don't show up in traditional thread pool metrics. You need to adjust your monitoring:\n\n```java\n// JFR events for virtual thread scheduling\njdk.VirtualThreadStart\njdk.VirtualThreadEnd\njdk.VirtualThreadPinned\n\n// Enable in JVM flags:\n-XX:StartFlightRecording=settings=profile,duration=60s,filename=recording.jfr\n```\n\nI also instrument ` StructuredTaskScope ` with custom JFR events:\n\n```java\n@jdk.jfr.Name(\"app.StructuredTaskComplete\")\n@jdk.jfr.Label(\"Structured Task Complete\")\nclass TaskCompleteEvent extends jdk.jfr.Event {\n @jdk.jfr.Label(\"Task Name\") String taskName;\n @jdk.jfr.Label(\"Duration ms\") long durationMs;\n @jdk.jfr.Label(\"Subtask Count\") int subtaskCount;\n @jdk.jfr.Label(\"Failed Count\") int failedCount;\n}\n```\n\nThis gives you Grafana dashboards that show structured task scope durations, subtask fan-out patterns, and failure rates, the kind of observability that's nearly impossible with unstructured ` ExecutorService ` usage.\n\n## Migration Checklist\n\nAfter migrating three services, here's my battle-tested checklist:\n\n1. **Audit ` synchronized ` blocks**, replace with ` ReentrantLock ` where I/O happens inside\n2. **Update JDBC drivers**, PostgreSQL 42.7+, check others for pinning fixes\n3. **Remove thread pool tuning**, ` server.tomcat.threads.max=200 ` is meaningless with virtual threads\n4. **Replace ` ThreadLocal ` with ` ScopedValue `**, especially for request context, MDC, trace IDs\n5. **Add ` -Djdk.tracePinnedThreads=short `** to your test JVM arguments\n6. **Replace ` CompletableFuture ` fan-out** with ` StructuredTaskScope `\n7. **Remove reactive dependencies** if you adopted them purely for throughput (not backpressure)\n\nThe migration took my team three weeks for a 50k-line service. Most of that was auditing synchronized blocks in third-party libraries. The actual code changes were surprisingly small.\n\n## The Bigger Picture\n\nStructured concurrency isn't just a convenience API. It's a paradigm shift in how the JVM models concurrent work. For the first time, you can write concurrent Java that has the same structural guarantees as sequential code: every task has an owner, every task has a bounded lifetime, and the runtime enforces this.\n\nCombined with virtual threads' near-zero overhead, this means the JVM can handle workloads that previously required Go's goroutines or Erlang's processes, with the mature ecosystem, tooling, and type safety that Java provides. That payment service I migrated? It's running on two DigitalOcean droplets now instead of six. Same throughput. One-third the [AWS](<https://aws.com/>) bill.\n\nThe reactive era on the JVM was a necessary detour. We learned important lessons about backpressure, non-blocking I/O, and functional composition. But for the vast majority of server-side Java, the future is virtual threads and structured concurrency, boring, readable, sequential code that happens to scale like nothing before it.\n\n## Frequently asked questions\n\n### Are virtual threads a replacement for reactive frameworks like WebFlux?\n\nFor most I/O-bound workloads, yes. Virtual threads give you the throughput of reactive code with the readability of blocking code. Reactive still wins for backpressure-heavy streaming scenarios, but the majority of request/response services can drop the Mono/Flux complexity entirely.\n\n### How many virtual threads can I realistically spawn on a single JVM?\n\nMillions. Each virtual thread consumes roughly 1-2 KB of stack initially (grows as needed), compared to ~1 MB for platform threads. I've tested 4 million concurrent virtual threads on a 16 GB heap doing simple HTTP calls without breaking a sweat.\n\n### Do virtual threads work with existing JDBC drivers?\n\nYes, but with caveats. Most JDBC drivers use synchronized blocks internally, which pins virtual threads to carrier threads. The PostgreSQL JDBC driver (42.7+) has been patched to use ReentrantLock. MySQL Connector/J is still catching up. Check your driver's source before assuming you'll get full benefits.\n\n## Tips\n\n1. **LuchoTip:** Never pin a virtual thread to a carrier by holding a ` synchronized ` block during I/O, use ` ReentrantLock ` instead. One pinned carrier can bottleneck your entire ForkJoinPool.\n2. **LuchoTip:** Use ` ScopedValue ` instead of ` ThreadLocal ` with virtual threads. ThreadLocal per-VT overhead adds up fast when you're spawning millions of them, scoped values are immutable, inherited cheaply, and garbage-collected predictably.\n3. **LuchoTip:** Wrap every fan-out call in ` StructuredTaskScope.ShutdownOnFailure ` during development. Switch to ` ShutdownOnSuccess ` only when you've verified your partial-failure semantics are correct. Debugging silent swallowed exceptions in concurrent subtasks is miserable.\n\n- java\n- virtual-threads\n- project-loom\n- structured-concurrency\n- jvm\n- scoped-values\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Structured Concurrency in Java 21: Virtual Threads and Server Architecture","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#title"},{"level":2,"text":"The Problem Structured Concurrency Actually Solves","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#the-problem-structured-concurrency-actually-solves"},{"level":2,"text":"StructuredTaskScope: The Core Primitive","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#structuredtaskscope-the-core-primitive"},{"level":2,"text":"Virtual Threads: Why This Wasn't Possible Before","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#virtual-threads-why-this-wasnt-possible-before"},{"level":2,"text":"Scoped Values: ThreadLocal's Replacement","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#scoped-values-threadlocals-replacement"},{"level":2,"text":"Real Architecture Impact: Ditching the Reactive Stack","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#real-architecture-impact-ditching-the-reactive-stack"},{"level":2,"text":"Custom Scopes for Sophisticated Policies","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#custom-scopes-for-sophisticated-policies"},{"level":2,"text":"The Pinning Problem","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#the-pinning-problem"},{"level":2,"text":"Spring Boot 3.2+ Integration","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#spring-boot-32-integration"},{"level":2,"text":"Monitoring and Observability","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#monitoring-and-observability"},{"level":2,"text":"Migration Checklist","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#migration-checklist"},{"level":2,"text":"The Bigger Picture","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#the-bigger-picture"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#questions"},{"level":3,"text":"Are virtual threads a replacement for reactive frameworks like WebFlux?","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#question-1"},{"level":3,"text":"How many virtual threads can I realistically spawn on a single JVM?","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#question-2"},{"level":3,"text":"Do virtual threads work with existing JDBC drivers?","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#article-tips"}],"sections":[{"heading":"Structured Concurrency in Java 21: Virtual Threads and Server Architecture","level":1,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads","content":"JVM"},{"heading":"Structured Concurrency in Java 21: Virtual Threads and Server Architecture","level":1,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#title","content":"2026-02-129 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads>)"},{"heading":"The Problem Structured Concurrency Actually Solves","level":2,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#the-problem-structured-concurrency-actually-solves","content":"Let me be concrete. Here's what \"unstructured\" concurrency looks like in practice, the thing most Java shops have been doing for two decades:\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// Now what? Which exceptions do we handle first?\n// What if fraudCheck fails but balanceCheck is still running?\n// What if we return early and these futures keep running?\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 // ...process\n} catch (TimeoutException e) {\n // Are the other tasks cancelled? No. They're still running.\n // Are they holding database connections? Probably.\n // Will they eventually complete and nobody cares? Yes.\n}\n```\n\nThe tasks have no relationship to the scope that spawned them. They leak. They outlive their parent. When you ` catch ` an exception from one, the others keep burning CPU and holding connections. Thread dumps become unreadable because there's no hierarchy, just a flat pool of threads doing who-knows-what.\n\nStructured concurrency fixes this by making concurrent task lifetime match lexical scope, the same way structured programming made control flow match block structure back in the 1960s."},{"heading":"StructuredTaskScope: The Core Primitive","level":2,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#structuredtaskscope-the-core-primitive","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(); // Wait for all subtasks\n scope.throwIfFailed(); // Propagate first failure\n\n // All three succeeded, safe to access results\n processPayment(fraudCheck.get(), balanceCheck.get(), complianceCheck.get());\n}\n// Scope is closed. ALL subtasks are guaranteed finished.\n// No leaked threads. No orphaned connections. No silent failures.\n```\n\nWhen ` fraudCheck ` throws, ` ShutdownOnFailure ` immediately cancels ` balanceCheck ` and ` complianceCheck ` by interrupting their virtual threads. The ` join() ` call won't return until all three subtasks are actually done (either completed, failed, or cancelled). The try-with-resources block guarantees cleanup.\n\nThis is the key insight: **the scope owns the threads**, not a shared pool. Thread lifetime is bounded by the block. Thread dumps show parent-child relationships. Debuggers can walk the task tree."},{"heading":"Virtual Threads: Why This Wasn't Possible Before","level":2,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#virtual-threads-why-this-wasnt-possible-before","content":"Structured concurrency is an API pattern. Virtual threads are the runtime mechanism that makes it practical. Without virtual threads, forking a subtask per request per downstream call would exhaust your thread pool in seconds under load.\n\nVirtual threads are scheduled by the JVM onto a small pool of carrier threads (typically one per CPU core). When a virtual thread blocks on I/O, a socket read, a ` ReentrantLock.lock() `, a ` Thread.sleep() `, the JVM unmounts it from the carrier and mounts another virtual thread. The carrier never blocks.\n\n```java\n// This spawns 100,000 concurrent HTTP calls.\n// With platform threads, you'd need 100,000 OS threads (~100 GB of stack).\n// With virtual threads, this uses ~200 MB of 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\nI benchmarked this pattern against the equivalent CompletableFuture chain on a 4-core [DigitalOcean](<https://digitalocean.com/>) droplet. The virtual thread version handled 47,000 requests/sec versus 31,000 for CompletableFuture. Not because virtual threads are inherently faster, the I/O wait time is the same, but because the scheduling overhead is dramatically lower and there's no thread pool contention."},{"heading":"Scoped Values: ThreadLocal's Replacement","level":2,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#scoped-values-threadlocals-replacement","content":"Here's a pattern every Spring developer knows:\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(); } // Forget this and you leak\n}\n```\n\nThreadLocal has two problems with virtual threads. First, it's mutable, any code can call ` set() ` at any time, making the data flow invisible. Second, each virtual thread gets its own copy, and since you might have millions of virtual threads, the memory overhead is significant.\n\n` ScopedValue ` fixes both:\n\n```java\nprivate static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();\n\n// Bind it for a scope, immutable within that scope\nScopedValue.runWhere(TRACE_ID, \"abc-123\", () -> {\n // Any code here (including in forked virtual threads) sees \"abc-123\"\n System.out.println(TRACE_ID.get()); // \"abc-123\"\n\n try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {\n scope.fork(() -> {\n // Inherited automatically. Zero copy cost.\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 is unbound here. No cleanup needed. No leak possible.\n```\n\nThe scoped value is inherited by child virtual threads automatically, it's immutable within its bound scope, and it's garbage-collected when the scope exits. No ` finally ` block calling ` clear() `. No silent corruption from some middleware calling ` set() ` at the wrong time."},{"heading":"Real Architecture Impact: Ditching the Reactive Stack","level":2,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#real-architecture-impact-ditching-the-reactive-stack","content":"Here's what my payment service looked like in 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\nAnd here's the virtual thread version:\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\nSame semantics. Half the lines. Every Java developer can read it. Stack traces actually show you where the error happened instead of pointing at ` Mono.java:4182 `."},{"heading":"Custom Scopes for Sophisticated Policies","level":2,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#custom-scopes-for-sophisticated-policies","content":"` ShutdownOnFailure ` and ` ShutdownOnSuccess ` are just two built-in policies. You can write custom scopes for nuanced behavior:\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(); // Got enough responses, cancel the rest\n }\n }\n }\n\n public List<T> results() {\n return List.copyOf(results);\n }\n}\n```\n\nI used this exact pattern for a distributed cache lookup: fire requests at three replicas, return as soon as two agree. Average latency dropped 40% because we stopped waiting for the slowest replica."},{"heading":"The Pinning Problem","level":2,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#the-pinning-problem","content":"Not everything is smooth. Virtual threads get \"pinned\" to their carrier thread when they block inside a ` synchronized ` block. The carrier can't be reused, which defeats the whole purpose.\n\n```java\n// BAD: Pins the virtual thread to the carrier\nsynchronized (lock) {\n connection.executeQuery(sql); // I/O inside synchronized = pinned\n}\n\n// GOOD: ReentrantLock unmounts the virtual thread properly\nlock.lock();\ntry {\n connection.executeQuery(sql); // Virtual thread unmounts during I/O\n} finally {\n lock.unlock();\n}\n```\n\nRun your app with ` -Djdk.tracePinnedThreads=short ` during testing. It prints a stack trace every time a virtual thread gets pinned. I found three pinning sites in a \"virtual-thread-ready\" library last month, all from ` synchronized ` blocks in connection pooling code."},{"heading":"Spring Boot 3.2+ Integration","level":2,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#spring-boot-32-integration","content":"Spring Boot 3.2 added first-class virtual thread support. One property:\n\n```properties\nspring.threads.virtual.enabled=true\n```\n\nThis switches Tomcat's thread pool to virtual threads. Every incoming request runs on a virtual thread. Your existing blocking code, JDBC calls, RestTemplate, file I/O, suddenly scales like reactive code without any refactoring.\n\nBut don't stop there. Use ` StructuredTaskScope ` explicitly for fan-out patterns:\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\nThree downstream calls in parallel, properly scoped, with automatic cancellation on failure. That endpoint used to be a ` CompletableFuture.allOf ` nightmare with manual timeout handling."},{"heading":"Monitoring and Observability","level":2,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#monitoring-and-observability","content":"Virtual threads don't show up in traditional thread pool metrics. You need to adjust your monitoring:\n\n```java\n// JFR events for virtual thread scheduling\njdk.VirtualThreadStart\njdk.VirtualThreadEnd\njdk.VirtualThreadPinned\n\n// Enable in JVM flags:\n-XX:StartFlightRecording=settings=profile,duration=60s,filename=recording.jfr\n```\n\nI also instrument ` StructuredTaskScope ` with custom JFR events:\n\n```java\n@jdk.jfr.Name(\"app.StructuredTaskComplete\")\n@jdk.jfr.Label(\"Structured Task Complete\")\nclass TaskCompleteEvent extends jdk.jfr.Event {\n @jdk.jfr.Label(\"Task Name\") String taskName;\n @jdk.jfr.Label(\"Duration ms\") long durationMs;\n @jdk.jfr.Label(\"Subtask Count\") int subtaskCount;\n @jdk.jfr.Label(\"Failed Count\") int failedCount;\n}\n```\n\nThis gives you Grafana dashboards that show structured task scope durations, subtask fan-out patterns, and failure rates, the kind of observability that's nearly impossible with unstructured ` ExecutorService ` usage."},{"heading":"Migration Checklist","level":2,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#migration-checklist","content":"After migrating three services, here's my battle-tested checklist:\n\n1. **Audit ` synchronized ` blocks**, replace with ` ReentrantLock ` where I/O happens inside\n2. **Update JDBC drivers**, PostgreSQL 42.7+, check others for pinning fixes\n3. **Remove thread pool tuning**, ` server.tomcat.threads.max=200 ` is meaningless with virtual threads\n4. **Replace ` ThreadLocal ` with ` ScopedValue `**, especially for request context, MDC, trace IDs\n5. **Add ` -Djdk.tracePinnedThreads=short `** to your test JVM arguments\n6. **Replace ` CompletableFuture ` fan-out** with ` StructuredTaskScope `\n7. **Remove reactive dependencies** if you adopted them purely for throughput (not backpressure)\n\nThe migration took my team three weeks for a 50k-line service. Most of that was auditing synchronized blocks in third-party libraries. The actual code changes were surprisingly small."},{"heading":"The Bigger Picture","level":2,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#the-bigger-picture","content":"Structured concurrency isn't just a convenience API. It's a paradigm shift in how the JVM models concurrent work. For the first time, you can write concurrent Java that has the same structural guarantees as sequential code: every task has an owner, every task has a bounded lifetime, and the runtime enforces this.\n\nCombined with virtual threads' near-zero overhead, this means the JVM can handle workloads that previously required Go's goroutines or Erlang's processes, with the mature ecosystem, tooling, and type safety that Java provides. That payment service I migrated? It's running on two DigitalOcean droplets now instead of six. Same throughput. One-third the [AWS](<https://aws.com/>) bill.\n\nThe reactive era on the JVM was a necessary detour. We learned important lessons about backpressure, non-blocking I/O, and functional composition. But for the vast majority of server-side Java, the future is virtual threads and structured concurrency, boring, readable, sequential code that happens to scale like nothing before it."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#questions","content":""},{"heading":"Are virtual threads a replacement for reactive frameworks like WebFlux?","level":3,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#question-1","content":"For most I/O-bound workloads, yes. Virtual threads give you the throughput of reactive code with the readability of blocking code. Reactive still wins for backpressure-heavy streaming scenarios, but the majority of request/response services can drop the Mono/Flux complexity entirely."},{"heading":"How many virtual threads can I realistically spawn on a single JVM?","level":3,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#question-2","content":"Millions. Each virtual thread consumes roughly 1-2 KB of stack initially (grows as needed), compared to ~1 MB for platform threads. I've tested 4 million concurrent virtual threads on a 16 GB heap doing simple HTTP calls without breaking a sweat."},{"heading":"Do virtual threads work with existing JDBC drivers?","level":3,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#question-3","content":"Yes, but with caveats. Most JDBC drivers use synchronized blocks internally, which pins virtual threads to carrier threads. The PostgreSQL JDBC driver (42.7+) has been patched to use ReentrantLock. MySQL Connector/J is still catching up. Check your driver's source before assuming you'll get full benefits."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#article-tips","content":"1. **LuchoTip:** Never pin a virtual thread to a carrier by holding a ` synchronized ` block during I/O, use ` ReentrantLock ` instead. One pinned carrier can bottleneck your entire ForkJoinPool.\n2. **LuchoTip:** Use ` ScopedValue ` instead of ` ThreadLocal ` with virtual threads. ThreadLocal per-VT overhead adds up fast when you're spawning millions of them, scoped values are immutable, inherited cheaply, and garbage-collected predictably.\n3. **LuchoTip:** Wrap every fan-out call in ` StructuredTaskScope.ShutdownOnFailure ` during development. Switch to ` ShutdownOnSuccess ` only when you've verified your partial-failure semantics are correct. Debugging silent swallowed exceptions in concurrent subtasks is miserable.\n\n- java\n- virtual-threads\n- project-loom\n- structured-concurrency\n- jvm\n- scoped-values\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads"},{"text":"DigitalOcean","url":"https://digitalocean.com/"},{"text":"AWS","url":"https://aws.com/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads"},"headline":"Structured Concurrency in Java 21: Virtual Threads and Server Architecture","description":"An examination of Project Loom's structured concurrency, virtual threads, scoped values, and StructuredTaskScope, and how they fundamentally alter JVM server architecture.","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/blog/structured-concurrency-java-21-virtual-threads","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"JVM","keywords":"java, virtual-threads, project-loom, structured-concurrency, jvm, scoped-values","wordCount":1668,"timeRequired":"PT9M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/structured-concurrency-java-21-virtual-threads"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Structured Concurrency in Java 21: Virtual Threads and Server Architecture","item":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Are virtual threads a replacement for reactive frameworks like WebFlux?","acceptedAnswer":{"@type":"Answer","text":"For most I/O-bound workloads, yes. Virtual threads give you the throughput of reactive code with the readability of blocking code. Reactive still wins for backpressure-heavy streaming scenarios, but the majority of request/response services can drop the Mono/Flux complexity entirely."}},{"@type":"Question","name":"How many virtual threads can I realistically spawn on a single JVM?","acceptedAnswer":{"@type":"Answer","text":"Millions. Each virtual thread consumes roughly 1-2 KB of stack initially (grows as needed), compared to ~1 MB for platform threads. I've tested 4 million concurrent virtual threads on a 16 GB heap doing simple HTTP calls without breaking a sweat."}},{"@type":"Question","name":"Do virtual threads work with existing JDBC drivers?","acceptedAnswer":{"@type":"Answer","text":"Yes, but with caveats. Most JDBC drivers use synchronized blocks internally, which pins virtual threads to carrier threads. The PostgreSQL JDBC driver (42.7+) has been patched to use ReentrantLock. MySQL Connector/J is still catching up. Check your driver's source before assuming you'll get full benefits."}}]}]}],"contentHash":"451efd46f0dc50be297f627c3327cb30dde451b1d765dffd7c469a7da001ded8","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/structured-concurrency-java-21-virtual-threads","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":73156,"contentBytes":16541}}
