Virtual threads are the biggest change to Java concurrency in a decade, and in Spring Boot they're one property away. That simplicity is exactly why teams get surprised: turning them on is easy; knowing where the real bottleneck moves to is the part that matters.
Turning them on
On Java 21 or later with Spring Boot 3.2+ (including Spring Boot 4), one property switches the embedded web server's request handling and Spring's auto-configured task execution and scheduling to virtual threads:
Each incoming request now runs on its own cheap virtual thread. When that thread blocks — a JDBC call, an HTTP call to another service, a file read — the JVM unmounts it from its carrier thread and runs something else. Blocking code stays readable, but you no longer pay one expensive OS thread per waiting request.
Good news: synchronized no longer pins (on modern JDKs)
Early adopters on Java 21 hit pinning: a virtual thread blocking inside a synchronized block couldn't unmount, so it held its carrier thread hostage. JEP 491 in JDK 24 removed that limitation for synchronized. If you're on Java 25 LTS, that class of problem is largely gone. Pinning can still happen during native calls, so it's worth knowing how to check:
Trap 1: the connection pool becomes your real limit
With platform threads, Tomcat's thread pool (200 by default) was an accidental throttle. With virtual threads there's no such throttle — a traffic spike can create thousands of concurrent requests, all queuing for a HikariCP pool of ten connections. Requests that used to be rejected quickly now wait, then time out, and the database may get hammered by everything the pool does let through.
Put limits back where they belong — around scarce resources. Spring Framework 7 (Spring Boot 4) includes a built-in concurrency limit annotation:
Also set sensible spring.datasource.hikari.connection-timeout values and HTTP client timeouts, so waiting has a ceiling. Virtual threads make waiting cheap; they don't make it free for your downstream systems.
Trap 2: ThreadLocal caches multiply
Code that caches expensive objects in a ThreadLocal — formatters, buffers, per-thread clients — assumed a few hundred long-lived threads. With a new virtual thread per request, those caches are created per request and thrown away, sometimes millions of times. Prefer immutable, thread-safe shared objects (DateTimeFormatter instead of SimpleDateFormat), and for request-scoped context consider scoped values, final since Java 25.
Trap 3: CPU-bound work doesn't get faster
Virtual threads help when threads spend most of their time waiting. A service that spends its time computing — PDF generation, encryption, heavy JSON transformation — is limited by CPU cores, and virtual threads add nothing. For those workloads, a bounded platform-thread pool sized to your cores is still the right tool.
Frequently asked questions
How do I enable virtual threads in Spring Boot?
Set spring.threads.virtual.enabled=true on Java 21 or later with Spring Boot 3.2 or newer. Spring Boot then uses virtual threads for the embedded web server's request handling and its auto-configured task execution and scheduling.
Are virtual threads faster than platform threads?
Not per request. They increase throughput for I/O-bound workloads by letting many more requests wait concurrently without tying up OS threads. CPU-bound workloads see no benefit.
Do virtual threads still pin on synchronized blocks?
Not on JDK 24 and later. JEP 491 allows virtual threads to unmount while blocked inside synchronized, which removed the most common pinning problem. Pinning can still occur during native calls.
spring.threads.virtual.enabled=true — on Java 21+.synchronized no longer pins virtual threads.