Skip to main content

7 min read #java #spring-boot #virtual-threads #concurrency #backend

Virtual threads quietly deleted my thread-pool anxiety

Project Loom landed, Spring Boot wired it in with one property, and the thread-pool tuning I used to lose afternoons to mostly evaporated. Here's what actually changed in a real service.

For most of my Java career, request throughput came down to a number I was bad at picking: the size of a thread pool. Too small and requests queued behind each other waiting on a database that wasn't even busy. Too big and the JVM drowned in context switches and memory, because every platform thread is a real OS thread with a fat stack. I'd tune Tomcat's maxThreads, watch a dashboard, tune it again, and never feel like I'd actually understood anything.

Virtual threads — Project Loom, stable since Java 21 — quietly made most of that go away. Not by being faster at any one thing, but by changing what a thread costs. And the part that surprised me is how little code I had to touch to get the benefit.

What a virtual thread actually is

A platform thread is a thin wrapper over an OS thread. You get a few thousand of them before memory and the scheduler give up. A virtual thread is managed by the JVM instead: it runs on a small pool of "carrier" platform threads, and the moment it does something blocking — a JDBC call, an HTTP request, reading a file — the JVM unmounts it from its carrier and parks it. The carrier is immediately free to run another virtual thread. When the blocking call returns, the virtual thread gets remounted and continues.

The result: you can have a million of them. Blocking is no longer expensive, because a blocked virtual thread isn't holding an OS thread hostage — it's just a parked object on the heap. The whole "don't block the thread" anxiety that pushed everyone toward reactive code exists because platform threads are scarce. Virtual threads make them cheap.

The Spring Boot part is almost insulting

I expected a migration. What I got was one line in application.properties:

spring.threads.virtual.enabled=true

On Spring Boot 3.2+ running Java 21+, that flips Tomcat to run each request on its own fresh virtual thread instead of borrowing from a bounded pool. @Async methods and a few other executors pick it up too. The mental model goes from "share a scarce pool of threads carefully" to "give every request its own thread and stop thinking about it." For an I/O-bound service — the kind that spends its life waiting on Postgres and downstream APIs — that's exactly right.

I stopped setting server.tomcat.threads.max. There's nothing to size anymore. A burst of 5,000 concurrent slow requests used to mean a saturated pool and a growing queue; now it means 5,000 parked virtual threads sharing a handful of carriers, each one waking up when its database call comes back.

The trap nobody warns you about: pinning

Here's where the honesty has to come in, because virtual threads have one sharp edge. When a virtual thread enters a synchronized block and then blocks inside it, it can't be unmounted — it pins the carrier thread for the duration. Enough pinned carriers and you've quietly recreated the thread-pool starvation you thought you escaped, except now it's invisible.

// Pins the carrier: blocking I/O inside a synchronized block
synchronized (lock) {
    var row = jdbcTemplate.queryForObject(sql, ...); // blocks while pinned
}

// Better: hold the lock only around the in-memory critical section,
// or switch to a java.util.concurrent.locks.ReentrantLock, which
// the JVM knows how to unmount cleanly.
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    // short, non-blocking critical section
} finally {
    lock.unlock();
}

The fix is rarely dramatic: replace synchronized with ReentrantLock on the hot paths, or shrink the critical section so the blocking call happens outside it. Java 24 went further and removed most of the pinning cases entirely, but if you're on 21 LTS in production — as most shops are — pinning is the thing to actually watch for. Run with -Djdk.tracePinnedThreads=short for a while and you'll find them.

When virtual threads do nothing for you

They are not a performance cheat code. Virtual threads help when threads spend their time waiting. If your workload is CPU-bound — crunching numbers, parsing huge payloads, doing real computation — a million virtual threads just means a million things fighting over the same cores. You still only have as many CPUs as you have. For that work, a bounded pool sized to your core count is still correct.

The other quiet cost is ThreadLocal. A lot of older libraries stash per-request state in thread locals on the assumption that threads are pooled and reused. With a fresh virtual thread per task, that assumption breaks in subtle ways, and thread-local-heavy code can balloon memory if it's caching aggressively. It's usually fine, but it's the second thing I check after pinning.

The honest takeaway

Virtual threads didn't make my service faster in a benchmark — under light load the numbers are identical. What they did was delete an entire category of decision. I no longer pick a pool size, no longer feel the pull to rewrite straightforward blocking code into reactive pipelines just to survive load, no longer treat "this endpoint calls three slow services" as a scaling problem. The code stayed boring and synchronous and readable, and it scales because the runtime finally made blocking cheap. That's the rare upgrade where the right move is to write less clever code, not more.