How virtual threads work and what Project Loom changes

A virtual thread is a java.lang.Thread that the JVM manages instead of the operating system. It has a stack, a thread-local context and normal blocking semantics. However, it is not bound one-to-one to an OS thread. The JVM runs virtual threads on a small pool of carrier threads, which are ordinary platform threads. By default the pool is sized to roughly the number of available CPU cores.

The important behavior is unmounting. When a virtual thread blocks on supported I/O, a lock or a sleep, the JVM copies its stack frames to the heap and releases the carrier. The carrier then picks up another runnable virtual thread. When the I/O completes, the original thread is scheduled again, possibly on a different carrier, and resumes where it stopped.

Carrier C1:  [VT-1 run]--(VT-1 blocks on socket)--[VT-3 run]--[VT-1 resumes]
Carrier C2:  [VT-2 run]--[VT-4 run]--(VT-4 blocks)--[VT-2 run]
Heap:        VT-1 stack parked ......... VT-4 stack parked ...

Compare this with a classic bounded pool of 200 platform threads. There, each in-flight request holds an OS thread for its whole lifetime, including the time it spends waiting on a database. Request 201 queues even when every CPU is idle.

Project Loom keeps the straightforward blocking style of read() then process() then write(). It also keeps readable stack traces. What it removes is the cost of one OS thread per waiting request. Waiting becomes cheap heap memory, so concurrency is no longer capped by thread count.

 CLASSIC: BOUNDED POOL, 200 PLATFORM THREADS (1:1 with OS threads)
 +-------------+     +----------------------+     +---------------------+
 | Req 1..200  |---->| T1..T200 (OS thread) |---->| DB / socket wait    |
 +-------------+     | held for whole req   |<----| thread stays blocked|
                     +----------------------+     +---------------------+
 +-------------+
 | Req 201+    |---->[ queued until a thread frees, even if CPUs idle ]
 +-------------+

 LOOM: VIRTUAL THREADS ON A SMALL CARRIER POOL (~ CPU cores)
 +----------------------+   mount    +-----------------------------------+
 | Run queue            |----------->| Carrier C1   Carrier C2   ...     |
 | VT-2 VT-3 ... VT-N   |            | (platform threads, run one VT     |
 +----------------------+            |  at a time, then pick the next)   |
            ^                        +-----------------------------------+
            |                                       |
            | I/O completes:                        | VT blocks on supported
            | VT runnable again,                    | I/O, lock or sleep:
            | may resume on another                 | frames copied to heap,
            | carrier                               v carrier released
 +----------------------------------------------------------------------+
 | Heap: parked VT stacks   [VT-1 frames]  [VT-4 frames]  ...           |
 +----------------------------------------------------------------------+

A bounded platform-thread pool ties up one OS thread per waiting request and queues the rest. Virtual threads unmount to the heap when they block, which frees a small set of carrier threads to run other work until the I/O completes.

What spring.threads.virtual.enabled actually switches on

Turning it on is a single property, set to true in application.properties as spring.threads.virtual.enabled=true, or nested under spring.threads.virtual.enabled in YAML. It needs a JDK with virtual threads, and Spring Boot simply ignores it on older runtimes.

The property changes which threads run your code. It does not change the code itself. Boot reconfigures the components it creates. The embedded servlet container dispatches each request on a fresh virtual thread instead of a pooled worker. The auto-configured task executor behind @Async and the auto-configured scheduler switch to virtual threads too. Your controllers, JDBC calls and synchronized blocks run unchanged, now on a different kind of carrier.

Anything you construct yourself is left alone. Boot backs off when you supply your own bean, and it never inspects executors created inside your classes. Two common cases stay on platform threads:

  • A @Bean returning a ThreadPoolTaskExecutor with setCorePoolSize(20). Its tasks still queue for 20 OS threads.
  • A field initialised with Executors.newFixedThreadPool(50). The JVM creates it, and Spring never sees it.

