How Project Loom virtual threads work in Spring Boot

A virtual thread is a java.lang.Thread that the JVM schedules, not the operating system. Its stack lives on the heap as a growable object instead of a fixed OS reservation, so creating one costs about as much as allocating a small object. That cost is why the model is one virtual thread per task, with no pooling.

Virtual threads run by being mounted on a carrier, an ordinary platform thread in a small JVM-managed pool that is usually sized to the CPU core count. When a virtual thread blocks on a supported operation, such as a socket read inside a JDBC driver, the JVM saves its stack and unmounts it. The carrier then picks up another runnable virtual thread. When the I/O completes, the parked thread is mounted again, possibly on a different carrier. The code still looks blocking, but the blocking no longer holds an OS thread.

Carrier Before the JDBC call While VT-2 waits on the database
C1 VT-1 VT-1
C2 VT-2 → executeQuery() VT-5 (VT-2 parked on the heap)
C3 VT-3 VT-3
Waiting to run VT-4, VT-5 … VT-10000 VT-4, VT-6 … VT-10000

In Spring Boot, setting spring.threads.virtual.enabled=true in application.properties switches servlet request handling and Spring's auto-configured task executors to virtual threads. The JDK you run on must have virtual threads as a final feature. Which components the property covers, such as schedulers, messaging listeners, and the embedded server, has grown across releases. Check the reference documentation for your Spring Boot version, and don't assume every executor in your application has switched.

# application.properties

# Run servlet request handling and Spring's auto-configured
# task executors on virtual threads. Requires a JDK where
# virtual threads are final. Check your Spring Boot version's
# reference docs for exactly which components this covers.
spring.threads.virtual.enabled=true

# Virtual threads are daemon threads. If the app has no web
# server (for example, only @Scheduled jobs), this keeps the
# JVM alive.
spring.main.keep-alive=true

# How requests map onto OS threads once this is enabled:
#
#   virtual threads (heap)        carriers (platform threads)
#   ----------------------        ---------------------------
#   VT-1  running  ------------>  C1
#   VT-2  executeQuery()          C2  (VT-2 unmounted, C2 freed)
#     |   blocks on socket read    ^
#     v                            |
#   [VT-2 stack parked on heap]    |
#   VT-5  runnable  ---------------+  C2 now runs VT-5
#   VT-3  running  ------------>  C3
#   VT-4, VT-6 ... VT-10000  queued, waiting for a free carrier
#
# When the database replies, VT-2 is mounted again on
# whichever carrier is free next, not necessarily C2.

A minimal application.properties that enables virtual threads in Spring Boot, with a comment diagram showing a virtual thread unmounting from its carrier during a blocking JDBC call while another virtual thread takes over that carrier.

Who benefits: blocking I/O workloads and Little's Law

Virtual threads pay off when a request spends most of its life waiting. A handler that issues a JDBC query, calls another service through a synchronous HTTP client, or reads from a network share holds a platform thread for the whole wait, even though that thread does almost no work. In these services, the size of the thread pool sets the concurrency limit, not the CPU.

Little's Law makes this measurable: concurrency = throughput × time in system. If you fix concurrency at the pool size, the throughput ceiling is pool size ÷ request duration.

Illustrative estimate

Assumed inputs: a 200-thread request pool, 50 ms of blocking I/O and 2 ms of CPU per request (52 ms total), and 16 cores.

Limit Calculation Ceiling
Platform thread pool 200 ÷ 0.052 s ~3,850 req/s
CPU 16 cores ÷ 0.002 s ~8,000 req/s
Concurrency needed at the CPU ceiling 8,000 × 0.052 s ~416 in flight

With platform threads, the pool caps the service at about 3,850 req/s while the cores sit roughly half idle. Virtual threads remove that cap, so the CPU ceiling of about 8,000 req/s becomes the theoretical limit. GC, serialization and framework overhead will reduce it in practice.

Each request still takes 52 ms. Virtual threads don't make a single request faster. They let more requests wait at the same time.

The extra concurrency now lands somewhere else. At the CPU ceiling, roughly 416 requests are in flight, and each one may want a database connection or an outbound socket. If your connection pool has 50 connections, it becomes the new ceiling well before the CPU does.

When to avoid virtual threads

That ceiling exists only when threads spend most of their time waiting. Some services never do, and virtual threads give them nothing.

CPU-bound work. Image encoding, JSON transformation of large payloads, cryptography and in-memory scoring keep a thread on a core for the whole request. Virtual threads are still scheduled onto a small pool of carrier threads, roughly one per core. Adding a million virtual threads does not add cores, so throughput stays where it was.

Reactive stacks. A WebFlux service built on non-blocking drivers already serves many connections from a handful of event-loop threads. No request holds a blocked thread, so there is nothing for virtual threads to free.

Latency goals. Virtual threads let more requests wait at the same time. They do not make a database query or an HTTP call return faster. A request that took 80 ms will still take 80 ms, and it can take longer if the extra concurrency overloads a downstream service.

