Question 24 · Section 9

When Should You Use Virtual Threads?

Virtual Threads (VT) are not a replacement for regular threads, but a specialized tool for specific tasks. They provide benefits when the application spends most of its time wai...

Language versions: English Russian Ukrainian

Junior Level

Basic Understanding

Virtual Threads (VT) are not a replacement for regular threads, but a specialized tool for specific tasks. They provide benefits when the application spends most of its time waiting (I/O): DB responses, microservices, reading from S3, HTTP requests.

Why VT help specifically with I/O: when a regular thread calls a blocking operation (e.g., socket.read()), it sleeps and occupies 1MB of OS memory. A virtual thread on the same operation “unmounts” — the JVM saves its stack to the heap (several KB) and gives the Carrier Thread to another virtual thread. When I/O completes, the VT “mounts” back and continues.

Ideal Scenarios for VT

1. I/O-bound Loads (Waiting)

If the application spends 90% of time waiting (DB response, microservice, reading from S3):

// VT — the best choice
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request request : requests) {
        executor.submit(() -> {
            String data = httpClient.get(url);    // Wait for network
            Result result = db.query(data);       // Wait for DB
            s3.upload(result);                     // Wait for S3
            return result;
        });
    }
}
// While one VT waits — Carrier Thread processes other VTs
// Carrier Thread — this is the real OS thread on which tens of thousands of VTs "sit"

2. Thread-per-Request Model

// Simple and clear code instead of reactive
public void handleRequest(HttpServletRequest req, HttpServletResponse resp) {
    String data = database.query(req.getParameter("id")); // Blocking call
    String processed = externalApi.call(data);            // Blocking call
    resp.getWriter().write(processed);                    // Blocking call
}
// With VT: a million such requests are handled in parallel!

When You MUST NOT Use VT

1. CPU-bound Tasks (Computations)

// BAD: VT for computations
Thread.ofVirtual().start(() -> {
    calculatePi(1_000_000); // No blocking — VT won't unmount
});
// JVM scheduler overhead with no benefit

// GOOD: FixedThreadPool
ExecutorService cpuPool = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors() + 1
);
cpuPool.submit(() -> calculatePi(1_000_000));

2. Resource Limiting (Throttling)

// BAD: VT without limiting to DB
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        executor.submit(() -> {
            database.execute("UPDATE ..."); // A million DB queries at once!
        });
    }
}
// DB will crash!

// GOOD: Semaphore for limiting
Semaphore dbSemaphore = new Semaphore(20); // Max 20 DB connections

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        executor.submit(() -> {
            dbSemaphore.acquire();
            try {
                database.execute("UPDATE ...");
            } finally {
                dbSemaphore.release();
            }
        });
    }
}

Middle Level

Spring Boot 3.2+ with VT

# application.yml
spring:
  threads:
    virtual:
      enabled: true
// All of Tomcat/Undertow switches to VT automatically
@RestController
public class MyController {
    @GetMapping("/user/{id}")
    public User getUser(@PathVariable Long id) {
        // Each request — a separate VT
        return userService.findById(id); // Blocking call — OK!
    }
}

Pinning Problem

A virtual thread “sticks” to a Carrier Thread and doesn’t unmount:

Cause Impact Solution
synchronized Carrier Thread blocked ReentrantLock
Native methods (JNI) Carrier Thread blocked Separate thread
// BAD: synchronized in VT
public synchronized Data loadData() {
    return httpClient.get(url); // VT "stuck" — Carrier Thread busy
}

// GOOD: ReentrantLock
private final ReentrantLock lock = new ReentrantLock();

public Data loadData() {
    lock.lock();
    try {
        return httpClient.get(url); // VT unmounts — Carrier is free
    } finally {
        lock.unlock();
    }
}

ThreadLocal Problem

// Million VT = million ThreadLocal copies = Memory Problem!
ThreadLocal<Context> context = ThreadLocal.withInitial(Context::new);

// Solution: Scoped Values (Java 21 Preview)
static final ScopedValue<Context> CONTEXT = new ScopedValue<>();

ScopedValue.runWhere(CONTEXT, new Context(), () -> {
    process(); // CONTEXT.get() is available
});

Dependency Audit

Before switching to VT:

# Check libraries for synchronized blocks
java -Djdk.tracePinnedThreads=full -jar app.jar

# Look in logs for:
# "VirtualThread pinning" — points to problem areas

Typical problematic libraries:

  • Old JDBC drivers (use synchronized)
  • java.text.SimpleDateFormat (inside synchronized)
  • Some old HTTP clients

Senior Level

Under the Hood: Why Pinning Happens

synchronized Does Not Support Unmount

VT enters synchronized:
  1. Acquires monitor (as usual)
  2. Blocking operation inside
  3. JVM CANNOT unmount — must hold the monitor
  4. VT "sticks" to Carrier Thread
  5. Carrier Thread busy → other VTs wait

Result: commonPool clogged → all parallel streams slow down

Native Methods

VT calls native method:
  1. Transition to native code (JNI)
  2. JVM doesn't know when native code finishes
  3. Cannot unmount — stack is in native code
  4. VT "sticks" until native returns

