Virtual Thread Best Practices

Naveen AnamaneniUpdated · 5 min read

Virtual threads are easy to start using, but they change some habits that were right for platform threads. Because they are cheap, you don't pool them; you limit resources directly instead. A few operations can pin a virtual thread to its carrier, so that blocking ties up a real OS thread. Thread-local caches that made sense for a few hundred pooled threads can waste memory with a million virtual ones. In this lesson, you will learn the rules for using virtual threads well.

The Rules

DoDon't
Create a new virtual thread per taskPut virtual threads in a pool
Limit access to scarce resources with a SemaphoreUse a small thread pool to limit concurrency
Write simple blocking codeRewrite everything as callbacks
Use virtual threads for I/O-bound tasksUse them to speed up CPU-bound loops
Keep ThreadLocal values small, or use scoped valuesCache large objects in ThreadLocal per thread
Watch for pinning in native codeAssume every blocking call unmounts

Limiting Concurrency with a Semaphore

With platform threads, a pool of 10 threads also meant "at most 10 database calls at once". With one virtual thread per task, there is no pool, so limit the resource instead:

LimitedDb.java▶ Try it
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
 
public class LimitedDb {
    static final Semaphore connections = new Semaphore(10);
    static final AtomicInteger inUse = new AtomicInteger();
    static final AtomicInteger maxInUse = new AtomicInteger();
 
    static String query(int id) throws InterruptedException {
        connections.acquire();
        try {
            maxInUse.accumulateAndGet(inUse.incrementAndGet(), Math::max);
            Thread.sleep(50);
            return "row " + id;
        } finally {
            inUse.decrementAndGet();
            connections.release();
        }
    }
 
    public static void main(String[] args) {
        AtomicInteger rows = new AtomicInteger();
        try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 1000; i++) {
                int id = i;
                ex.submit(() -> {
                    query(id);
                    return rows.incrementAndGet();
                });
            }
        }
        System.out.println("Rows fetched: " + rows.get());
        System.out.println("Most connections used at once: " + maxInUse.get());
    }
}
Output
Rows fetched: 1000
Most connections used at once: 10

Explanation: All 1,000 tasks get their own virtual thread, but the Semaphore lets only 10 into query at a time, matching a database that accepts 10 connections. The other virtual threads wait cheaply in acquire(), unmounted from their carriers.

Pinning

A virtual thread is pinned when it can't be unmounted while it blocks, so it holds on to its carrier (an OS thread). Since Java 24, synchronized blocks no longer pin (JEP 491). Pinning still happens when a virtual thread blocks:

  • inside a native method or a foreign function call, or
  • inside certain JVM internals, such as some class initialization.

Short pins are harmless. Long, frequent pins can exhaust the carriers, so other virtual threads can't run. You can spot them with Java Flight Recorder, which records a jdk.VirtualThreadPinned event whenever a pinned virtual thread blocks for too long.

ThreadLocal with Virtual Threads

ThreadLocal still works, but every virtual thread gets its own copy. A pattern such as "cache a 1 MB buffer in a ThreadLocal" was fine for 200 pooled threads, but with 100,000 virtual threads it means 100,000 buffers. Prefer passing objects explicitly, using a shared thread-safe object, or using scoped values (see "Scoped Values in Java").

Common Mistakes

Executors.newFixedThreadPool(100, Thread.ofVirtual().factory()). It pools virtual threads and caps concurrency in the wrong place; use a semaphore.

Large per-thread caches. Memory grows with the number of virtual threads.

Assuming synchronized still pins. It hasn't since Java 24, although very long critical sections are still bad practice.

Interview Corner

What are the best practices for virtual threads?

Create one virtual thread per task and never pool them; to limit concurrency on scarce resources such as database connections, use a Semaphore or the resource's own pool. Keep code simple and blocking. Avoid heavy per-thread state in ThreadLocal, since there may be millions of threads, and consider scoped values. Use them for I/O-bound work and keep CPU-bound work on platform-thread pools. Watch for pinning, which since Java 24 mainly comes from native or foreign calls, using JFR's jdk.VirtualThreadPinned event.

Practice

  1. Easy: Change the semaphore in LimitedDb to 25 permits and predict the new maximum and the total time.
  2. Harder: Write a crawler that fetches 500 simulated pages with virtual threads, but never more than 20 at a time per website, using one semaphore per site in a ConcurrentHashMap.

Quick check: Virtual thread best practices

4 questions
1Should you put virtual threads in a pool?
2How do you limit concurrent database calls with virtual threads?
3Since Java 24, does synchronized pin a virtual thread?
4Which JFR event reports pinning?

Key Takeaways

Was this tutorial helpful?

Lesson 430 of 482