Audit checklist

  • Search for ThreadPoolTaskExecutor, Executors.new, ForkJoinPool and @Bean methods returning Executor or TaskScheduler.
  • Check library-owned pools, such as HTTP client and messaging listener containers.
  • Confirm that @Async methods without a qualifier resolve to the auto-configured executor.
  • Take a thread dump under load and verify which threads are virtual.

Exact coverage has grown across Spring Boot releases, so check the reference documentation for the version you run.

// application.properties: spring.threads.virtual.enabled=true
// The property affects only the executors Boot auto-configures. Everything below stays on platform threads.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;

@Configuration
class ReportExecutorConfig {

    // A user-defined Executor bean makes Boot's executor auto-configuration back off.
    // Tasks submitted here still queue for at most 20 OS threads.
    @Bean
    ThreadPoolTaskExecutor reportExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(20);
        executor.setMaxPoolSize(20);
        executor.setThreadNamePrefix("report-");
        return executor;
    }
}

@Service
class InvoiceService {

    // Built by plain JDK code, so Spring never sees this pool and cannot reconfigure it.
    private final ExecutorService pool = Executors.newFixedThreadPool(50);

    // To use virtual threads here, opt in explicitly (JDK 21+):
    // private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();

    void submit(Runnable job) {
        pool.submit(job);
    }
}

With spring.threads.virtual.enabled=true set, a custom ThreadPoolTaskExecutor bean and a hand-built fixed thread pool still run on platform threads unless you change them to virtual-thread executors yourself.

When virtual threads help: blocking I/O and the moving bottleneck

The services that gain the most are thread-per-request applications whose request time is mostly spent waiting on blocking I/O, such as JDBC calls, synchronous HTTP clients, and file or socket reads. In these services the platform thread pool was the throughput ceiling. A parked virtual thread costs little memory and no OS thread, so concurrency is no longer limited by how many platform threads you can afford.

Little's Law (concurrency = throughput × latency) shows where the limit moves next.

Illustrative estimate (assumed inputs, not measured):

Constraint Concurrency Time per unit Ceiling
Platform thread pool 200 threads 100 ms per request ≈ 2,000 req/s
Database connection pool 50 connections 90 ms per query ≈ 550 queries/s

With platform threads, 200 threads at 100 ms each cap the service near 2,000 requests per second. Suppose every request also runs one query on a 50-connection pool, and each query holds a connection for 90 ms. That pool can serve only about 550 queries per second, so it was already the real limit. Removing the thread ceiling does not raise throughput. Requests now pile up waiting for connections instead of waiting for threads.

The same reasoning applies to rate-limited downstream APIs, small HTTP client pools, and saturated databases.

For many CRUD services, the realistic gain is therefore better tail latency under bursts, not higher peak throughput. During a spike, requests no longer queue for a scarce worker thread before they even reach the real bottleneck.

When to avoid virtual threads

Virtual threads make waiting cheap. When a service spends little time waiting, or when something else already caps concurrency, they have little to improve.

CPU-bound work has no waits to overlap. Image resizing, JSON transformation of large payloads, and cryptography all keep a carrier thread busy until the task finishes. The scheduler cannot run more of them than there are cores, so the extra threads just add scheduling overhead.

A service bounded by a small database connection pool or a rate-limited downstream API will not get faster either. Ten thousand virtual threads competing for twenty connections still complete twenty queries at a time. The queue moves from the executor to the pool's acquire timeout, which is often less visible.

Code that is already non-blocking or reactive already avoids thread-per-request costs. Switching it to virtual threads trades one concurrency model for another without removing a bottleneck.

Third-party libraries can also cancel out the benefit. Internal synchronized blocks around I/O, native calls, or heavy ThreadLocal caching can pin carriers or multiply memory use. Because the code is someone else's, the team may not be able to fix it.