Overhead. Mounting and unmounting a virtual thread on its carrier has a cost. For short, non-blocking tasks, that cost buys nothing. Libraries that cache expensive objects per thread in ThreadLocals also lose that cache, because virtual threads are not reused. The object gets rebuilt for each task.

Approach Programming model Best fit Debugging Migration effort
Tuned platform thread pools Blocking, imperative CPU-bound or modest concurrency Familiar stack traces and tooling None
Reactive (WebFlux) Non-blocking, operator chains Very high concurrency, streaming Fragmented traces, harder to follow High; needs non-blocking drivers throughout
CompletableFuture async Callback composition Fan-out to a few parallel calls Moderate; stack traces split across stages Medium
Virtual threads Blocking, imperative High-concurrency blocking I/O Familiar, but very large thread dumps Low to medium; audit pinning and ThreadLocal use

The bottleneck moves downstream: replacing thread-pool tuning with explicit limits

A platform thread pool of 200 was never just a performance setting. It was also admission control. When all 200 threads were busy, request 201 waited in the connector's accept queue, and the database only ever saw as many concurrent queries as the web tier had threads. Virtual threads remove that ceiling. A traffic spike that used to queue harmlessly at the edge now arrives at the database as thousands of simultaneous connection requests, at a rate-limited partner API as a burst of 429s, or at an internal service as load it was never sized for.

The fix is to make each limit explicit and put it in front of the scarce resource it protects. Size the database connection pool from what the database can actually sustain, not from your old thread count. Then expect requests to wait on pool acquisition, and set an acquisition timeout short enough that callers fail fast instead of stacking up.

For resources without a built-in pool, wrap calls in a java.util.concurrent.Semaphore or a bulkhead. Acquire a permit before calling the partner API and release it in a finally block. Use tryAcquire with a timeout so excess work is rejected rather than queued forever. A blocked virtual thread waiting for a permit costs almost nothing, so this is cheap.

Do not pool virtual threads to recreate the old cap. That brings back a single global limit tied to no particular resource, and it throws away the main reason to adopt virtual threads: creating one per task is inexpensive.

import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class PartnerApiClient {

    // Explicit limit sized to the partner's contract, not to any thread count.
    // Placeholder value: assume the partner allows 20 concurrent requests.
    private final Semaphore permits = new Semaphore(20);

    public String fetchQuote(String sku) throws InterruptedException {
        // Wait briefly for a permit; a waiting virtual thread is cheap,
        // but we still fail fast instead of queuing without bound.
        if (!permits.tryAcquire(200, TimeUnit.MILLISECONDS)) {
            throw new IllegalStateException("Partner API saturated, rejecting request");
        }
        try {
            return callPartner(sku); // blocking HTTP call is fine on a virtual thread
        } finally {
            // Always return the permit, even if the call throws.
            permits.release();
        }
    }

    private String callPartner(String sku) {
        // Placeholder for the real blocking HTTP client call.
        return "quote-for-" + sku;
    }
}

A Semaphore with a bounded tryAcquire caps concurrent calls to a rate-limited partner API, so each virtual thread can still be created per task while the scarce resource gets its own explicit limit.

Pinning, ThreadLocal and context propagation pitfalls

Pinning

A virtual thread is cheap because it unmounts from its carrier (platform) thread when it blocks. Sometimes it cannot unmount. It stays pinned to the carrier while blocking inside a native or foreign function call. On older JDKs it is also pinned while blocking inside a synchronized block or method. The carrier pool is small, roughly one carrier per core. A few pinned threads waiting on a slow socket or a contended lock can therefore occupy every carrier. When that happens, runnable virtual threads have nowhere to run. The result looks like a deadlock or a sudden latency cliff, even though CPU usage is low.

Check how your specific JDK handles synchronized instead of assuming either behaviour. To see pinning in practice, start the JVM with -XX:StartFlightRecording, run load, then inspect the recording with jfr print --events jdk.VirtualThreadPinned. The stack traces show which locks or native frames are responsible. Common sources are JDBC drivers and connection pools.

ThreadLocal and context

Libraries often use ThreadLocal as a per-thread cache for objects like formatters or buffers. With one virtual thread per task, that cache becomes a per-task allocation. You lose the reuse and pay more memory.

Context that relied on thread identity also needs review. MDC values and the security context are bound to the thread that set them. When work hops to another thread, that context is missing unless you propagate it explicitly. The result is log lines without request IDs, or an anonymous principal in the middle of a request.

Finally, more in-flight requests means more live request state at once. Expect higher heap occupancy and GC pressure, and size the heap for that.

#!/usr/bin/env bash
# Record pinning events while the app runs under load.
# Assumes a Spring Boot fat jar at build/libs/app.jar (placeholder path).
set -euo pipefail

REC=/tmp/pinning.jfr

# Start the app with virtual threads enabled and a time-bounded JFR recording.
# The recording is written to $REC when the duration elapses.
java \
  -XX:StartFlightRecording=duration=120s,filename="$REC" \
  -jar build/libs/app.jar \
  --spring.threads.virtual.enabled=true &
APP_PID=$!

