How virtual threads work and what they change

A platform thread in Java is a thin wrapper around an operating system thread. Each one reserves a fixed-size stack up front and is scheduled by the kernel, so a few thousand of them is a meaningful cost in memory and context switching. A thread-per-request server sized at 200 threads can therefore have at most 200 requests in progress, even when most of those threads are doing nothing but waiting on a socket.

Project Loom's virtual threads separate the Java thread from the OS thread. The JVM runs virtual threads on a small pool of carrier threads, by default roughly one per CPU core. When a virtual thread makes a blocking call such as a JDBC query, an HTTP client request, or Thread.sleep, the JVM unmounts it. Its stack frames move to the heap, and the carrier is free to run another virtual thread. When the I/O completes, the virtual thread is mounted again, possibly on a different carrier, and continues from where it stopped.

Blocking gets cheaper, but the I/O itself does not get faster. A query that takes 40 ms still takes 40 ms, and per-request latency is unchanged. The change is in how many requests can wait at the same time: waiting costs a small heap object instead of an OS thread.

Treat the switch as a change to your capacity model, not a performance optimization. It raises the concurrency ceiling. It does not shorten any individual request.

  Each request -> its own virtual thread (VT1 ... VTN); thousands are fine

  +------------------------------+          +-----------------------------+
  | Carrier threads (OS threads) |          | Heap: unmounted virtual     |
  | ~1 per CPU core              |          | threads (stack frames as    |
  |                              |  unmount | small heap objects)         |
  |  +---------+  +---------+    |  on      |                             |
  |  | C1: VT1 |  | C2: VT2 |    |--------->|  [VT3] [VT4] [VT5] [VT6]    |
  |  +---------+  +---------+    |  block   |  [VT7] [VT8]  ...  [VTN]    |
  |  +---------+  +---------+    |          |                             |
  |  | C3: VT9 |  | C4: idle|    |<---------|  each one parked on JDBC,   |
  |  +---------+  +---------+    |  remount |  HTTP client, sleep ...     |
  |  mounted VTs run CPU work    |  when I/O|                             |
  +------------------------------+  done,   +--------------+--------------+
                                    any free               |   pending I/O
                                    carrier                v
                                            +-----------------------------+
                                            | DB / remote service:        |
                                            | a 40 ms query still takes   |
                                            | 40 ms on either thread type |
                                            +-----------------------------+

  Platform threads: 200-thread pool => at most 200 requests in flight.
  Virtual threads:  waiting costs a heap object => concurrency ceiling
                    rises; per-request latency is unchanged.

Virtual threads run mounted on a few carrier threads. A blocking call unmounts them to the heap, and they remount on any free carrier when the I/O completes, so many more requests can wait at once while each request takes just as long.

When threads are the real bottleneck: a Little's Law check

Little's Law states that average concurrency equals throughput multiplied by average time in the system (L = λ × W). Rearranged, a fixed pool of request threads puts a hard ceiling on throughput: λ = L / W.

Illustrative estimate, assumed inputs: 200 request threads, each request spending an average of 100 ms blocked on a database call or downstream HTTP call. The ceiling is 200 / 0.1 s = 2,000 requests per second. Beyond that, new requests queue in the connector backlog. Latency climbs while CPU may sit at 20%. Adding cores does nothing, because the threads are parked and not computing. Virtual threads target this case: services with many concurrent requests, each mostly waiting on I/O.

Before switching, confirm that the thread pool is the limiter and not something behind it:

  • Busy request threads near the pool maximum, with CPU low. This is the clearest signal that threads are the constraint.
  • Thread dumps under load. Most request threads should be blocked in socket reads or waiting on HTTP client responses.
  • Threads waiting to acquire a connection from the database or HTTP client pool. In this case the pool is the real limit. Virtual threads will only add more waiters to the same queue.
  • Downstream latency rising with load. The dependency is saturating. More concurrency from your side will make it worse.
  • High CPU. The workload is compute-bound. More threads only add contention.

