Skip to main content
Java

Project Loom in 2026: What Shipped, What's Still Incubating

Ravinder··7 min read
JavaProject LoomVirtual ThreadsConcurrencyJVM
Share:
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 scope

ThreadLocal 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

graph TD A[Virtual Thread 1] -->|mount| C[Carrier / Platform Thread - ForkJoinPool] B[Virtual Thread 2] -->|mount| C D[Virtual Thread 3] -->|mounted elsewhere| E[Carrier / Platform Thread] F[Virtual Thread 4 - blocked on I/O] -->|unmounted| G[Continuation parked on heap] G -->|I/O completes| A2[Re-mounted on any carrier] subgraph JVM Scheduler C E end subgraph Heap G end style G fill:#f5a623,color:#000

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:

  1. You hold a synchronized monitor and the thread blocks.
  2. 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.jar

Or with JFR events: jdk.VirtualThreadPinned. Any duration over ~10 ms is worth investigating.

Libraries that pin in 2026:

  • JDBC drivers using synchronized internally (most thin drivers). Use the Loom-aware wrappers or switch to async drivers like vertx-pg-client exposed 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: true

Tomcat 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 allOf with individual failure handling, no anyOf keeping 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 wrappers

Performance 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 synchronized blocks that wrap blocking I/O and replace with ReentrantLock.
  • Structured Concurrency (StructuredTaskScope) is genuinely useful for fan-out patterns but remains in preview; treat its API as unstable.
  • ScopedValue is 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 synchronized around blocking calls.