# Drive representative traffic while recording. Replace with your load tool.
# Pinning is only visible when threads actually block under concurrency.
sleep 15
for i in $(seq 1 500); do
  curl -s -o /dev/null  &
done
wait_for_recording=130
sleep "$wait_for_recording"

# Quick check: does the recording contain any pinning events at all?
jfr summary "$REC" | grep VirtualThreadPinned || echo "no pinning events recorded"

# Print each pinning event with its stack trace. Look for synchronized
# frames or native methods, often inside JDBC drivers or connection pools.
jfr print --events jdk.VirtualThreadPinned "$REC"

kill "$APP_PID"

Starts a Spring Boot app with virtual threads and a JFR recording, generates concurrent load, then uses jfr summary and jfr print --events jdk.VirtualThreadPinned to find the stack frames that pin virtual threads to their carriers.

Observability and debugging with virtual threads

Your existing dashboards will show little of this, because many of their signals stop meaning what they used to.

With a fixed pool of platform threads, "active threads" and "pool saturation" told you how close you were to capacity. A virtual thread per task removes that ceiling. The thread count now just mirrors in-flight requests, and saturation stays near zero until something downstream fails. The pressure still exists, but it now shows up as queuing on connection pools, semaphores and remote services.

Thread dumps have the opposite problem. A classic dump of tens of thousands of threads, most parked on the same socket read, is hard to read. Use jcmd <pid> Thread.dump_to_file -format=json <path> to capture virtual threads, and group the output by stack trace rather than reading it line by line. Older profilers, APM agents and dump analyzers may list only platform threads, or show virtual threads as anonymous carrier activity. Check what your tools actually show before you rely on them during an incident.

Platform-thread signal Replacement signal
Active thread count In-flight requests per endpoint
Executor queue depth / saturation Connection-pool wait time and pending acquires
Thread-pool rejections Semaphore or rate-limiter rejections
Per-thread CPU in dumps Carrier thread utilization and pinning events
Request latency alone Downstream latency percentiles (p95/p99) per dependency

Alert on pool wait time first. It is usually the earliest sign that you have hit the explicit limits you set.

#!/usr/bin/env bash
# Capture a thread dump that includes virtual threads, then summarize it
# by stack shape instead of reading tens of thousands of entries.
set -euo pipefail

PID="${1:?usage: $0 <pid>}"   # JVM process id of the Spring Boot app
OUT="/tmp/threads-$(date +%Y%m%d-%H%M%S).json"  # new file each run

# JSON format lists virtual threads grouped by thread container.
jcmd "$PID" Thread.dump_to_file -format=json "$OUT"

# Total threads across all containers (tracks in-flight work, not capacity).
jq '[.threadDump.threadContainers[].threads[]] | length' "$OUT"

# Group threads by their top 3 frames and show the 10 largest groups.
# One large group parked on a socket read or a pool acquire tells you
# which dependency the requests are waiting on.
jq -r '
  [.threadDump.threadContainers[].threads[]
   | (.stack[0:3] | join(" <- "))]
  | group_by(.)
  | map({count: length, frames: .[0]})
  | sort_by(-.count)
  | .[0:10][]
  | "\(.count)\t\(.frames)"
' "$OUT"

# Separately, record pinning events for 60s to check carrier-thread health.
# jdk.VirtualThreadPinned fires when a virtual thread blocks while pinned.
jcmd "$PID" JFR.start name=pinning duration=60s filename=/tmp/pinning.jfr
sleep 65
jfr print --events jdk.VirtualThreadPinned /tmp/pinning.jfr | head -n 50

Captures a JSON thread dump that includes virtual threads and groups it by top stack frames with jq. It also records JFR pinning events to show how virtual threads are using their carrier threads.

Rollout checklist and impact on cost and engineering time

A rollout sequence that limits surprises

  1. Audit your dependencies first. List the JDBC drivers, HTTP clients, caches and SDKs on your request path. Flag any that hold synchronized blocks around I/O or call native code that blocks. Those paths pin carrier threads, and no load test will show it clearly if the traffic mix does not exercise them.
  2. Load test against realistic downstream limits. Configure staging with the same connection-pool sizes, rate limits and database capacity as production. With virtual threads, your service will push concurrency until something downstream stops it, so a test against an unconstrained mock tells you very little.
  3. Compare p95 and p99, not averages. Virtual threads often improve median latency while tail latency gets worse, because more requests queue on pools and locks. An average can hide that.
  4. Keep a fast off switch. Treat spring.threads.virtual.enabled as a runtime configuration value, not a build-time choice, and confirm in staging that reverting it restores the old thread-pool behavior.

What it costs in engineering time

The real saving is code you don't have to write. Teams can keep straightforward blocking code instead of rewriting services in a reactive style, and that code stays easier to read, test and debug. That saving is offset by other work. Someone has to redo capacity planning, design explicit concurrency limits for each dependency, investigate pinning, and rebuild the dashboards and alerts that used to watch thread-pool saturation.

Budget for that work up front. Enabling virtual threads changes how your service consumes downstream capacity, so treat it as a capacity-planning change and not as a free performance switch.

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.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

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.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • 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.