Only the first pattern suggests virtual threads will raise throughput.

Enabling virtual threads in Spring Boot

On Boot versions that support it, virtual threads are controlled by a single property: spring.threads.virtual.enabled=true. Your JDK must also support virtual threads as a final, non-preview feature. Check the reference documentation for your exact Boot version before relying on it, because the set of components that react to the property differs between releases.

What the property switches over

When the flag is on, Boot's auto-configuration changes the threads it controls:

  • Servlet container request handling. The embedded container runs each incoming request on a virtual thread instead of a worker from its fixed-size pool. The container's max-threads setting stops limiting concurrency.
  • Spring-managed task executors. The auto-configured executor behind @Async and the auto-configured scheduler use virtual threads. They no longer use a bounded platform-thread pool.

Both changes remove a limit you may have depended on without realizing it. The old pool size capped how much work could reach your dependencies at the same moment.

What it leaves alone

The property affects only beans that Boot creates. It does not change any of these:

  • An ExecutorService you build yourself with Executors.newFixedThreadPool.
  • A ThreadPoolTaskExecutor bean that you declare explicitly.
  • Thread pools inside HTTP clients, database drivers and messaging libraries.

If request handlers pass work to one of these pools, that work still runs on platform threads and is still limited by the pool's size. Search your codebase for places that create executors directly. Decide one by one whether to migrate each executor or keep it as an intentional limit.

Roll out behind a flag

The property is a configuration value, so you can set it per environment through an environment variable or a profile. Turn it on for a canary instance first. Compare latency, error rates and downstream connection usage against instances with the flag off. If the canary looks worse, roll back by changing the config value, with no redeploy needed.

# application.yml
# Virtual threads stay off by default. Each environment opts in through
# an environment variable, so rollback is a config change, not a redeploy
# of new code. Confirm your Boot version supports this property first.
spring:
  threads:
    virtual:
      # Placeholder with default: unset means platform threads.
      # Set VIRTUAL_THREADS_ENABLED=true in staging first, then production.
      enabled: ${VIRTUAL_THREADS_ENABLED:false}

# When enabled, this affects only what Boot auto-configures:
#   - embedded servlet container request handling
#   - the auto-configured @Async executor and task scheduler
# It does NOT change executors you create yourself, such as
# Executors.newFixedThreadPool(...), an explicitly declared
# ThreadPoolTaskExecutor bean, or pools inside HTTP clients and drivers.

---
# Optional: force it on for a canary profile regardless of the variable.
spring:
  config:
    activate:
      on-profile: canary
  threads:
    virtual:
      enabled: true

An application.yml that gates spring.threads.virtual.enabled behind an environment variable defaulting to false, with an optional canary profile that turns it on.

The bottleneck moves downstream: connection pools and backpressure

Those downstream connection numbers matter because virtual threads remove only one limit: the cap on concurrent requests. A JDBC pool with 50 connections still allows exactly 50 concurrent queries. With a 200-thread Tomcat pool, at most 200 requests could compete for those connections. With virtual threads, thousands can, and every one beyond the fiftieth waits on pool acquisition. Latency moves from the request queue into pool wait time. If that wait exceeds the pool's acquisition timeout, requests fail with connection errors that look like database trouble but are really queueing.

Illustrative estimate: assume 50 connections and 20 ms per query. Throughput tops out near 2,500 queries per second. Above that rate, wait time grows without bound no matter how many threads exist.

Size the pool deliberately. Base it on what the database can sustain, not on expected request concurrency. Set the acquisition timeout short enough that callers fail before their own deadlines expire.

The fixed thread pool also provided backpressure you never configured. When it filled, new work waited in a bounded queue or was rejected. Virtual threads remove that limit, so add it back explicitly around each scarce downstream:

  • Semaphores or bulkheads cap in-flight calls per dependency.
  • Acquire with a timeout and reject when the permit doesn't arrive.
  • Bounded queues stop pending work and its memory from growing unchecked.