Mixing virtual threads with custom platform-thread executors in one application also makes it harder to see where concurrency limits actually live. That complicates both tuning and incident response.

Workload type Expected benefit
Blocking I/O, many slow remote calls High
CRUD behind a small connection pool Low: the pool is the limit
CPU-bound computation None
Reactive or non-blocking stack Negligible
Libraries with I/O under locks or native calls Unpredictable, possibly negative

Thread pinning: causes, detection and fixes

A virtual thread is pinned when it blocks but cannot unmount from its carrier. The carrier platform thread stays occupied for the whole wait. The carrier pool is sized roughly to the number of cores, so a handful of pinned threads can consume every carrier. Unrelated requests then queue behind them even though they have nothing to do with the blocked work.

Causes

There are two classic causes:

  • Blocking inside a synchronized block or method. On older JDKs, holding a monitor prevented unmounting, so any I/O inside the block pinned the thread.
  • Blocking during a native or foreign call. The JVM cannot unmount a thread while a native frame is on its stack.

Recent releases changed synchronized behaviour substantially. Check your JDK's release notes rather than relying on older guidance.

Detection

  • JDK Flight Recorder. Record under realistic load and look for the jdk.VirtualThreadPinned events. They include stack traces showing where the pin occurred.
  • Thread dumps. Use jcmd <pid> Thread.dump_to_file to capture a dump that includes virtual threads.
  • Correlation. Match both against latency spikes to confirm that pinning is the cause.

Diagnostic system properties and flags have been added and removed across releases. Verify them against your JDK's documentation before relying on them.

Fixes

In the example that follows, a synchronized section wrapping a blocking call is rewritten to use ReentrantLock. A virtual thread waiting on a java.util.concurrent lock can unmount, so the carrier is freed for other work.

Much pinning lives in code you don't own, such as JDBC drivers, connection pools and HTTP clients that synchronize internally. Upgrade them to versions that state they support virtual threads.

Why it's intermittent

Pinning only hurts when the number of simultaneously pinned threads approaches the number of carriers. At low traffic there are spare carriers, so nothing visible happens. Under peak load the carriers saturate and latency collapses. As a result, pinning problems often pass functional tests and show up first in production.

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.locks.ReentrantLock;

public class TokenRefresher {
    private final HttpClient client = HttpClient.newHttpClient();
    private final URI tokenUri = URI.create("");
    private final ReentrantLock lock = new ReentrantLock();
    private String token;

    // BEFORE: on JDKs where monitors pin, the blocking send() holds the carrier
    // for the whole network round trip.
    public synchronized String refreshPinned() throws IOException, InterruptedException {
        token = fetch();
        return token;
    }

    // AFTER: a virtual thread that waits on this lock, or blocks on I/O while
    // holding it, can unmount and free its carrier.
    public String refresh() throws IOException, InterruptedException {
        lock.lock();
        try {
            token = fetch();
            return token;
        } finally {
            lock.unlock(); // always release, even if fetch() throws
        }
    }

    private String fetch() throws IOException, InterruptedException {
        HttpRequest req = HttpRequest.newBuilder(tokenUri).GET().build();
        return client.send(req, HttpResponse.BodyHandlers.ofString()).body();
    }
}

The same blocking token refresh guarded first by a synchronized method, which can pin the carrier on older JDKs, and then by a ReentrantLock, which lets the virtual thread unmount while it waits.

Reintroducing backpressure and controlling hidden costs

Overload behaves differently once the platform-thread pool is gone. With a fixed pool of, say, 200 request threads, the 201st request waited in the server's accept queue. That cap was an implicit rate limiter. With virtual threads, every request gets a thread immediately, so contention moves downstream. It surfaces as JDBC pool exhaustion, acquire timeouts and a spike in calls to dependencies that were sized for the old concurrency.

Limit the resource, not the threads