Throttling via Semaphore

public class ThrottledExecutor {
    private final Semaphore semaphore;
    private final ExecutorService virtualExecutor;

    public ThrottledExecutor(int maxConcurrent) {
        this.semaphore = new Semaphore(maxConcurrent);
        this.virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
    }

    public Future<?> submit(Runnable task) {
        return virtualExecutor.submit(() -> {
            semaphore.acquire();
            try {
                task.run();
            } finally {
                semaphore.release();
            }
        });
    }
}

// Usage:
ThrottledExecutor dbExecutor = new ThrottledExecutor(20); // Max 20 to DB
ThrottledExecutor apiExecutor = new ThrottledExecutor(100); // Max 100 to API

Performance and Benchmarks

Scenario: 10,000 HTTP requests (latency 100ms each)

Platform Threads (100 pool):
  - Time: ~10 seconds
  - Threads: 100
  - Memory: ~100MB

Virtual Threads:
  - Time: ~1 second
  - Threads: 10,000
  - Memory: ~50MB
  - Carrier Threads: 8

Diagnostics

-Djdk.tracePinnedThreads=full

# Prints full stack trace on every pinning
java -Djdk.tracePinnedThreads=full MyApp

# Output:
# VirtualThread pinning detected at:
#   at java.net.SocketInputStream.socketRead0(Native Method)
#   at com.example.MyClass.synchronizedMethod(MyClass.java:42)

JMX Monitoring

// Metric: number of pinned threads
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
ObjectName name = new ObjectName("jdk:type=VirtualThread");
Long pinned = (Long) mbs.getAttribute(name, "VirtualThreadPinned");

if (pinned > 0) {
    log.warn("Pinned virtual threads detected: {}", pinned);
}

Thread Dumps

# jstack is not efficient for a million threads
jstack <pid> # Can take minutes

# Use instead:
jcmd <pid> Thread.dump_to_file -format=json threads.json
# JSON format — faster and more efficient

When VT Provide Maximum Benefit

Scenario Benefit
API Gateway (proxying) 10x-100x throughput
Web Scraper (many HTTP requests) 5x-50x throughput
Microservice with DB + HTTP calls 3x-10x throughput
File processor (I/O) 5x-20x throughput

When VT Are Useless or Harmful

Scenario Problem Solution
Computations (ML, crypto) No blocking FixedThreadPool
synchronized-heavy legacy Pinning ReentrantLock
Limited resources (DB) Need throttling Semaphore
ThreadLocal-heavy Memory Scoped Values

Best Practices

  1. VT for I/O-bound — API gateway, web server, proxy
  2. ReentrantLock instead of synchronized — avoid pinning
  3. Semaphore for limiting — instead of pool size limits
  4. Scoped Values instead of ThreadLocal — for context
  5. Dependency audit — check libraries for synchronized
  6. -Djdk.tracePinnedThreads=full — during development
  7. Update JDBC/HTTP drivers — VT-compatible versions
  8. Not for CPU-bound — use FixedThreadPool for computation
  9. Monitor VirtualThreadPinned — via JMX/JFR
  10. Spring Boot 3.2+spring.threads.virtual.enabled=true

Interview Cheat Sheet

Must know:

  • VT ideal for I/O-bound: web servers, API gateway, proxy, microservices with DB + HTTP calls
  • VT NOT suitable for CPU-bound (computations, ML, cryptography) — use FixedThreadPool(N+1)
  • When working with limited resources (DB pool of 20 connections) — VT + Semaphore
  • Pinning: synchronized and JNI don’t allow VT to unmount; replace synchronized with ReentrantLock
  • ThreadLocal + million VT = Memory Problem; alternative — Scoped Values (Java 21 Preview)
  • Dependency audit is mandatory: old JDBC drivers, SimpleDateFormat use synchronized
  • Benefits: API Gateway 10-100x throughput, Web Scraper 5-50x, microservice 3-10x

Frequent follow-up questions:

  • How to limit simultaneous DB queries when using VT? — Semaphore with a limit, wrapped in VT executor
  • Why does Spring Boot 3.2+ simplify VT migration?spring.threads.virtual.enabled=true switches all of Tomcat to VT automatically, without changing controller code
  • How to check if a library is VT-compatible? — Run with -Djdk.tracePinnedThreads=full and check logs for “VirtualThread pinning”
  • What to do if a dependency uses synchronized internally? — Wrap the call in a separate Platform Thread or replace the library

Red flags (DO NOT say):

  • “VT are always faster — should be used everywhere” — VT are slower for CPU-bound and add overhead at low contention
  • “A million VT to DB without limits — is fine” — DB will crash, need Semaphore
  • “VT migration is free” — requires auditing dependencies for synchronized, updating JDBC/HTTP drivers
  • “VT solve the ThreadLocal problem” — VT make the problem worse: million VT = million ThreadLocal copies

Related topics:

  • [[23. What are Virtual Threads in Java 21]]
  • [[24. What are the advantages of Virtual Threads over regular threads]]
  • [[26. What is structured concurrency]]
  • [[20. How to prevent deadlock]]