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.

Think of it like this
Platform threads are like restaurant tables: expensive, limited, and a customer waiting for their food still occupies one. Virtual threads let customers step away while they wait and come back when the food is ready — so the same restaurant serves far more people. But if the kitchen (your database) only has ten cooks, you still only get ten dishes at a time.

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:

application.properties
spring.threads.virtual.enabled=true

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:

terminal
# Record, then look for pinned virtual threads java -XX:StartFlightRecording=filename=app.jfr -jar app.jar jfr print --events jdk.VirtualThreadPinned app.jfr

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:

ReportService.java
@Configuration @EnableResilientMethods class ResilienceConfig { } @Service public class ReportService { @ConcurrencyLimit(20) // at most 20 concurrent executions, whatever the thread count public Report buildMonthlyReport(String accountId) { return reportRepository.aggregate(accountId); } }

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.

How to adopt them safely
Enable them in a staging environment and run a realistic load test — not a hello-world endpoint. Compare p95/p99 latency and error rates, and watch connection-pool wait time, which is where problems surface first. Add explicit limits around databases and third-party APIs. Check pinning with JFR. Only then roll out to production — ideally one service at a time, starting with the most I/O-heavy one.

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.

One property — spring.threads.virtual.enabled=true — on Java 21+.
On Java 24+, synchronized no longer pins virtual threads.
The bottleneck moves to connection pools and downstream services — add explicit limits.
Watch ThreadLocal caches; consider scoped values.
No gain for CPU-bound work.