Do not rebuild a pool of virtual threads. Bound the scarce resource directly. The example below wraps a downstream client in a Semaphore bulkhead and uses tryAcquire with a timeout, so callers fail fast instead of queuing without limit. Pair it with an explicit acquire timeout on the connection pool and a maximum connection count on the embedded server, so excess load is rejected at the edge.

Per-thread state multiplies

ThreadLocal caches and per-thread buffers were cheap when there were 200 threads. At high concurrency they are not.

Illustrative estimate (assumed numbers, not a benchmark): memory = concurrent virtual threads × per-thread allocation. With 10,000 × 64 KB, that is about 640 MB. Deep call stacks also live on the heap, which adds GC pressure.

Check that MDC, the security context and tracing context still propagate through @Async and any custom executors.

Change what you alert on

Thread count and pool saturation no longer signal overload. Alert on connection-pool wait time, in-flight requests and downstream latency instead.

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

// application.yml companions: bound the edge and the pool, not the threads.
//   spring.threads.virtual.enabled: true
//   server.tomcat.max-connections: 2000                # cap open connections at the edge
//   spring.datasource.hikari.maximum-pool-size: 20
//   spring.datasource.hikari.connection-timeout: 2000  # ms to wait for a JDBC connection

@Component
public class InventoryBulkhead {

    // Size to what the downstream service can handle, not to request or thread count.
    private final Semaphore permits = new Semaphore(50);
    private final InventoryClient client; // placeholder for your blocking HTTP/RPC client

    public InventoryBulkhead(InventoryClient client) {
        this.client = client;
    }

    public StockLevel fetchStock(String sku) throws InterruptedException {
        // Wait briefly for a permit, then fail fast instead of queueing without limit.
        if (!permits.tryAcquire(200, TimeUnit.MILLISECONDS)) {
            throw new BulkheadFullException("inventory bulkhead full");
        }
        try {
            return client.getStock(sku); // blocking is fine on a virtual thread
        } finally {
            permits.release(); // always return the permit, even on failure
        }
    }

    // Map this to HTTP 503 so callers can back off or retry.
    public static class BulkheadFullException extends RuntimeException {
        public BulkheadFullException(String msg) { super(msg); }
    }
}

A Semaphore bulkhead limits concurrent calls to a scarce downstream service and fails fast after a short wait, while the commented Spring Boot properties cap server connections and set a JDBC pool acquire timeout.

Safe rollout, alternatives, and impact on cost and engineering time

Roll out in steps

  1. Enable spring.threads.virtual.enabled in staging only.
  2. Load-test against downstreams with realistic latency injected. Instant mocks hide the one effect you need to see, which is requests piling up behind a connection pool or a slow dependency.
  3. Compare throughput, p99 latency, pool wait time and heap usage against a platform-thread baseline under the same load. Record JFR pinning events throughout the run.
  4. Ship one service at a time. Keep the property as your rollback switch, because turning it off restores the previous threading model without a code change.

Compare the alternatives

Concern Virtual threads Reactive WebFlux Tuned platform pool
Programming model Imperative, blocking Functional, non-blocking Imperative, blocking
Debuggability Normal stack traces Fragmented traces across operators Normal stack traces
Library compatibility Existing blocking libraries; audit for pinning Requires non-blocking drivers Everything works
Backpressure Manual (semaphores, pool limits) Built into the stream protocol Implicit via pool size
Streaming Possible, but you manage flow Native strength Awkward at scale

Weigh cost and engineering time

The main saving is the work you skip. Teams keep their imperative code, JDBC drivers and blocking clients, and they avoid a reactive rewrite and the retraining that comes with it.

That saving has its own costs:

  • Auditing libraries and JDK versions for pinning behaviour.
  • Building load tests that model real latency.
  • Reworking dashboards and alerts that assumed thread counts meant something.

Peak throughput among these three options is often similar once the real bottleneck is a database or downstream service. Base the decision on which programming model your team can write, debug and operate well.

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.