DEV Community

Machine coding Master
Machine coding Master

Posted on

JDK 26 Pitfalls: Why CPU-Bound Tasks are Killing Your Virtual Threads

JDK 26 Pitfalls: Why CPU-Bound Tasks are Killing Your Virtual Threads

In JDK 26, teams are blindly migrating entire microservices to virtual threads and wondering why their p99 latency is suddenly spiking into the seconds. The culprit is carrier thread starvation: developers are treating lightweight virtual threads like silver bullets, forgetting that cooperative scheduling requires yield points that CPU-bound tasks simply do not have.

Why Most Developers Get This Wrong

  • Treating virtual threads as "faster" threads rather than "cheaper to block" threads. This leads to CPU-heavy operations (like JWT validation or heavy JSON parsing) being scheduled on the default ForkJoinPool carrier pool, which is sized strictly to the number of available CPU cores.
  • Assuming the JVM will preemptively time-slice virtual threads. In reality, Project Loom relies on cooperative scheduling, meaning a thread only yields during blocking I/O (e.g., socket reads, database queries, or explicit locks).
  • Running un-yieldable CPU tasks that monopolize carrier threads, starving the other thousands of virtual threads waiting in the scheduler queue and completely halting the application's throughput.

The Right Way

Keep virtual threads strictly for I/O-bound operations and offload CPU-bound computations to a dedicated, sized platform thread pool.

  • Isolate CPU-heavy tasks (e.g., BCrypt hashing, Jackson serialization of massive payloads, or complex cryptography) using a traditional ThreadPoolExecutor sized strictly to the machine's physical cores.
  • Bridge the gap using CompletableFuture.supplyAsync(), allowing the calling virtual thread to park cleanly and yield its carrier thread while the platform thread handles the heavy lifting.
  • Actively monitor carrier thread pinning and starvation using JDK Flight Recorder (JFR) with the jdk.VirtualThreadPinned event to identify blocking native calls or synchronized blocks.

Shameless plug: javalld.com has full LLD implementations with step-by-step execution traces — free to use while prepping.

Show Me The Code (or Example)

// Inside a Virtual Thread handler
public Response handleRequest(Request req) {
    // I/O bound: Fetch from DB (Virtual thread yields here)
    var user = db.findUser(req.userId()); 

    // CPU bound: Offload to prevent Carrier Thread Starvation
    var token = CompletableFuture.supplyAsync(
        () -> jwtService.generateToken(user), CPU_PLATFORM_POOL
    ).join(); // Virtual thread yields cleanly while platform thread works

    return new Response(token);
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  • Virtual threads are designed for waiting, not for burning CPU cycles.
  • No yield point means carrier thread hijacking; keep ForkJoinPool free.
  • Always isolate CPU-bound tasks in a dedicated, sized platform ThreadPoolExecutor.

Top comments (0)