Career Guide

Virtual Threads in Production: Common Pitfalls and How to Avoid Them

Virtual threads (Project Loom) make concurrent Java incredibly simple, but they bring new runtime behaviors. Here is a guide to thread pinning, carrier thread blocking, and tuning.

With the release of JDK 21, Java introduced virtual threads (Project Loom) as a production-ready feature. For decades, Java concurrency was tied directly to operating system (OS) threads. Every instance of java.lang.Thread was a platform thread, representing a 1:1 mapping to a kernel thread. Since OS threads are expensive to create, consume around 1MB of memory for stack space, and require context switching overhead, Java applications were limited in their scalability. The thread-per-request model meant a server could only handle a few thousand concurrent connections before running out of memory.

Virtual threads change everything. They introduce an M:N scheduling model where millions of virtual threads (M) can be scheduled on a much smaller pool of carrier threads (N), which are standard platform threads. When a virtual thread performs a blocking operation—such as querying a database, calling an external HTTP API, or sleeping—the JVM automatically unmounts the virtual thread from its carrier thread and parks it. The carrier thread is then free to execute other virtual threads. Once the blocking operation completes, the virtual thread is rescheduled on a carrier thread.

However, moving virtual threads to production is not a magic bullet. It introduces new runtime behaviors and pitfalls that can degrade performance if not handled correctly.

The Pitfall of Thread Pinning

Thread pinning occurs when a virtual thread is blocked but cannot be unmounted from its carrier thread. When this happens, the carrier thread is pinned. It cannot run other virtual threads, which reduces throughput.

There are two primary causes of thread pinning:

  1. Synchronized Blocks and Methods: When a virtual thread enters a synchronized block or method, the JVM pins it to its carrier thread. If the code inside the synchronized block performs a blocking I/O operation, the underlying carrier thread is also blocked.
  2. Native Method Calls: If a virtual thread executes a native method (e.g., via JNI or Foreign Function API) and blocks, the carrier thread is pinned.

In production, pinning can lead to starvation in the carrier thread pool (which defaults to the number of available processors). If all carrier threads get pinned by synchronized blocks waiting for slow database queries, the application will hang.

To identify pinning in your application, you can run the JVM with the system property:

-Djdk.tracePinnedThreads=full or -Djdk.tracePinnedThreads=short. This prints a stack trace whenever a thread is pinned.

Carrier Thread Blocking

Apart from pinning, some blocking operations in the JDK still block the underlying carrier thread directly instead of unmounting. While modern file I/O has been largely rewritten in JDK 21+ to support virtual threads, certain legacy native file operations or third-party libraries using native calls can still block the carrier thread. If you notice high CPU or low throughput despite using virtual threads, trace your blocking calls to ensure they are cooperative with Loom.

Connection Pool Sizing Changes

In a traditional thread-per-connection model, you might size your database connection pool (like HikariCP) to match the thread pool size (e.g., 50 to 100). With virtual threads, you can suddenly run thousands of concurrent tasks. If you let each virtual thread acquire a database connection, you will immediately overwhelm your database.

You must not pool virtual threads themselves—they are cheap and disposable. Instead, you should continue to pool scarce resources like database connections. Moreover, you may need to implement rate-limiting or semaphores to prevent thousands of virtual threads from requesting connections simultaneously. Keep your connection pool size tuned to what the database can actually handle, and use a Semaphore to throttle access rather than relying on thread pool limits.

Replacing Synchronized with ReentrantLock

To prevent thread pinning, you must refactor code that uses synchronized blocks around blocking operations. The solution is to use java.util.concurrent.locks.ReentrantLock. Unlike synchronized, ReentrantLock does not pin virtual threads when they wait to acquire the lock.

Here is an example of code that causes pinning:

public class LegacyQueueService {
    private final List<String> queue = new ArrayList<>();

    public synchronized void processTask(String task) {
        // Simulating a blocking network call inside a synchronized method
        try {
            Thread.sleep(100); // This blocks the carrier thread!
            queue.add(task);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

To fix this, we replace synchronized with ReentrantLock:

import java.util.concurrent.locks.ReentrantLock;

public class ModernQueueService {
    private final List<String> queue = new ArrayList<>();
    private final ReentrantLock lock = new ReentrantLock();

    public void processTask(String task) {
        lock.lock();
        try {
            // This blocking call will unmount the virtual thread safely
            Thread.sleep(100); 
            queue.add(task);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock();
        }
    }
}

By switching to ReentrantLock, you ensure that the carrier thread is freed up to execute other tasks while this virtual thread is sleeping or waiting. This simple refactoring is key to unlocking the full scaling potential of Java virtual threads in production.

📢 Share this article