A typical Spring Boot backend — the kind with service layers, JPA repositories, and an HTTP client hitting some external provider — migrates to Java 21, flips on Virtual Threads with spring.threads.virtual.enabled=true, and expects magic. The internal demo goes great: more throughput, less memory per thread, no business logic touched. Then someone asks to measure it under real load, and that's when the uncomfortable question shows up: why didn't an endpoint that calls a service with a synchronized block inside improve at all?
That question is the starting point for this post. Not to sell Virtual Threads, not to bury it either — just to separate what JEP 444 actually guarantees from what the marketing around Project Loom took for granted.
My take: Loom isn't free magic. If your code has synchronized blocks or blocking native calls, you're stuck with the same old bottleneck — it just runs on a thread that looks cheap and, at that exact point, isn't.
Java virtual threads / Loom limitations: what the official source says
JEP 444 (Java 21, final feature) is clear about its goal: reduce the cost of writing "one thread per request" style concurrent code without changing the programming model. The core idea is that a virtual thread runs on top of a carrier thread (a real platform thread from the ForkJoinPool pool), and when the virtual thread hits a compatible blocking operation — network I/O, Thread.sleep, java.util.concurrent locks — the JVM unmounts it from the carrier and frees that carrier to serve another virtual thread.
That's what the JEP promises, and it delivers. The official text also documents, plainly, the cases where the virtual thread can't be unmounted and blocks the carrier just like a traditional thread:
- Code inside a
synchronizedblock (before Java 24, where this was partially improved for non-reentrant monitors in some scenarios — but JEP 444 documents the baseline behavior from the original release). - Blocking native calls via JNI or OS-level native methods.
- Blocking file operations on some filesystems, depending on the filesystem implementation.
This isn't fine print. It's the heart of the trade-off. The JEP calls it "pinning" — the virtual thread gets "pinned" to its carrier thread during the blocking operation, and while that's happening, that carrier can't serve any other virtual thread. If your carrier pool is small (by default, as many as available CPU cores) and several virtual threads get pinned at the same time because of synchronized, you end up right back with the thread-scarcity problem Loom was supposed to eliminate.
Where people get it wrong: the common recipe and its hidden cost
The recipe floating around in talks and blog posts is: "swap @Async for virtual threads, flip the flag, done." It works great in the happy path: an endpoint that runs a JPA query, waits for an HTTP response from another service, and returns JSON. That's where Virtual Threads shines, because modern JDBC and Java's HTTP clients (HttpClient, and updated JDBC drivers) already play nice with unmounting.
The counterexample shows up in older layers of code — the stuff nobody's touched in years. A typical case: a utility class shared across several services, with a synchronized method guarding an in-memory cache or a counter. Before Loom, that synchronized was already a bottleneck — but since every request had its own platform thread, the cost got diluted among threads that were (relatively) cheap to create, and the OS handled the scheduling.
With Virtual Threads, that same synchronized costs something different: if you've got thousands of virtual threads running on a small carrier pool, and several of them pass through that block at the same time, pinning starts eating up the available carriers. The symptom isn't a visible error. It's latency climbing while CPU stays nowhere near the limit — the classic sign that something is blocking threads that should be free. I've chased that exact shape of graph before on a different problem (thread pool exhaustion, pre-Loom): CPU flat, latency climbing, and everyone staring at the wrong dashboard. The instinct to check is the same even when the mechanism isn't.
// Simplified example of the problematic pattern
public class CacheUtil {
private static final Map<String, Object> cache = new HashMap<>();
// This synchronized blocks the entire carrier thread
// while the virtual thread is "pinned"
public static synchronized Object get(String key) {
return cache.get(key);
}
}The fix isn't exotic: replace synchronized with ReentrantLock (which is compatible with virtual thread unmounting) or with java.util.concurrent structures like ConcurrentHashMap. But that means auditing code, not just flipping a flag.
flowchart TD
A[Virtual Thread executes] --> B{Blocking operation?}
B -->|Network I/O, sleep, ReentrantLock| C[Unmounts from carrier]
C --> D[Carrier free for another virtual thread]
B -->|synchronized, JNI, blocking native call| E[Stays pinned to carrier]
E --> F[Carrier blocked until it finishes]Decision matrix: when to migrate and when to wait
There's no universal answer, and anyone who gives you one without looking at your actual code is selling snake oil. This is a guide to where to look first, not a closed conclusion:
| Situation | What to check first | Pinning risk |
|---|---|---|
| Endpoints with JPA + updated JDBC drivers (virtual-thread compatible) | Driver version, whether it supports unmounting | Low |
HTTP clients using Java's HttpClient or reactive WebClient | Connection pool configuration | Low |
Legacy code with synchronized in shared utilities | Grep for every synchronized before migrating | High |
| JNI calls or native libraries (compression, low-level crypto) | Whether the library exposes native blocking | High — no way around it without changing the library |
| Database connection pools with old internal locks | Check if the pool is Loom-compatible (HikariCP has been since recent versions) | Medium |
The practical rule I'd actually follow: before flipping spring.threads.virtual.enabled=true on anything beyond an isolated experiment, run grep -rn "synchronized" over your own code and over whatever dependencies you can inspect. If blocks show up on the hot path of your highest-traffic endpoints, that's the real work to do before touching the flag — not after, when the metrics dashboard is already lying to you about where the bottleneck lives.
Limits: what this evidence doesn't let you conclude
Let's be honest about what you can claim and what you can't. JEP 444 documents pinning behavior as a known design decision, not a bug. That's public, verifiable evidence — anyone can read the document. What you can't conclude without your own experiment, with real logs and metrics from a real case, is how much that pinning actually costs a specific system. It depends on how many synchronized blocks sit on the hot path, the size of the carrier pool, and the traffic pattern.
It's also wrong to claim Virtual Threads "doesn't work" — it works, and works well, for the case it was designed for: I/O-bound workloads with lots of concurrent connections waiting on network or disk. The mistake is assuming it automatically solves an app's entire concurrency model without auditing what's underneath. Any throughput-improvement claim without a reproducible, documented benchmark of your own is marketing, not evidence — I won't cite a number here I haven't measured myself, and neither should you trust one that shows up in a slide deck without the raw numbers behind it.
How you actually decide
My stance, after reading the JEP with the same attention I'd give a stack trace in production: Virtual Threads is a real improvement for the "one thread per request" pattern in I/O-bound backends, and there's no drama in adopting it there. The serious work happens before flipping the flag, not after — audit synchronized, check driver and native library compatibility, and understand that the carrier pool is still a finite resource.
If your code has an old layer with manual locks or blocking native dependencies, migrating without auditing just renames the bottleneck instead of removing it. That's the kind of technical decision you want to make with the official docs open next to you, not with a blog post promising throughput without showing where the number came from.
The uncomfortable question worth asking your own team before you flip that flag: do you actually know how many synchronized blocks live in your hot path, or are you about to find out in production?
If this kind of trade-off analysis with public evidence instead of loose claims is your thing, there are more cases like it on the blog: how to evaluate npm dependencies before adding them, where Prisma stops controlling the actual query against PostgreSQL, or what actually changes when you run Qwen3 locally with Ollama — same approach, different stack.
FAQ
Does Virtual Threads replace platform threads? No, it runs on top of them. Every virtual thread needs a carrier thread (platform thread) to execute code. The difference is that many virtual threads can share few carriers, because they unmount during compatible blocking operations.
Do I need to change code to use Virtual Threads in Spring Boot?
For the basic case, no — flipping the flag is enough if your stack (JDBC driver, HTTP client) is already compatible. The real work shows up if there's synchronized, old pools, or native libraries on the path.
Does synchronized stop working with Virtual Threads?
It works, but it blocks the entire carrier thread for its duration, instead of letting the virtual thread unmount. That cuts into the scalability Loom promises for that specific chunk of code.
How do I replace synchronized without breaking mutual exclusion semantics?
ReentrantLock from java.util.concurrent.locks is compatible with virtual thread unmounting and keeps the same mutual exclusion guarantee, with an explicit API (lock()/unlock()) instead of an implicit block.
Does this affect every project on Java 21?
Only those that explicitly enable Virtual Threads and have synchronized, JNI, or non-compatible blocking I/O on the hot path. If you don't flip the flag, thread behavior stays traditional.
Is it worth migrating a generic Spring Boot backend today? Depends on your load profile. For I/O-bound systems with lots of concurrency waiting on network or disk, yes, it's worth evaluating — with a prior audit of blocking code. For CPU-bound systems, the benefit is marginal because the bottleneck isn't in I/O wait time.
Original source: https://openjdk.org/jeps/444</content> <parameter name="excerpt">Virtual Threads solves the cost of spinning up thousands of threads in the JVM. It doesn't solve the blocking problem when your code has synchronized blocks or blocking native calls. JEP 444 says so, but almost nobody reads it to the end.
Related Articles
Spring Boot Actuator: What to Expose, What to Hide, and What to Check Before Adding Endpoints
Actuator isn't the problem. Enabling it without a clear exposure policy is. A practical guide to using it as an operational tool without turning it into unnecessary public attack surface.
Jun 17 2026 · 8′ · Tutorials · devops · backend
Digital identity backend architecture: the decisions tutorials skip
Auth tutorials show you the happy path. The real problems in digital identity show up in revocation, state-change propagation, and the trust model. A decision guide from the inside.
May 30 2026 · 9′ · Tutorials · backend · seguridad
Digital signatures: format, certificate, and validation policy — three layers people constantly mix up
When a digital signature fails, the instinct is to look at cryptography. Most of the time the problem is format or validation policy. Here I separate the three layers so the next error doesn't cost you hours.
May 29 2026 · 10′ · Tutorials · seguridad · certificados
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.
No comments yet. Be the first — your take matters most when we're few.