Java Concurrency & Async Programming · Lesson 9

Java Virtual Threads for Backend Services

Use Java 21 virtual threads for blocking backend tasks while keeping CPU, connection pools, downstream limits, context, and pinning explicit.

Virtual threads make the thread-per-request style scalable for large numbers of tasks that spend substantial time waiting. They preserve familiar blocking code and stack traces while the runtime schedules virtual threads over a smaller set of carrier platform threads. They are not a universal throughput switch.

Quick answer

Java 21 virtual threads are lightweight Thread instances designed for high-throughput blocking workloads. Use one virtual thread per task rather than pooling virtual threads as a concurrency limit. They can reduce the cost of waiting threads, but they do not guarantee lower per-request latency and do not increase CPU, connection-pool, lock, memory, or downstream capacity. Guard scarce resources with semaphores, bulkheads, admission control, or the owning service.

Learning objectives

  • Decide when Java 21 virtual-thread-per-task execution fits a blocking backend workload and when it does not.
  • Preserve downstream capacity, observability, context, cancellation, and pinning evidence while adopting virtual threads in Spring Boot.

Prerequisites

Read ExecutorService, Thread Pools, and Work Queues and Java Interruption, Cancellation, and Deadlines. Virtual Threads is shared with the Spring Backend course while its primary sequence remains the Java Concurrency course and topic cluster.

What changes—and what does not

A platform thread is a comparatively scarce OS-backed execution resource. A virtual thread can park during supported blocking operations so its carrier runs other work. That makes a large set of mostly waiting tasks practical without rewriting the application into callback chains.

CPU-bound work still needs CPU time. Eight thousand virtual threads doing computation do not create more cores. Eight thousand database requests still compete for the connection pool and database. Removing an executor queue may cause more tasks to reach a downstream boundary at once, which is why resource admission must remain explicit.

Java 21 baseline example

import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

final class VirtualThreadQueries {
    private final Semaphore databasePermits = new Semaphore(20);

    void runQueries(Iterable<Runnable> queries) throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (Runnable query : queries) {
                executor.submit(() -> {
                    databasePermits.acquire();
                    try {
                        query.run();
                    } finally {
                        databasePermits.release();
                    }
                    return null;
                });
            }
        }
    }
}

The permit count is illustrative, not a production recommendation. Derive it from verified connection and database capacity. Closing the executor waits for submitted tasks; production request paths also need deadlines, rejected admission above the resource guard, and shutdown policy.

Do not pool virtual threads to throttle them

The JEP guidance is explicit: represent every concurrent task with a virtual thread and use constructs such as semaphores to limit access to scarce services. Pooling virtual threads conflates cheap task representation with business resource capacity. A semaphore says which resource is guarded and can be measured independently.

Pinning and Java 21 diagnostics

A virtual thread cannot unmount from its carrier in every situation. In Java 21, blocking while holding an intrinsic monitor or entering native/foreign code can pin it. Pinning is not automatically an error; frequent long-lived pinning can reduce scalability. JFR can record pinned-thread events, and the Java 21 diagnostic property -Djdk.tracePinnedThreads=full can help during investigation. Do not replace every synchronized block speculatively—measure relevant blocking first, then shorten the blocking region or use an appropriate lock.

Spring Boot boundary

Spring Boot can enable virtual threads with spring.threads.virtual.enabled=true on supported Java versions. This changes task execution defaults described by the selected Boot release; verify the exact framework and embedded server behavior. Virtual threads are daemon threads, so Spring Boot documents spring.main.keep-alive=true for applications whose scheduled virtual-thread work must keep the JVM alive.

@Async, transactions, SecurityContext, MDC, and arbitrary ThreadLocal data retain their own boundaries. A thread switch does not automatically propagate a transaction or authorization. Thread-local caching that was cheap for a small platform pool can multiply memory use across many virtual threads. Prefer explicit data flow and review each supported context mechanism.

Failure scenario: queue removed, database overwhelmed

A service replaces a 100-thread pool with virtual-thread-per-task execution. Request queueing disappears and latency initially improves. During a spike, thousands of tasks immediately wait for a 50-connection pool; caller deadlines expire while work remains admitted. Increasing the connection pool overloads the database. The correct design adds admission and a database guard, expires stale work before acquisition, propagates deadlines into queries, and sheds excess demand.

Common misconceptions

  • Virtual threads do not guarantee lower single-request latency.
  • Virtual threads do not increase CPU, connection pool, or downstream capacity.
  • A virtual thread is still a Thread, but old assumptions about thread scarcity and ThreadLocal caching need review.
  • Pinning is not the same as ordinary parking and must be diagnosed with evidence.
  • Virtual threads do not make external side effects cancelable or exactly once.

Production validation

Establish user latency and throughput baselines. Compare active requests, database acquisition, downstream concurrency, CPU, allocation, timeout, cancellation, and post-deadline completion. Record JFR virtual-thread start/end and pinned events when appropriate, inspect thread dumps, and validate shutdown. Load-test representative blocking—not synthetic thread creation alone. Roll out gradually with guardrails and confirm the durable business outcome, not only JVM task completion.

Use the Java Concurrency Budget Lab to contrast executor admission and resource demand. Continue with Scoped Values and Structured Concurrency and Production Java Concurrency Troubleshooting.

FAQ

Should I create a fixed pool of virtual threads?

No for ordinary throttling. Use virtual-thread-per-task execution and separately guard the resource whose capacity is limited.

Do virtual threads make CPU work faster?

No. CPU-bound throughput remains constrained by available processors and scheduling overhead.

Does Spring automatically propagate every request context?

No. Transactions, security, MDC, ThreadLocal state, deadlines, and cancellation each require an explicit supported contract.

Follow the Java Concurrency course, explore its topic cluster, and connect the Spring execution boundary through the Spring Backend course.

Official sources

Knowledge check

Check your understanding

Answer both questions correctly to mark this lesson as mastered. You can retry without penalty.

1. Why should a backend avoid pooling virtual threads merely to throttle database access?

2. What performance claim is valid after enabling virtual threads?