How many threads can you create with Spring Boot and Java virtual threads?
by Henry Brown
In looking at enabling virtual threads for a Spring Boot application, I began to wonder how many virtual threads I could spawn compared to platform threads. I knew the answer would be “many more”, but I wanted to see the difference on my own machine rather than just repeat the theory.
So I wrote a deliberately simple experiment. It does not simulate a full production workload. It does not call a database, marshal JSON, apply business rules, or exercise a web server. It simply asks one question:
How many parked threads can this JVM create before something gives way?
That is still a useful question, because it shows the most important difference between platform threads and virtual threads. Platform threads are expensive because they are backed by operating system threads. Virtual threads are cheap because they are managed by the JVM and only use a platform thread while they are actually running.
Spawning platform threads
The first test creates platform threads using the normal Thread constructor.
Each thread immediately parks itself so that it stays alive without doing any useful work.
import org.junit.jupiter.api.Test
import java.util.concurrent.locks.LockSupport
class PlatformThreadLimitTest {
@Test
fun `how many platform threads can be created`() {
var threadCount = 0
try {
while (true) {
Thread {
while (true) LockSupport.park()
}.start()
threadCount++
}
} catch (_: OutOfMemoryError) {
System.err.println("Platform thread limit reached: $threadCount")
}
}
}
This is not a test I would leave in a normal build. It is intentionally trying to exhaust a resource, and the exact failure point depends on the machine, operating system limits, JVM settings, and other processes running at the same time.
The important point is that every platform thread needs an operating system thread underneath it. That means the limit is not only about Java heap. Native memory, per-thread stack size, process limits, and the operating system scheduler all matter.
Spawning virtual threads
The second test creates one million virtual threads. Again, each thread parks itself immediately.
import org.junit.jupiter.api.Test
import java.util.concurrent.locks.LockSupport
class VirtualThreadLimitTest {
@Test
fun `how many virtual threads can be created`() {
val n = 1_000_000
repeat(n) {
Thread.startVirtualThread {
while (true) LockSupport.park()
}
}
val rt = Runtime.getRuntime()
val usedMb = (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024)
println("Mounted $n virtual threads, heap used: ${usedMb}MB")
}
}
This is where virtual threads feel quite different.
There is no attempt here to create a million operating system threads.
Instead, the JVM creates a million lightweight Thread instances whose continuations can be mounted on a much smaller number of carrier platform threads when they need to run.
Because the threads are parked, most of them are not mounted on carrier threads most of the time. That is exactly the kind of situation virtual threads are designed for: lots of concurrent tasks, many of which spend most of their lives waiting.
Results
On my MacBook Pro M4 with 48 GB RAM, I got the following results.
Platform threads
Platform thread limit reached: 12253
Virtual threads
Mounted 1000000 virtual threads, heap used: 581MB
Result analysis
The platform-thread test dies in the low thousands, around 12k on my machine. The virtual-thread test manages to create one million parked virtual threads on a normal heap size.
That is a remarkable difference, but it is worth being precise about what this proves. It proves that virtual threads have a much lower per-thread footprint when they are parked or blocked in a way the JVM can unmount. It does not prove that a Spring Boot application can handle one million useful concurrent requests.
Real applications have other bottlenecks:
- Database connection pools.
- HTTP client connection pools.
- Downstream service limits.
- CPU-bound work.
- Memory used by request objects, responses, buffers, caches, and application state.
- Rate limits, queues, and back-pressure policies.
Virtual threads remove one very old bottleneck: the need to keep a large pool of expensive platform threads around just because requests might block. They do not remove the need to size and protect the rest of the system.
The number of virtual threads you can create is mainly a memory question rather than an operating-system-thread question.
If you increase the heap with -Xmx, you can usually create more parked virtual threads.
If each virtual thread has a deeper stack or holds more request state, you will create fewer.
Why this matters for Spring MVC
Spring MVC traditionally uses a request-per-thread model. That model is wonderfully simple: a request comes in, a thread handles it, and the code can be written in a direct blocking style.
The downside has always been scalability. If a request spends time waiting for a database, a REST call, a message broker, or a filesystem operation, the platform thread is still occupied while it waits. That is one of the reasons reactive programming with Spring WebFlux became attractive for high-concurrency I/O-heavy applications.
Virtual threads change that trade-off. For many I/O-bound workloads, you can keep the straightforward blocking style of Spring MVC while allowing the JVM to park blocked work cheaply. That does not make WebFlux obsolete. Reactive programming is still a very good fit for streaming, back-pressure, and end-to-end reactive systems. But virtual threads make the simple blocking model scale much further than it used to.
Enabling virtual threads in Spring Boot
Enabling virtual threads in Spring Boot is as simple as setting a property in your application.properties or application.yaml file:
spring.threads.virtual.enabled=true
At least Java 21 is required for this to work. Spring Boot’s reference documentation also notes that the feature affects auto-configured task execution and scheduling. When virtual threads are enabled, Boot uses virtual-thread-friendly executor and scheduler choices in places where its auto-configuration is responsible for creating them.
That means this property is not only about embedded Tomcat handling requests.
It can also affect pieces such as @Async, Spring MVC asynchronous request handling, blocking execution support in WebFlux, and scheduled task infrastructure depending on how the application is configured.
There is one small Spring Boot detail that is easy to miss. Virtual threads are daemon threads. If your application relies on scheduled work or other virtual-thread-based infrastructure to keep the JVM alive, you may need:
spring.main.keep-alive=true
This is called out in the Spring Boot reference documentation and is worth knowing before enabling virtual threads in a service that mostly does background or scheduled work.
Things to watch out for
Virtual threads are very useful, but they are not magic performance dust. I would pay attention to at least the following when enabling them in a Spring Boot application.
Pinning
A virtual thread can usually unmount from its carrier thread when it blocks.
There are cases where it cannot, which is known as pinning.
Historically, blocking while inside a synchronized block or native method could pin the virtual thread to its carrier.
If enough virtual threads are pinned for long periods, throughput can suffer because the carrier threads are no longer free to run other virtual threads.
The JDK has been improving this area, but it is still worth testing your own application.
The official Java documentation and Spring Boot reference both point to tools such as JDK Flight Recorder and jcmd for detecting pinned virtual threads.
Spring Boot’s current reference documentation also strongly recommends Java 24 or later for the best virtual-thread experience, even though Java 21 is the minimum version required.
Pool sizing still matters
With platform threads, the web server thread pool often acted as an accidental concurrency limit. Turning on virtual threads can remove or greatly raise that limit. That is usually what you want, but it means other limits become more visible.
If your database pool has 20 connections, then allowing 20,000 requests to reach code that all needs a database connection will not make the database faster. It may just move the waiting from the servlet thread pool to the connection pool.
For that reason, virtual threads often pair well with explicit bulkheads, timeouts, rate limits, and connection-pool sizing.
CPU-bound work does not become cheaper
Virtual threads are excellent when work blocks or waits. They do not give the CPU more cores. If the workload is mostly CPU-bound, creating more virtual threads can increase scheduling overhead without improving throughput.
For CPU-heavy work, the usual rules still apply: measure, use bounded parallelism, and size around the available processors.
Be careful with ThreadLocal usage
Virtual threads are still instances of java.lang.Thread, and they support ThreadLocal.
That is useful for compatibility with existing libraries.
However, virtual threads are intended to be numerous and short-lived, so using ThreadLocal as a cache for expensive resources is a bad fit.
Use normal Spring-managed beans, connection pools, and scoped context mechanisms where appropriate. Do not treat a virtual thread as a long-lived worker thread with reusable state attached to it.
Useful references
These are the pages I would keep close by when experimenting with this in a Spring Boot application:
- Spring Boot reference documentation: Virtual threads
- Spring Boot reference documentation: Task execution and scheduling
- Spring Guide: Building a RESTful Web Service
- Spring Guide: Building a Reactive RESTful Web Service
- Spring blog: Embracing Virtual Threads
- OpenJDK JEP 444: Virtual Threads
- Oracle Java documentation: Virtual Threads
Final thoughts
For me, the experiment makes the appeal of virtual threads very concrete. With platform threads, the JVM runs into a practical wall after a few thousand parked threads. With virtual threads, a million parked tasks is not especially dramatic.
That difference matters for Spring Boot applications because so many business systems are mostly waiting: waiting for databases, waiting for downstream APIs, waiting for queues, waiting for storage.
Virtual threads let you keep the readable request-per-thread programming model while dramatically reducing the cost of waiting.
They are not a replacement for good system design, sensible limits, or performance testing.
But for many Spring MVC applications running on Java 21 or later, spring.threads.virtual.enabled=true is now one of the most interesting properties worth testing.