Without these limits, overload doesn't stop at your service. It reaches databases, caches and third-party APIs as connection storms, rate-limit errors and retry amplification. Fast rejection at your edge is cheaper than a cascading failure downstream.

import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import org.springframework.stereotype.Component;

// Bulkhead for one scarce downstream (the orders database).
// Virtual threads no longer cap concurrency, so this class does.
@Component
public class OrdersDbBulkhead {

    // Match the JDBC pool size so excess callers wait here briefly, not inside the pool.
    private static final int MAX_IN_FLIGHT = 50;
    // Keep this shorter than the pool's acquisition timeout and the request deadline.
    private static final long ACQUIRE_TIMEOUT_MS = 100;

    private final Semaphore permits = new Semaphore(MAX_IN_FLIGHT, true);

    public <T> T call(Supplier<T> work) {
        boolean acquired;
        try {
            acquired = permits.tryAcquire(ACQUIRE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BulkheadFullException("interrupted waiting for permit");
        }
        if (!acquired) {
            // Reject fast. Map this to HTTP 503 in an exception handler.
            throw new BulkheadFullException("orders DB bulkhead full");
        }
        try {
            return work.get();
        } finally {
            permits.release(); // Always return the permit, even when the query fails.
        }
    }

    public static class BulkheadFullException extends RuntimeException {
        public BulkheadFullException(String msg) { super(msg); }
    }
}

A semaphore-based bulkhead caps in-flight database calls at the pool size and rejects callers that cannot get a permit within 100 ms, so overload causes fast failures instead of unbounded queueing on the connection pool.

Thread pinning, ThreadLocals and observability

A virtual thread only frees its carrier when it blocks somewhere the JDK can unmount it. When it blocks while pinned, it holds the carrier for the whole wait. Historically the common cause was blocking inside a synchronized block or method. Native and JNI frames still pin. The carrier pool is sized near the core count, so a few pinned waits on a hot path can occupy every carrier. Throughput then collapses while CPU stays low, which looks like a downstream outage rather than a scheduling problem. Newer JDKs have removed most synchronized-related pinning. Check how the JDK you actually deploy behaves, and don't rely on advice written for an older release.

Detecting it

  • JDK Flight Recorder: record the jdk.VirtualThreadPinned event under load. Its stack traces show which lock or native frame held the carrier.
  • Thread dumps: use a dump that includes virtual threads, such as jcmd <pid> Thread.dump_to_file. Classic dumps can leave them out.
  • Load tests: compare p50 and p99 latency and carrier utilization against the platform-thread baseline. If p99 grows while carriers sit busy and CPU stays idle, suspect pinning.

ThreadLocals and context

Virtual threads are not pooled. Any ThreadLocal used as a cache, such as a reusable buffer or formatter, gets rebuilt for every task, so the cache only adds allocation cost.

Per-request state is different. MDC values and Spring Security's context are request-scoped and still work, but only on the request's own thread. If you hand work to another executor, propagate that state explicitly.

Tooling

Audit libraries that use ThreadLocal caches or native calls. Dashboards and alerts built on Tomcat busy-thread counts or pool saturation lose their meaning. Replace them with signals for in-flight requests and connection-pool wait time.

#!/usr/bin/env bash
# Investigate suspected carrier pinning in a running Spring Boot service
# (virtual threads enabled via spring.threads.virtual.enabled=true).
# Run this while a load test is driving the hot path.
set -euo pipefail

PID="${1:?usage: $0 <pid>}"   # find it with: jcmd -l
OUT=/tmp/pinning-check
mkdir -p "$OUT"

# 1. Record JFR for 60s under load. The default settings enable
#    jdk.VirtualThreadPinned for pinned waits above a short threshold.
jcmd "$PID" JFR.start name=pinning settings=default \
  duration=60s filename="$OUT/pinning.jfr"

# 2. Halfway through, take a thread dump that includes virtual threads.
#    A classic jstack-style dump can leave them out.
sleep 30
jcmd "$PID" Thread.dump_to_file -format=json "$OUT/threads.json"

# Wait for the recording to finish and be written to disk.
sleep 35

# 3. Show how many pinning events were recorded.
jfr summary "$OUT/pinning.jfr" | grep -i VirtualThreadPinned || \
  echo "no pinning events recorded"

# 4. Print the pinned events with stack traces. Look for a monitor
#    (synchronized) or native frame that sits under a blocking call
#    such as socket reads or a JDBC driver.
jfr print --events jdk.VirtualThreadPinned "$OUT/pinning.jfr" \
  > "$OUT/pinned-events.txt"
echo "pinned stacks: $OUT/pinned-events.txt"
echo "thread dump:   $OUT/threads.json"

A shell script that records JDK Flight Recorder pinning events and takes a thread dump that includes virtual threads, so you can find which lock or native frame holds a carrier while the service is under load.

When to avoid virtual threads and what the alternatives are

Virtual threads only help when requests spend most of their time waiting. Four cases add migration risk without adding throughput:

  • CPU-bound handlers. Virtual threads run on a carrier pool sized to the available cores. Image resizing, heavy serialization or crypto saturates those cores either way, and accepting more concurrent requests just lengthens queues and tail latency.
  • Low-concurrency services. If the Little's Law estimate is below your current pool size, threads were never the constraint.
  • Downstream-limited services. A database pool of 30 connections caps throughput at 30 concurrent queries, however many threads wait on it.
  • Existing WebFlux stacks. These already avoid blocking threads. Rewriting them trades away operators and backpressure for a simpler programming model, which is a real tradeoff, not a free upgrade.
Criterion Virtual threads Tuned platform pool Reactive (WebFlux)
Code simplicity Plain blocking code Plain blocking code Operator chains, steeper learning curve
Stack traces Full, sequential Full, sequential Fragmented across operators
Streaming and backpressure Manual: semaphores, bounded queues Implicit through pool limits Built-in demand signalling
Best fit High-concurrency blocking I/O Moderate, predictable load Streaming, fan-out, slow consumers

Moving off WebFlux makes sense if the team struggles to maintain the reactive code and the workload is plain request/response. If the service streams data to slow clients or depends on demand-driven flow control, keep the reactive stack. With virtual threads you would have to rebuild those guarantees by hand.

Migration checklist and impact on cost and engineering time

For everything else, migrate in this order. Each step surfaces problems that the next one would otherwise hide.

  1. Profile where threads actually wait. Take thread dumps under production-like load. If most request threads are parked on socket reads or JDBC calls, you have a candidate. If they are mostly runnable, more threads will not help.
  2. Audit synchronized blocks and native calls on I/O paths. Check drivers, HTTP clients, caches and your own code. Any of them can pin a carrier thread while it blocks.
  3. Review pool sizes and timeouts. The old thread-pool size was acting as a concurrency limit. Once it is gone, connection pools and downstream timeouts become the limit that actually applies.
  4. Add explicit concurrency limits. Put a semaphore or bulkhead in front of each downstream dependency. Requests the old pool would have queued then wait in your service instead of piling onto the database.
  5. Load test against realistic downstream latency. Mocks that answer in a millisecond hide pool exhaustion and pinning.
  6. Roll out behind a config flag. Toggle spring.threads.virtual.enabled per environment, and compare a canary against the baseline on latency percentiles and pool wait time.
Reactive rewrite Virtual threads Stay on platform threads
Code changes Large: new APIs, operators Small: config plus targeted fixes None
Debugging Fragmented stack traces Ordinary imperative traces Ordinary imperative traces
New work Retraining, rewriting tests Library audit, load tests, observability updates None
Pays off when Streaming, backpressure High-concurrency blocking I/O CPU-bound or low concurrency

Compared with a reactive rewrite, virtual threads cost far less. You keep the existing code and the readable traces. The work is still real, though: audits, load tests and dashboard changes take engineering time. For CPU-bound or low-concurrency services, that time buys nothing, so leave those services alone.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to spring boot virtual threads, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.