When should you not use virtual threads, and what is thread pinning?

Virtual threads help when tasks spend most of their time waiting. They don't help CPU-heavy work, and some code patterns stop them from unmounting from their carrier thread, which is called pinning. Pinned virtual threads hold an OS thread while blocked and lose their scalability benefit.

Key Points: • CPU-bound work (image processing, large calculations) gains nothing. Use a fixed pool sized to the number of cores. • Pinning: before Java 24, blocking inside a synchronized block pinned the carrier thread. Use ReentrantLock in those hot paths, or move to Java 24+, where JEP 491 removed this limitation. • Native calls (JNI) also pin the virtual thread. • Don't pool virtual threads, and don't rely on large ThreadLocal caches, because millions of threads each holding a copy wastes memory. • To limit access to a scarce resource (for example 20 database connections), use a Semaphore rather than a small thread pool. • Diagnose pinning with the JFR event jdk.VirtualThreadPinned.

Code Example:

import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;

public class VirtualThreadPitfalls {

    private static final ReentrantLock lock = new ReentrantLock();     // instead of synchronized
    private static final Semaphore dbConnections = new Semaphore(20);  // limit, not a pool
    private static final AtomicInteger maxInUse = new AtomicInteger();
    private static final AtomicInteger inUse = new AtomicInteger();

    static void queryDatabase() throws InterruptedException {
        dbConnections.acquire();
        try {
            maxInUse.accumulateAndGet(inUse.incrementAndGet(), Math::max);
            Thread.sleep(50);                                          // simulated query
        } finally {
            inUse.decrementAndGet();
            dbConnections.release();
        }
    }

    static void updateCache() {
        lock.lock();
        try { /* short critical section */ } finally { lock.unlock(); }
    }

    public static void main(String[] args) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 1000; i++) {
                executor.submit(() -> { queryDatabase(); updateCache(); return null; });
            }
        }
        System.out.println("1000 tasks finished");
        System.out.println("Max concurrent DB calls: " + maxInUse.get());
    }
}

Output:

1000 tasks finished Max concurrent DB calls: 20

Good to Know: Senior interviews often ask this right after "What are virtual threads?". Naming pinning, the synchronized versus ReentrantLock advice, the Java 24 fix and the Semaphore pattern covers what most interviewers are looking for.

Interview Tip: A concise interview answer is:

"I avoid virtual threads for CPU-bound work, and I watch out for pinning, where blocking inside synchronized or native code holds the carrier thread; I use ReentrantLock there or Java 24, which fixed the synchronized case. I don't pool virtual threads, and I limit scarce resources with a Semaphore."

Previous question: What are virtual threads in Java 21, and how are they different from platform threads?

Next question: How do you create and use virtual threads, including in Spring Boot?

More Java 17 and 21 Features interview questions

All Java 17 and 21 Features questions