Project Loom in 2026: What Shipped, What's Still Incubating
The hype cycle for Project Loom peaked somewhere around 2022. "A million threads!" the benchmarks screamed. What followed was three years of production experience separating signal from noise. This post is that accounting—written for engineers who already skipped the hello-world demos and want to know what Loom looks like when it hits real workloads in 2026.
What Actually Shipped
Virtual threads graduated to a standard, non-preview feature in Java 21 (September 2023). By now, any team on Java 21+ has them available without feature flags. The core guarantee is simple: a virtual thread is a lightweight thread managed by the JVM scheduler, mounted on a platform (OS) thread called a carrier. When a virtual thread blocks on I/O, the carrier is released and can carry other virtual threads. The thread-per-request model scales again.
// The API is deliberately boring — that's the point
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = IntStream.range(0, 10_000)
.mapToObj(i -> executor.submit(() -> fetchOrder(i)))
.toList();
for (var f : futures) {
process(f.get());
}
}Ten thousand tasks. No reactive glue. No callback hell. The executor shuts down cleanly via AutoCloseable. This is the entire pitch, and it delivers.
Structured Concurrency is still in preview as of Java 23/24. The StructuredTaskScope API exists, but the final shape keeps shifting. ShutdownOnFailure and ShutdownOnSuccess are the two scopes that shipped, and they're genuinely useful:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<UserProfile> profile = scope.fork(() -> fetchProfile(userId));
Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
scope.join().throwIfFailed(); // blocks until both done or either fails
return new Dashboard(profile.get(), orders.get());
}If fetchProfile throws, fetchOrders is cancelled. You get structured lifetimes for free. The catch: this is still --enable-preview. Ship it to production at your own risk; the API will change.
ScopedValue — also preview — replaces ThreadLocal for virtual threads. The semantics difference matters:
static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance();
// Caller
ScopedValue.where(CTX, new RequestContext(traceId))
.run(() -> handleRequest(req));
// Deep callee
RequestContext ctx = CTX.get(); // always valid within the scopeThreadLocal values are inherited across Thread.startVirtualThread() calls, which causes surprising memory retention. ScopedValue is bounded to the dynamic scope of the run() call. The GC story is cleaner, the semantics are more explicit.
The Architecture of Virtual Thread Scheduling
The scheduler is a work-stealing ForkJoinPool. The number of carrier threads defaults to Runtime.getRuntime().availableProcessors(). You can tune it with -Djdk.virtualThreadScheduler.parallelism=N, but for I/O-heavy workloads the default is usually correct. Do not increase it to "create more parallelism" — you'll just create more OS threads and defeat the purpose.
Pinning: The Production Gotcha
Pinning is what happens when a virtual thread cannot unmount from its carrier. It blocks the entire carrier thread. Your throughput silently degrades back toward the pre-Loom baseline.
You are pinned when:
- You hold a
synchronizedmonitor and the thread blocks. - You call native code (JNI) that blocks.
// This will pin the carrier thread
synchronized (this) {
var result = jdbcStatement.executeQuery(); // blocks while synchronized → PINNED
}
// This will NOT pin
var lock = new ReentrantLock();
lock.lock();
try {
var result = jdbcStatement.executeQuery(); // blocks while holding ReentrantLock → unmounts cleanly
} finally {
lock.unlock();
}Diagnose pinning with JFR:
java -XX:+EnableDynamicAgentLoading \
-Djdk.tracePinnedThreads=full \
-jar myapp.jarOr with JFR events: jdk.VirtualThreadPinned. Any duration over ~10 ms is worth investigating.
Libraries that pin in 2026:
- JDBC drivers using
synchronizedinternally (most thin drivers). Use the Loom-aware wrappers or switch to async drivers likevertx-pg-clientexposed through a synchronous adapter. ObjectInputStream/ObjectOutputStream— still synchronized.- Legacy
Hashtable,Vector— obvious but still lurks in old codebases.
Spring Boot 3.2+ enables virtual threads via a single property:
spring:
threads:
virtual:
enabled: trueTomcat switches to virtual-thread-per-request mode. Watch your metrics for carrier thread saturation — it shows up as unexpected latency spikes, not obvious errors.
What Structured Concurrency Still Lacks
The StructuredTaskScope API does not yet have:
- Timeout propagation: you can
scope.joinUntil(Instant), but there is no automatic deadline inheritance from a parent scope. - Cancellation tokens: no standard way for a child task to check if it's been cancelled cooperatively.
- Rich combinators: no
allOfwith individual failure handling, noanyOfkeeping the winner and cancelling the rest (ShutdownOnSuccess is close but coarse).
These are solvable in userspace today. They will be standardized — just not yet.
ScopedValue vs ThreadLocal Decision Matrix
| Concern | ThreadLocal | ScopedValue |
|---|---|---|
| Mutable state per thread | Yes | No (immutable within scope) |
| Inherited by child threads | Yes (copied) | Yes (read-only) |
| GC-safe with virtual threads | Risky — long-lived threads hold refs | Safe — bounded by run() scope |
| API stability | Stable since Java 1.2 | Preview, changing |
| Debugging | Hard | Easier (explicit scope) |
For new code on Java 21+: use ScopedValue for read-only context (trace IDs, tenant IDs, auth context). Keep ThreadLocal only where you genuinely need mutable per-call state and you understand the lifecycle.
Practical Migration Checklist
Before flipping virtual.enabled: true on your Spring Boot service:
[ ] Audit synchronized blocks in your own code — replace with ReentrantLock where they contain I/O
[ ] Check third-party JDBC driver release notes for Loom compatibility
[ ] Enable -Djdk.tracePinnedThreads=short in staging, run load test
[ ] Remove thread pool sizing tuning (e.g., server.tomcat.threads.max) — irrelevant for virtual threads
[ ] Test with JFR: jdk.VirtualThreadPinned events for >10ms
[ ] Monitor carrier thread count via JMX (java.lang:type=Threading, virtualThreadCount)
[ ] Replace ThreadLocal-based context propagation with ScopedValue or MDC wrappersPerformance Reality Check
Virtual threads are not faster than platform threads for CPU-bound work. If your bottleneck is computation, Loom does nothing. The wins are:
- I/O-bound services: request throughput increases proportionally with concurrency depth. A service doing 50 ms database calls can handle 20× more concurrent requests with the same carrier thread count.
- Memory: a virtual thread at rest costs ~1KB of heap versus ~1MB of OS stack for a platform thread. Ten thousand idle virtual threads cost 10 MB. Ten thousand platform threads cost 10 GB.
- Code style: synchronous code that reads linearly, no flatMap chains.
Microbenchmarks showing "1 million virtual threads" measure thread creation overhead, not real-world throughput. Benchmark your actual workload.
Key Takeaways
- Virtual threads are stable and production-ready since Java 21; flip the Spring Boot property and run load tests before calling it done.
- Pinning is the silent killer — audit
synchronizedblocks that wrap blocking I/O and replace withReentrantLock. - Structured Concurrency (
StructuredTaskScope) is genuinely useful for fan-out patterns but remains in preview; treat its API as unstable. ScopedValueis the right model for request-scoped context on virtual threads, but wait for GA before standardizing on it in shared libraries.- Virtual threads do not help CPU-bound workloads — profile first, then decide if Loom applies.
- The ecosystem gap is narrowing but real: check your JDBC driver, your connection pool (HikariCP 5.1+ is Loom-aware), and any library that uses
synchronizedaround blocking calls.