Mastering Virtual Threads in Java and Spring Boot - Many requests, few threads—nobody waits too long.
Java’s Virtual Threads are revolutionizing how we write concurrent applications. With the introduction of virtual threads in modern JDKs, Java developers can create thousands or even millions of threads with minimal overhead. This has big implications for high-throughput server apps, including those built with Spring Boot. In this article, I’ll explain what virtual threads are, how they differ from classic threads, and the advantages they bring. I’ll also dive into how Spring Boot supports virtual threads (from Spring Boot 3.2 onward) and what changes in programming style they enable. By the end, intermediate to advanced Java/Spring developers will understand how to leverage virtual threads for more scalable and simpler concurrent code.
What Are Virtual Threads?
Virtual threads are lightweight threads managed by the Java Virtual Machine (JVM) rather than the operating system. In Java, virtual threads are still instances of java.lang.Thread, but unlike “platform” threads, a virtual thread is not permanently tied to an OS thread. Instead, the JVM schedules virtual threads onto a pool of OS threads (called carrier threads) as needed. When a virtual thread performs a blocking operation (like I/O), the JVM can suspend that virtual thread and free its carrier OS thread to work on other tasks. This is analogous to how an OS implements virtual memory: mapping many virtual addresses onto a smaller set of physical memory pages.
In essence, virtual threads allow Java to have massive concurrency with minimal OS overhead. Each virtual thread has a much smaller memory footprint and is cheap to create and block. They were introduced as part of Project Loom (an OpenJDK project led by Ron Pressler) to simplify writing high-throughput concurrent applications. First available as a preview in JDK 19 (Sept 2022) and finalized in JDK 21 (Sept 2023), virtual threads mark a new era in Java concurrency.
Virtual Threads vs. Classic Threads (Platform Threads)
Virtual threads and classic platform threads differ fundamentally in how they are implemented and managed:
- One OS
Thread per Platform Thread vs. Many Virtual Threads per OS Thread: A
classic Java thread (platform thread) is a thin wrapper around an OS
thread. When you create a platform thread, the JVM asks the OS to
create a new thread, which has a dedicated call stack (often ~1MB by
default) and OS-managed scheduling. In contrast, a virtual thread is
not backed by its own OS thread for its whole
life. A virtual thread only “mounts” on an OS thread
when it’s actively running, and it unmounts
(yields the OS thread) whenever it blocks on
I/O or sleep, etc. This means the OS thread can serve other virtual
threads while one is waiting. The JVM handles scheduling of virtual
threads to OS threads.
- Lightweight
and Memory-Efficient: Platform threads are heavy –
each comes with a large fixed-size stack and OS resources. Virtual
threads use a “pay-as-you-go” stack that
grows on the heap, and they don’t consume an OS thread when idle.
This makes virtual threads far more memory-efficient, allowing you
to create orders of magnitude more threads. For example, by default a platform
thread reserves ~1MB stack, whereas a virtual thread’s stack starts
small and expands as needed. Millions of virtual threads can exist
without exhausting memory.
- Cheap to
Create and Dispose: Creating a platform thread is
relatively slow and costly, hence the common use of thread pools to
reuse threads. Virtual threads are cheap to
start and stop, so you can create them
freely for short-lived tasks. Best practice is to avoid pooling
virtual threads – treat them as disposable, one per task, since
creation overhead is minimal.
- Blocking
Behavior: If a platform thread blocks (e.g. on
IO), it ties up the underlying OS thread – that OS thread can do
nothing else until the operation completes. This is like a bartender
serving one customer and doing nothing while waiting for that
customer’s order to complete. With virtual threads, when a thread
performs a blocking operation, the JVM parks
the virtual thread and frees up the OS
thread (bartender) to handle other virtual threads. The blocked
virtual thread doesn’t consume an OS thread while waiting – it’s
suspended and stored efficiently until the I/O is ready.
- Scheduling
and Throughput: Platform threads are scheduled by
the OS, whereas virtual threads are scheduled by the JVM on a pool
of carriers. Virtual threads aren’t “faster” at executing code –
they run at the same speed per thread – but because you can have so
many more of them, they enable higher throughput in
concurrent applications (handling lots of tasks concurrently).
Virtual threads provide scalability (lots of concurrency) rather than raw
execution speed improvement.
In summary, a platform thread = 1:1 mapping to OS thread, heavy and limited in number. A virtual thread = many:1 mapping on OS threads, extremely lightweight and numerous. The number of threads is no longer a primary scalability limit when using virtual threads. This difference lets us rethink our approach to concurrency in Java.
Key Advantages of Virtual Threads
Virtual threads bring several important benefits to Java applications:
- Massive
Scalability for I/O-intensive Workloads: Virtual
threads shine in applications that handle many concurrent tasks that
spend time waiting (for I/O, network calls, etc.). You can spawn
thousands or millions of threads without running out of memory or
thread switching capacity. This dramatically improves throughput for
servers that handle lots of simultaneous requests or connections. High-throughput concurrent applications (like web services, proxies, database access)
can handle more load with the same hardware by utilizing virtual
threads.
- Reduced
Resource Overhead: Because they are so
lightweight, virtual threads reduce memory
footprint and thread management
overhead compared to classic threads. Each virtual thread uses
minimal memory when idle and doesn’t tie up an OS thread, so you
don’t pay for thousands of deep stacks that sit mostly idle. Context
switching between virtual threads is also cheaper since it’s managed
by the JVM (which can simply schedule a new task on the OS thread
without an OS context switch in many cases).
- Simpler
Concurrency Code (Better Readability): Perhaps one
of the biggest wins is that we can write blocking code in a simple
sequential style and still achieve scalability. Virtual threads “dramatically reduce the effort of writing,
maintaining, and observing high-throughput concurrent applications”.
You no longer need to resort to complex asynchronous programming
models (completable futures, callbacks, or reactive frameworks) just
to avoid blocking threads. Code that waits for I/O can be written in
a straightforward manner, and the virtual threads will transparently
yield to let others run. This improves code
readability and maintainability – it’s
easier to reason about one straightforward code path per request,
for example, than a tangled asynchronous flow.
- High
Throughput without Async Frameworks: Related
to the above, virtual threads remove much of the need for techniques
like thread pools or reactive non-blocking I/O solely for
scalability. As the Spring team noted, the reasons to use
asynchronous programming models diminish in many cases when using
virtual threads. You can achieve high throughput with a simple
thread-per-task model (now virtual thread-per-task) without
“callback hell” or complex reactor pipelines. This doesn’t mean
reactive programming has no value (it’s still great for streaming
data, backpressure, and CPU-bound parallelism), but for typical
I/O-bound request/response workloads, virtual threads allow a
synchronous style with comparable scalability.
- Better
Resource Utilization: Virtual threads enable
better CPU utilization because threads that are waiting don’t
consume an OS thread. An OS thread (carrier) can constantly be busy
executing some runnable thread, instead of many OS threads sitting
blocked. This can reduce idle CPU time. Also, you can have as many
concurrent tasks as you have work (e.g. 100k client connections each
waiting on I/O), rather than being limited by a thread pool size. Of
course, CPU-bound tasks don’t
speed up by using more threads than you have cores – virtual threads
are most beneficial for I/O-bound and mixed workloads.
- Lower
Memory and Lower Contention: With platform threads,
having tens of thousands of threads would not only eat huge memory,
it could also lead to scheduler thrashing. Virtual threads avoid
that by not holding up OS threads, and by using a small pool of
carriers. They also integrate with Java’s locking and
synchronization mechanisms: for example, Java’s locks (ReentrantLock, etc.) have been updated
so that a blocking lock will park the
virtual thread without blocking a carrier thread. This
means operations like Future.get() that block can be used freely in virtual
threads without tying up system threads.
In summary, virtual threads improve scalability, throughput, resource usage, and developer productivity for concurrent applications. They let you write simple code that can handle a massive number of concurrent tasks, especially when those tasks involve waiting on I/O.
Example: Creating and Using Virtual Threads (Java Code)
Using virtual threads in Java is straightforward. The JDK provides new APIs to create virtual threads:
- Using Thread.startVirtualThread: The
simplest way to spawn a virtual thread is via Thread.startVirtualThread(Runnable). For example:
Thread.startVirtualThread(() -> {
System.out.println("Running in a virtual thread: " + Thread.currentThread());
});
This creates and starts a new virtual thread to run the given lambda task. The Thread.currentThread() inside will identify it as a virtual thread (you might see a name like VirtualThread[#...]).
- Using
Executors newVirtualThreadPerTaskExecutor(): The Executors utility provides an executor service that
creates a new virtual thread for each task. For example:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> result = executor.submit(() -> {
// Some task, e.g. simulate work
Thread.sleep(100);
return "Task done on " + Thread.currentThread().getName();
});
System.out.println(result.get());
}
In this example, the executor will spawn a virtual thread for the submitted task. I use try-with-resources to auto-close the executor, which waits for all virtual threads to finish before closing. The output of result.get() will confirm the task ran on a virtual thread.
Notably, Executors.newVirtualThreadPerTaskExecutor() does not use a thread pool internally – it creates new virtual threads on the fly for each submit. There is no need to size a pool or recycle threads. This is a paradigm shift: you can spawn as many threads as you have tasks, and the JVM will handle scheduling them efficiently.
Note: To run virtual threads, you need to be on Java 21 or higher (or Java 19/20 with preview enabled). In JDK 21+, no special JVM flags are required – virtual threads are a normal feature. In JDK 19 or 20, you would need to run with --enable-preview and use the preview APIs.
Real-World Analogy: Bartenders and Customers (Thread-Per-Task vs Virtual Threads)
A helpful analogy for understanding virtual threads is to imagine a bar or coffee shop:
- Classic Threads (Platform Threads) – One Bartender per Customer: In a traditional setup, each customer is assigned their own personal bartender who takes their order and prepares their drink. While the drink is brewing or the bartender is waiting for something (e.g., waiting for change, waiting for a blender), that bartender just stands idle, doing nothing else. They cannot serve other customers until they finish with the current one. If the bar is busy with 50 customers, you would need 50 bartenders, many of whom might be idle at any given moment waiting on drinks. This is how the classic one-thread-per-request model works – each thread handles one task at a time and if it’s waiting on I/O, that entire thread (and its OS resource) is just parked, unable to do other work. It’s obviously inefficient if the threads spend a lot of time waiting.
- Virtual
Threads – Few Bartenders for Many Customers: Now
imagine a smarter bar. There are only a few bartenders (say 2 or 3),
but they are not tied to a single customer.
If bartender A is preparing a drink and needs to wait 30 seconds for
it to brew, he can quickly switch to help another customer in the
meantime. When the first drink is ready or the customer has a
question, the next available bartender can handle it. In this
scenario, customers don’t each occupy a
bartender while waiting – any free bartender can
attend to whatever customer’s order is ready to progress. This
models how virtual threads use a small pool of OS threads
(bartenders) to handle a large number of virtual threads
(customers/tasks). While a virtual thread is
“waiting”, it’s not holding up a real thread. The JVM
will schedule another runnable task on the OS thread until the
waiting task can resume. The result: you can serve many more customers with the same few bartenders,
as long as each customer spends a lot of time waiting (for brewing,
etc.). This is analogous to how virtual threads enable concurrency:
you can have many more concurrent operations than OS threads,
because idle time doesn’t consume an OS thread.
In the traditional model, if you had 1000 customers concurrently, you’d need ~1000 OS threads (bartenders) which is impractical. With virtual threads, 1000 customers can be handled by, say, a pool of 10 OS threads cycling through tasks, because at any given time maybe only a small fraction are actively using a thread. This analogy underscores why virtual threads are so much more scalable for I/O-bound workloads: they eliminate the waste of threads sitting idle. The system can keep its limited number of OS threads busy serving whichever tasks are ready to run.
History of Virtual Threads and Project Loom
The journey of virtual threads began with Project Loom, an OpenJDK project started to explore “fibers” (as virtual threads were originally called). The goal was to drastically improve Java’s concurrency by introducing lightweight user-mode threads. Key milestones in the history:
- Early
Development (Fibers): The concept of fibers
(lightweight threads managed by the runtime) has been around for a
while. The Loom project team at Oracle (led by Ron Pressler) spent
years prototyping and refining how to implement fibers in the JVM.
During development, virtual threads were called Fibers internally. Early Loom builds showed
promising results but also revealed challenges (for example,
interactions with synchronization, debuggers, etc.).
- JDK 19
Preview (2022): Virtual Threads were first
delivered as an official preview feature in Java
19 (JEP 425) in September 2022. Developers could
enable preview features and try out the new Thread.ofVirtual().start() API,
the virtual thread executors, etc. At this stage, Loom was still
being tested – for example, one known limitation was that using synchronized blocks could pin a virtual thread to an OS thread, reducing concurrency if a virtual thread entered
a synchronized block and then performed blocking I/O. Despite such
limitations, the preview already proved that the model works: it
could scale to millions of threads, dramatically reducing the effort
to write concurrent code.
- JDK 20
Second Preview (2023): In Java 20, virtual threads
got a second preview (JEP 436), incorporating feedback and
improvements. This continued to be incubator/preview mode. By this time, many frameworks and
libraries started experimenting with Loom to ensure compatibility.
- JDK 21
General Availability (LTS, 2023): Java
21 released in September 2023 included virtual
threads as a standard, production-ready feature (no preview flag
needed). This was a huge milestone – JDK 21 being an LTS (Long Term
Support) release meant broad adoption. The final JEP 444 for JDK 21
described virtual threads as “lightweight threads that dramatically
reduce the effort of writing, maintaining, and observing
high-throughput concurrent applications”. Many limitations were
resolved by now. (For instance, the issue of pinned threads with synchronized was
largely addressed in JDK 21, and further improvements like removing
monitor pinning completely are coming in JDK 24.)
- Adoption in Frameworks (2023-2024): With JDK 21, major Java frameworks moved to support virtual threads. This includes Spring Framework/Spring Boot, as well as other frameworks like Quarkus and servers like Tomcat, Jetty, etc., which needed tweaks to fully utilize virtual threads. The ecosystem began embracing Loom – e.g., JDBC drivers, Netty, and other libraries examined their code for thread-blocking or synchronization that might affect virtual threads.
In summary, virtual threads went from an experimental idea to a fully-fledged feature in about 3-4 years. They are now available in Java 21+ (and usable in 19/20 with preview) and represent one of the biggest changes to Java’s concurrency model in decades. Project Loom continues to evolve (e.g., adding structured concurrency APIs for managing groups of virtual threads, and further optimizations). But even without additional APIs, the core feature of virtual threads is ready to use now.
How Virtual Threads Change Java Programming
The advent of virtual threads invites us to revisit many established practices in Java concurrency. Here are some ways programming changes with virtual threads:
- Thread-Per-Task
is Viable Again: Traditionally, creating a new
thread for each task (e.g. each web request) was considered too
expensive, so I used thread pools or asynchronous I/O to avoid
blocking too many threads. With virtual threads, the thread-per-request model becomes practical and
efficient. You can spawn a new virtual
thread for every incoming task without worrying about exhausting
resources. This simplifies programming: you can write code in a
straightforward synchronous style (one request handled by one
thread) and trust the runtime to manage the scaling.
- Less Need
for Async Frameworks: Many asynchronous or
reactive programming models in Java (CompletableFutures, callbacks,
reactive streams) exist largely to cope with the limitations of
platform threads. They let you handle more concurrent operations by
not blocking threads. Now, with virtual threads, you can often get
the same throughput with a simpler synchronous approach. As the
Spring team put it: the reasons to use
asynchronous programming models go away in many cases if our code
runs on virtual threads. For example, in a Spring MVC
app, you might no longer need to use Servlet async request
processing or external task executors to avoid tying up threads –
you can just let each request block as needed on I/O, using virtual
threads underneath. That said, reactive
programming isn’t obsolete – it’s still
beneficial for certain patterns (like streaming, or when you need to
coordinate complex asynchronous workflows or apply backpressure).
But virtual threads mean you choose reactive for its model, not
because of thread scarcity.
- Simplified
Code and Debugging: Writing concurrent code with
virtual threads often means writing sequential
code that reads top-to-bottom, which is easier to
understand and debug than callback-based flows. Each virtual thread
has a standard Java call stack, so debugging is the same as with
normal threads (and tools like debuggers, profilers, and Flight Recorder have
been updated to handle lots of virtual threads). It’s easier to
profile and trace threads doing work than it is to trace reactive
pipelines that hop threads. So in many cases, developer productivity
improves – you spend less time reasoning about complex asynchronous
control flow.
- Revisiting
Timeouts and Backpressure: One thing that doesn’t
change with virtual threads is that blocking
calls still incur latency – Loom
doesn’t make I/O magically faster, it just makes thread management
more efficient. You still need to handle cases where too many
operations overload a database or where calls are slow. Techniques
like timeouts, rate limiting, or backpressure (in reactive systems)
remain important. Virtual threads prevent your server from running
out of threads, but they can’t create infinite database connections,
for example. In fact, if you enable virtual threads and suddenly
allow 10x more concurrent requests to hit a database, you might
overwhelm the DB connection pool or saturate CPU if all requests
become active at once. So you might need to adjust other resources
(connection pool sizes, etc.) to keep up.
- Structured
Concurrency: Project Loom also introduced the
concept of structured concurrency (in preview): a way to handle
multiple subtasks in parallel within a method, and then join them,
with the structure of the code ensuring proper cancellation and
error handling. This goes hand-in-hand with virtual threads by
providing high-level control of groups of threads. While not yet a
standard feature (as of JDK 21 it’s an incubation API), it’s worth
noting as a future programming model improvement – making concurrent
code look a bit like async/await in
other languages, but with explicit scoping of threads. Developers
can experiment with StructuredTaskScope to run, for example, several virtual threads
in parallel and then wait for all or the first to finish. This
pattern can replace manually managing countdown latches or complex
reactive merges with something more readable.
In summary, virtual threads encourage a “synchronous style for asynchronous work”. You can write blocking calls as if each task had its own thread (because it does – a virtual one), and the system handles the scaling. It reduces boilerplate and mental overhead. However, developers should still apply good concurrency practices: be mindful of shared state, use proper synchronization (though avoid long blocking inside synchronized blocks), and consider capacity of downstream systems. The programming model shifts to make concurrency more horizontal (many small threads) instead of vertical (stacking tasks in single thread or complex pipelines).
Spring Boot Support for Virtual Threads – Versions and Setup
Spring Boot has eagerly adopted virtual threads to empower Spring developers. Spring Boot 3.2 (released late 2023) is the first version with built-in support for virtual threads, designed to work with JDK 21’s Loom feature. Starting with Spring Boot 3.2, you can enable virtual thread processing with a simple configuration property, without any custom code.
Here’s a quick timeline of Spring Boot support:
- Spring Boot
3.0 and 3.1 (2022 – mid 2023): These
versions were based on Java 17 (Spring Framework 6.0) and came out
before Loom was GA. They did not have official Loom support
out-of-the-box. You could still experiment with virtual threads, but
you had to configure things manually (I’ll show how later).
Essentially, Spring Boot <=3.1 treats everything as platform
threads unless you customize it.
Spring Boot 3.2 (Nov 2023): Official
virtual threads support arrived. Spring Boot 3.2 (with Spring
Framework 6.1) runs on Java 21 and allows you to easily switch the
application to use virtual threads for handling requests and other
tasks. This is as simple as adding a property in application.properties:
spring.threads.virtual.enabled=true
- With that
setting (and running on Java 21), Spring Boot will automatically
use virtual threads for all request
processing threads and many background tasks. Under the hood, if
you’re using an embedded Tomcat or Jetty web server, it will
configure them to use a virtual thread per request instead of the
old thread pool. I’ll discuss the details in the next section.
- Spring Boot
3.3 and 3.4 (2024): These continued to
improve Loom integration. By Spring Boot 3.4, the support for
virtual threads was expanded to more components and servers. For
example, Spring Boot 3.4 added official support for Undertow web
server to use virtual threads (previously, 3.2 covered Tomcat and
Jetty). Also, additional components such as certain Micrometer meter
registries now use virtual threads when enabled. In short, each
minor release is making virtual threads more seamless across the
Spring ecosystem.
- Spring Boot
3.5+ and beyond: As of writing (2025),
Spring Boot is expected to further optimize and embrace virtual
threads. Future improvements might include enabling virtual threads
by default when running on Java 21+, once it’s proven safe to do so
(currently it’s opt-in). Also, as third-party libraries (database
drivers, etc.) fully support Loom, Spring can turn on virtual
threads confidently for production by default. Keep an eye on Spring
Boot release notes for updates.
In summary: To use virtual threads in Spring Boot, you should be using Spring Boot 3.2 or higher and running on Java 21+. Ensure your spring-boot-starter-parent or Spring Boot dependencies are 3.2.x or newer. Then simply enable the spring.threads.virtual.enabled property (which can be true in application properties or set as an environment variable). There’s no additional library to add for Loom – it’s part of the JDK. Spring Boot will do the rest of the magic behind the scenes.
How Spring Boot Integrates Virtual Threads
When you enable virtual threads in Spring Boot (via the property or manual config), what actually happens under the hood? Spring Boot and Spring Framework make a number of integration points to route work onto virtual threads:
- Web Server
Request Handling: In a typical Spring Boot MVC
application (Servlet stack), the embedded web server (Tomcat, Jetty,
or Undertow) uses a pool of threads to handle incoming HTTP
requests. Spring Boot 3.2 reconfigures these servers to use Loom’s
virtual thread executor instead of a fixed thread pool. For Tomcat
and Jetty, this means each incoming request is handled by a virtual thread (the server uses
an Executor that
creates a new virtual thread per task). So if you have 1000
concurrent requests, Tomcat will spawn 1000 virtual threads (very
fast and lightweight) rather than queuing them waiting for, say, 200
operating system threads. Undertow support was added in 3.4, so
Undertow will similarly use virtual threads if enabled.
- Spring MVC
Controller Execution: Since the web server
dispatch thread is a virtual thread, all the work done in the
controller and service layers for that request will run in that
virtual thread context. This means if your controller calls a
repository method that blocks on a database query, that blocking
will park the virtual thread without
hindering other requests. From a developer’s perspective, nothing
changes in how you write the code – it’s still a normal synchronous
method – but behind the scenes it’s highly scalable.
- @Async and Thread Pools: Spring’s
@EnableAsync and @Async methods rely on a task
executor to run background tasks. By default, Spring uses SimpleAsyncTaskExecutor (or a
configurable TaskExecutor) for
these async calls. Spring Framework 6.1 (used in Boot 3.2) updated SimpleAsyncTaskExecutor to use virtual threads when available.
That means if you have @Async methods
and Loom is enabled, each async invocation will spawn a virtual
thread instead of using a limited thread pool. This happens
automatically when spring.threads.virtual.enabled=true –
behind the scenes Spring Boot switches the default executor to one
backed by Loom. This greatly simplifies using @Async because you no longer have to worry about
thread pool exhaustion for CPU-light tasks; you can fire off lots of
parallel operations safely.
- Asynchronous
Servlet Requests: The Servlet API has
an async mode (where a request can be handled in a separate thread
and the container thread is freed). This was important for scaling
with limited threads. However, if you are using virtual threads to
handle requests, the need for Servlet async diminishes – you could
just keep the request on the virtual thread. In fact, Spring experts
have noted that starting an async servlet request to free threads is
not necessary when using virtual threads, since the server thread is
cheap and doesn’t block an OS thread while waiting. Spring Boot
doesn’t forbid you from using the async servlet model, but you might
find it simpler to stick with the normal synchronous processing with
Loom.
- Spring
WebFlux (Reactive) and Blocking Calls: Spring
WebFlux is the reactive, non-blocking web framework in Spring.
Normally, you avoid any blocking calls there; but if you do need to
call a blocking API, you would typically use Schedulers.boundedElastic() to
run it on a separate thread pool. With Loom, Spring Boot’s Loom
integration will attempt to use virtual threads for such blocking
segments. Indeed, when virtual threads are enabled, any
Spring WebFlux code that explicitly performs blocking (like using block() or similar
via reactor’s publishOn) will create virtual threads instead of
blocking a precious few event-loop threads. Also,
Project Reactor (the reactive engine) introduced a Schedulers.virtual() in Reactor 3.6+ which leverages virtual
threads (if on Java 21) for scheduling tasks on a thread-per-task
basis. In short, Loom can complement reactive by making any
necessary blocking interop more efficient. However, if you’re fully
non-blocking, virtual threads don’t give additional performance
benefit – they’re more about making blocking code cheap. WebFlux
apps might choose to stay purely reactive or might mix in Loom for
convenience when calling legacy blocking libraries.
- Messaging
Listeners and Other Tasks: Spring Boot’s virtual
thread support also extends to other asynchronous processing. For
example, message listener containers for RabbitMQ
or Kafka can use virtual threads for dispatching
messages. Spring Integration or Spring Batch jobs that run tasks can
also utilize virtual threads. Essentially, many places where Spring
would normally create a thread or use a thread pool have been wired
to opt-in to virtual threads if enabled. This includes scheduled
tasks (@Scheduled) and background actuator tasks, etc. The Spring
Boot 3.2 release notes mention that even components like Redis stream consumers or Pulsar listeners will work with virtual threads when turned
on.
All of the above happens mostly behind the scenes. As a developer, you just flip the switch (spring.threads.virtual.enabled=true). The framework ensures that Tomcat/Jetty/Undertow gets an executor from Executors.newVirtualThreadPerTaskExecutor(), and that Spring’s various TaskExecutor and scheduler beans use virtual threads. The result is a broad adoption of Loom across the runtime.
For completeness, if you are on Spring Boot 3.1 or earlier (or if you want fine-grained control), you can manually integrate virtual threads by defining beans. For example, you could register a custom TaskExecutor bean that uses Executors.newVirtualThreadPerTaskExecutor(), or a custom TomcatProtocolHandlerCustomizer bean to set Tomcat’s executor. This was the technique suggested in early experiments. But if you’re on 3.2+, the easier route is using the property.
Enhancements in Spring Boot 3.4 and Above
As mentioned, Spring Boot 3.4 (released late 2024) further enhanced virtual thread support. Notable improvements in Spring Boot 3.4+ include:
- Undertow
Web Server Support: In Spring Boot 3.2 and 3.3,
Tomcat and Jetty were the focus for Loom support. Spring Boot 3.4
added Undertow to the mix. If you use Undertow as your server and
enable virtual threads, now Undertow will
use virtual threads for request handling as
well. This means all major embedded servers in Spring Boot support
virtual threads (Tomcat, Jetty, and Undertow).
- Metrics and
Observability Threads: Spring Boot 3.4 also
ensures certain background tasks run on virtual threads when
enabled. For example, Micrometer’s OtlpMeterRegistry (which
periodically pushes metrics to an OpenTelemetry collector) will use
a virtual thread instead of a platform thread if virtual threads are
on. This is part of Spring Boot’s goal to have a consistent
approach: if Loom is enabled, virtually all internal thread usage should align with that
to save resources.
- Polish and
Stability: By 3.4, the integration had
time to stabilize and incorporate community feedback. Any rough
edges from 3.2 (the initial integration) were smoothed out.
Performance of using virtual threads in Spring got better as
libraries adapted. Spring Boot 3.4 and Spring Framework 6.1.x
refined things like thread naming, diagnostics, and compatibility.
For instance, thread dumps in a Spring Boot app might show many
virtual threads, so improvements in how they’re grouped or labeled
help in debugging. Spring’s documentation by this time also added
guidance for Loom.
- Future
(Spring Boot 3.5 and 4.0): While not in 3.4 yet,
it’s expected that future versions will continue this trajectory.
Spring Framework 6.2/7 might incorporate structured concurrency
helpers, or make virtual threads usage even more automatic. We might
see a time where spring.threads.virtual.enabled defaults to true when running on a
Loom-enabled Java, making it opt-out rather than opt-in.
For now, if you are on Spring Boot 3.4+, you can be confident that enabling Loom covers essentially all server and asynchronous execution aspects of your app. Always check the release notes for your Spring Boot version to know exactly what components take advantage of virtual threads, as the list grows with each release.
Spring Boot vs Classic Java: What’s the Added Value with Virtual Threads?
You might wonder, if virtual threads are a core Java feature, what extra does Spring Boot bring to the table? Why not just use Loom directly in a plain Java program? Here are a few ways Spring Boot adds value when using virtual threads:
- Auto-Configuration
and Convenience: Spring Boot makes it trivial to
use virtual threads in a web app. Without Spring, if you wrote a
server using standard APIs, you’d have to configure the thread pools
or executors to use virtual threads manually. Spring Boot 3.2+
essentially does that for you – a single property flips the whole
application’s thread management to Loom mode. It configures the
embedded container, the TaskExecutor for
@Async, message
listener containers, etc., all to use virtual threads. This saves
you from writing boilerplate and ensures consistency.
- Integration
with Framework Components: Spring provides a lot
of abstractions (like RestTemplate/RestClient, JdbcTemplate/JdbcClient, transaction management,
messaging templates, etc.). Spring Boot’s integration means that
these components will work smoothly with virtual threads. For
example, if a transaction is started on a virtual thread, Spring’s
transaction synchronization will still work as normal. Or if you use
RestClient (the new
synchronous HTTP client in Spring Framework 6.1), each call will
block the virtual thread, which is exactly what you want – and
Spring Boot 3.2 introduced a RestClient.Builder bean
by default to encourage making HTTP calls synchronously with virtual
threads. Essentially, Spring Boot guides you towards patterns (like
using the new JdbcClient or
RestClient which
are synchronous but efficient with Loom) that take full advantage of
virtual threads.
- Broad
Ecosystem Readiness: Spring is at the center of a
huge ecosystem. The Spring team worked with other projects to ensure
compatibility with Loom (for instance, making sure Spring Data and
various Spring integrations don’t accidentally block platform
threads in ways that harm Loom). When you go with Spring Boot, you
benefit from these collective efforts – Spring Boot 3.2+ is Loom-ready out of the box for
many common tech stacks. By contrast, if you hand-roll a Loom-based
server, you’d have to verify each library (database driver, etc.)
plays nicely. For example, older JDBC drivers using synchronized
sections may have needed updates for Loom; using Spring Boot means
by the time Loom was GA, Spring included the necessary tweaks or
documented any remaining gotchas.
- Monitoring
and Metrics: Spring Boot’s Actuator and
Micrometer integration also now account for virtual threads. For
instance, if you enable metrics, the system may track the number of
active threads. With Loom, you might have thousands of virtual
threads, so metrics collection had to be adjusted to not overwhelm
or to differentiate between virtual and platform threads. Spring
Boot’s adoption means those considerations have been made for you.
Even things like request tracing or MDC (diagnostic context) can
still work with virtual threads (though if you relied on thread
locals for MDC, it still works – just be mindful that with more
threads the volume of data could be higher).
In short, Spring Boot provides a cohesive, holistic integration of virtual threads. It’s not providing new capabilities beyond what Java offers, but it ensures that when you enable Loom in a Spring app, everything “just works” and you can focus on writing your business logic. Spring Boot’s abstractions abstract away the low-level thread management, which is exactly what it always aimed to do – now those abstractions simply utilize a much more powerful underlying thread model.
Setting Up Your Project – Maven Dependencies and Configuration
To use virtual threads in a Spring Boot project, you primarily need the right Java version and Spring Boot version. Here’s how to set up your project:
- Java 21 or
Above: Make sure your project is set to use Java
21 (or later). In Maven, that means setting the java.version (if
you use the Spring Boot starter parent) or the maven compiler
source/target to 21. For example, in your pom.xml properties:
<java.version>21</java.version>. If you are using Gradle, ensure the Java
toolchain is 21. Virtual threads are a Java feature, so this is
non-negotiable.
- Spring Boot
3.2+ Dependency: Use Spring Boot 3.2.0 or higher.
For example, in Maven your parent or BOM should be spring-boot-starter-parent version 3.2.x (or 3.3, 3.4, etc., as long as
>=3.2). This ensures the Spring Framework 6.1+ underneath, which
contains Loom support code. If using Gradle, use the Spring Boot
plugin with version 3.2+.
Spring Starters: Include the
usual Spring Boot starters for the modules you need (web, data,
etc.). There is no special “loom” library to
add – it’s built into the JDK. For
instance, a typical web app pom might include:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
- and perhaps spring-boot-starter-data-jpa or
others as needed. The key is the Spring Boot version, not the
artifact, for Loom support.
(If using Java 19 or 20 Preview): If you happen to try Loom on Java 19/20 (not
recommended for production), you would need to enable preview
features. In Maven, that means adding compiler and runtime flags. For
example, in the Maven Compiler Plugin configuration:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>19</source>
<target>19</target>
<compilerArgs>--enable-preview</compilerArgs>
</configuration>
</plugin>
- And similarly,
you’d run the JVM with --enable-preview. However, since Java 21, this is no longer needed
as virtual threads are standard.
- Enable
Virtual Threads in Spring Boot: As discussed, add
spring.threads.virtual.enabled=true to
your application.properties or
application.yml. Alternatively,
you can set the environment variable SPRING_THREADS_VIRTUAL_ENABLED=true. If you forget this step on Spring Boot 3.2, the
app will still run on platform threads by default.
- No
Additional Dependencies: You do not need
to add jdk.incubator.concurrent or
anything – that was only for structured concurrency (which is still
incubating). For basic Loom threads, everything is in the core JDK.
If you’re using modules, ensure you don’t accidentally deny access
to the JDK’s jdk.internal.vm module
(but normally not an issue).
Once this setup is done, you can launch your Spring Boot application on Java 21 and enjoy virtual threads. The console at startup will likely show that it’s using the given number of virtual threads for the server, etc. (In Tomcat, you might see it logging the custom executor being set if you have debug logs, or you might log something in a TomcatProtocolHandlerCustomizer bean as I do in the example below.)
Using Virtual Threads in Spring MVC (Servlet Context)
Spring MVC (the Servlet-based web framework) traditionally uses a thread-per-request model out of the box. Normally, Tomcat/Jetty maintain a pool of, say, 200 threads to handle requests. With Loom, Spring MVC can still use the same programming model but without the thread pool bottleneck.
When you enable virtual threads in a Spring MVC app:
- Each
request runs on its own virtual thread. The
Servlet container will create a virtual thread for each incoming
HTTP request, instead of grabbing a thread from the pool. These
virtual threads are created via the Loom executor so they are
extremely cheap. That means if your app suddenly gets 1000
concurrent requests, the container will just spawn ~1000 virtual
threads instantly to handle them (assuming memory allows), rather
than queuing or rejecting excess requests due to pool limits.
- Controller
code doesn’t change. You still write controllers
with methods that can block (e.g., calling a repository that
executes a SQL query). The difference is, if a controller does
something like Thread.sleep(5000) or waits on a slow DB call, the underlying OS
thread is freed up to handle other requests in the meantime. Your
controller’s virtual thread will resume when the sleep or DB call is
done, and it will likely get an OS thread from the pool at that time
to finish processing.
- No need for
DeferredResult or
Servlet 3 async in most cases. In the past, if you
had a long-running request, you might use DeferredResult or CompletableFuture in a controller to
handle it asynchronously and immediately free the request thread.
With Loom, you can often avoid that complexity. Just let the request
run synchronously on a virtual thread – it won’t hog an OS thread,
so you’re not hurting scalability. This simplifies code for
long-polling or streaming responses (although for truly streaming
responses, reactive still has advantages, but for something like
waiting 5 seconds then returning data, a virtual thread is fine).
- ThreadLocal
considerations: If you use ThreadLocal variables
(or things like Spring’s RequestContextHolder,
which binds request data to the thread), those still work with
virtual threads – each virtual thread is a Thread instance, so it has its own ThreadLocal
storage. However, note that since you might have many more threads,
heavy use of ThreadLocals (especially with large values) could
increase memory usage. Spring’s request attributes and security
context etc., continue to function as expected in Loom threads. Just
be cautious about using ThreadLocals for large caches or data – not
because of Loom per se, but because you might create far more
threads than before.
- Tomcat and
Jetty config tweaks: If you’re curious, what
Spring Boot does internally is set Tomcat’s Executor to
an implementation that uses virtual threads. In earlier times, you
could do this manually by providing a bean of type TomcatProtocolHandlerCustomizer that
calls protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor()).
Spring Boot 3.2 essentially does that for you when you set the
property. The maximum thread settings in Tomcat (maxThreads) become
less relevant – you might see Tomcat’s thread pool size be very high
or effectively unbounded because it’s not an actual OS thread pool
anymore. You also don’t need to adjust the <Connector> thread config because Loom bypasses the
traditional pool. Undertow in 3.4 similarly will use XNIO worker
configured to dispatch tasks to virtual threads instead of a fixed
pool.
In summary, Spring MVC with Loom behaves like the classic Spring MVC model, but supercharged. Each request has an isolated thread of execution (which is great for simplicity and thread-safety), yet we’re not constrained by the number of OS threads. This gives a huge boost to synchronous web applications, allowing them to handle high concurrency without resorting to reactive programming if not needed. Just be aware that other bottlenecks (CPU, DB connections) still apply – Loom removes the thread limitation, but you must still capacity-plan other resources accordingly.
Virtual Threads in Reactive Context (Spring WebFlux/Reactor)
Spring WebFlux is built for asynchronous, non-blocking execution using a small number of threads (event loop workers) and reactive programming. At first glance, it might seem that virtual threads are not needed there – after all, the whole point of reactive is to avoid blocking threads. And indeed, if you have a 100% non-blocking pipeline, Loom doesn’t improve throughput because you’re not blocking threads significantly anyway.
However, there are a few intersections between Loom and reactive:
- Blocking to
Reactive Bridge: In reactive code, if you must
call a blocking API (like a legacy service or a slow computation),
the recommended approach is to schedule that call on a separate
Scheduler (like Reactor’s boundedElastic or a custom Scheduler).
BoundedElastic by default uses a pool of traditional threads to
offload blocking tasks. Reactor 3.6 introduced a Schedulers.virtual() (or
similar mechanism) that will create a new virtual thread for each
submitted task, if running
on Java 21+. This means rather than maintaining a pool of, say, 10
elastic threads that might get exhausted, Reactor can just spin up a
virtual thread for each blocking task and then shut it down. The
benefit is improved scalability and simpler tuning (no need to set
pool sizes) for the blocking parts of a reactive flow.
- Mixing
Models: Some applications use WebFlux not for
higher throughput per se, but for other reasons like functional
style or streaming. If those apps still end up doing blocking calls
(perhaps using old drivers), enabling Loom in Spring Boot ensures
those blocking calls don’t stall the reactive event loop threads.
Spring Boot 3.2’s integration will cause any Spring-provided
mechanism for blocking (like using @Async within
a WebFlux app, or using the block() method in a controller) to utilize a virtual
thread. This prevents one misbehaving blocking call from ruining the
scalability of the reactive pipeline.
- Reactor
Core Adaptation: The Reactor team is
actively working to support Loom. As of Reactor Core 3.5 and 3.6,
they’ve made internal changes so that certain operators can detect
if they’re running with Loom and adjust behaviors. Also, debugging a
reactive chain that has thousands of virtual threads is something
they’ve considered – though typically, you won’t spawn that many
threads in a reactive app unless you explicitly do so for blocking
operations.
- Will Loom
replace Reactive? This is a common question. The
answer is not entirely –
they address different problems. Reactive (like Project Reactor) is
great for composing asynchronous operations (e.g.,
zipping results, managing streams of data, applying backpressure to
send data at a rate a consumer can handle). Virtual threads excel at
making blocking IO scalable but don’t inherently provide a way to
coordinate multiple async tasks or stream data in chunks. Josh Long
of the Spring team put it succinctly: use WebFlux
or Reactor if you need functional composition of streams or you’re
already in a non-blocking architecture; use virtual threads if your
problem is basically blocking I/O concurrency.
They can also complement each other – you could run a mostly
reactive app, but if there’s one part that is easier to write with a
blocking call, you could offload that to a virtual thread for
simplicity.
To sum up, in a pure WebFlux scenario you might not directly “see” virtual threads at play (since ideally nothing blocks). If you enable spring.threads.virtual.enabled, it won’t hurt – it will ensure that any incidental blocking uses a virtual thread, and the WebClient’s new RestClient or JdbcClient can use blocking internally on virtual threads which might simplify calling external services from a reactive app. Reactor’s integration (via Schedulers.virtual()) is an additional tool if needed. But if your application is heavily reactive and non-blocking, you might not gain much by enabling Loom except possibly simplifying some parts of your code where blocking was unavoidable.
TaskExecutor and @Async with Virtual Threads
Spring’s TaskExecutor interface and the @Async annotation provide a way to run methods asynchronously on a separate thread. Prior to Loom, these typically used a thread pool (like a SimpleAsyncTaskExecutor which would spawn new threads as needed, or a ThreadPoolTaskExecutor with a fixed pool). With virtual threads, using @Async becomes more powerful:
Default Behavior Change in Spring Boot 3.2: As
noted, when virtual threads are enabled, Spring Boot configures the application’s default AsyncTaskExecutor to use virtual threads.
Specifically, SimpleAsyncTaskExecutor will
delegate to Executors.newVirtualThreadPerTaskExecutor(). So if you have code like:
@Service
public class MyService {
@Async
public void doSomethingAsync() {
// ... some logic that might block
System.out.println("Async thread: " + Thread.currentThread());
}
}
- and you’ve
enabled virtual threads, each call to doSomethingAsync() will
run in a separate virtual thread instead of a platform thread. If
you print the thread, you’ll see VirtualThread in its name. This is huge because it means
you can fire many async tasks without worrying about a fixed thread
pool. The tasks will not contend for a limited number of threads;
each gets its own virtual thread.
Custom TaskExecutors: If you
want finer control or if you’re on a version before 3.2, you can
define a custom TaskExecutor bean.
For example:
@Bean(name =
TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
public AsyncTaskExecutor asyncTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
- This bean
replaces the default executor with one that uses virtual threads for
each task. TaskExecutorAdapter is
an adapter that allows using a plain Executor (like from JDK) as a
Spring AsyncTaskExecutor. By
doing this, any Spring @Async method or any code using that executor bean
will utilize Loom threads. This is exactly what Spring Boot does
behind the scenes when you enable the property, so you typically
don’t need to write it yourself (unless you want to tweak behavior).
- Thread Pool
vs Virtual Thread Executor: One might ask, could I
use a ThreadPoolTaskExecutor with
virtual threads as threads in the pool? That wouldn’t make much
sense – the whole idea of Loom is you don’t need to pool these
threads. The Executors factory method newVirtualThreadPerTaskExecutor essentially
is a pool that has an unbounded number of
threads, but implemented efficiently. You
shouldn’t try to limit the number of virtual threads via a pool – if
you do need to limit concurrency of tasks, you could use semaphores
or rate limiters, but not by restricting threads. So best practice
is to use the per-task executor.
- Nested
Async and Inheritance: Virtual threads propagate
thread locals like normal threads do (inheritable thread locals). If
your @Async methods relied
on inheriting a security context or other context via
InheritableThreadLocal, that still works with virtual threads.
However, keep in mind that if you spawn a huge number of threads,
storing context in inheritable thread locals might increase memory
usage (because each thread will have a copy). Spring Security’s
context propagation for @Async likely still functions (it typically uses a
TaskDecorator or similar to copy context).
- Scheduling
Tasks: If you use Spring’s TaskScheduler or
@Scheduled tasks, those by
default use a thread pool scheduler (usually a single thread or a
small pool). As of now, scheduled tasks are not automatically put on
virtual threads by Spring Boot’s flag (since scheduling usually
involves a fixed pool). However, you could configure a TaskScheduler that
launches each execution on a new virtual thread if you wanted to.
But typically, scheduled tasks are few and long-running, so they
might just use one platform thread. If you have thousands of
scheduled jobs that run concurrently, that might be a use case for
Loom – but it’s less common. The property I set doesn’t
automatically convert the scheduler threads to virtual; it mainly
targets request handling and async tasks.
Example of using @Async with Loom:
Suppose you have a controller that triggers an async process:
@RestController
public class AsyncDemoController {
@Autowired
MyService myService;
@GetMapping("/startTask")
public String startTask() {
myService.doSomethingAsync();
return "Task started";
}
}
And MyService has the @Async method as shown earlier. If Loom is enabled, calling /startTask will immediately return “Task started”, and the doSomethingAsync logic will run on a virtual thread in the background. If that logic involves blocking calls or heavy computations, it will not block any of Spring’s request threads – it’s on its own virtual thread. If 100 users call /startTask at once, you’ll get 100 concurrent virtual threads doing the work, without any thread starvation.
This model is extremely powerful for offloading work. Just remember that @Async methods, by default, don’t propagate the request context (like HTTP session or request info), so if your background thread needs info from the request, you should pass it as parameters. Loom doesn’t change that behavior.
Example: Synchronous Controller Using Virtual Threads
Let’s put it all together with a practical example of a Spring Boot controller leveraging virtual threads. I’ll simulate a scenario of a blocking operation (e.g., calling a slow service or database) to see how Loom helps.
First, ensure your application is configured for Loom (Spring Boot 3.2+, Java 21, property enabled as described). Then consider this simple controller:
@RestController
public class ReportController {
@GetMapping("/report")
public String generateReport() throws InterruptedException {
// Simulate a blocking I/O operation (e.g., database query or remote API call)
Thread.sleep(1000); // 1 second pause to simulate I/O
String threadInfo = Thread.currentThread().toString();
return "Report generated by thread: " + threadInfo;
}
}
This generateReport endpoint intentionally uses Thread.sleep to mimic a blocking operation that takes some time. If Loom is working, each request will run on a virtual thread, so one request sleeping doesn’t hinder others.
To test this in action, you could call the /report endpoint multiple times concurrently (e.g., using a load tester or simply hitting it in parallel via cURL or a browser). What you should observe:
- The
responses come back after ~1 second each (since each sleeps for 1
second), but if you fire, say, 50 requests in parallel, the server
should handle them all concurrently and they should all complete in
about 1 second (plus overhead), not serially or queued. This
indicates the server managed 50 concurrent sleeps without trouble.
- The returned thread
info should indicate a virtual thread. For example, it might return
a string like:
"Report generated by thread: VirtualThread[HttpServer-worker-19,…”.
The exact format can vary, but you’ll see VirtualThread or a specific name given by the server. In Tomcat, it might name them http-nio-8080-exec-... even if they’re virtual, unless explicitly renamed. But JDK’s Thread.toString() does show if it’s virtual or carrier. In any case, the important part is that it works concurrently beyond the normal pool limits.
Now, let’s look under the hood: Without Loom, if you had 50 requests each sleeping, and your Tomcat max thread pool was, say, 20, then only 20 would run and the other 30 would wait until threads freed up. With Loom, all 50 can have their own thread (virtual) because Tomcat is not limited by 20 OS threads; it can exceed that because the additional threads are virtual. Those virtual threads when sleeping do not consume an OS thread, so even if Tomcat had, e.g., 10 carrier threads in its pool, those 10 OS threads just cycle through the 50 virtual threads as they become runnable. The result is high concurrency with simple code.
For another example, let’s show how to configure via code (for older Spring Boot or custom config):
@SpringBootApplication
public class LoomDemoApplication {
public static void main(String[] args) {
SpringApplication.run(LoomDemoApplication.class, args);
}
// Only needed if not using spring.threads.virtual.enabled property
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
// Only needed for customizing @Async if not auto-enabled
@Bean(name = TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
public AsyncTaskExecutor taskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
}
This snippet shows two beans: one to configure Tomcat to use virtual threads for request handling, and one to configure the default async executor. In Spring Boot 3.2+, you wouldn’t need these because setting the property does the equivalent internally. But it’s illustrative of how straightforward it is: basically replace any thread pool with Executors.newVirtualThreadPerTaskExecutor().
Finally, always test your application under load when using a new concurrency paradigm. Virtual threads can handle a lot, but if your code has any thread-local or synchronization quirks, heavy load might reveal them. Check thread dumps to verify threads are indeed virtual (in a dump, virtual threads typically show up grouped or with a special naming). Also monitor memory and CPU: virtual threads use heap for their stacks, so the memory usage might show up in heap rather than native memory.
Best Practices and Considerations for Working with Virtual Threads
Virtual threads greatly simplify concurrent programming, but to use them effectively, keep these best practices and considerations in mind:
- Use Virtual
Threads for I/O-bound and High-Concurrency Tasks: Virtual
threads are ideal when you have a large number of tasks that spend
time waiting (e.g., lots of HTTP requests, file or network I/O,
database calls). They won’t speed up
CPU-bound tasks – if your task is
purely computing on the CPU, having more threads than cores won’t
make it faster. In fact, you should avoid spawning huge numbers of
threads for CPU-bound work, as they’ll just contend for CPU time.
Use virtual threads where you previously might have used
asynchronous I/O or where thread starvation was a worry.
- Don’t Pool
Virtual Threads – One per Task: With platform
threads, I often used thread pools to limit threads and reuse them.
With virtual threads, the guidance is not to
pool them. Create a new virtual thread for
each independent task (e.g., each request, each message to process).
The JVM can handle millions, so there’s usually no need to limit
thread creation. If you have 100,000 tasks, make 100,000 virtual
threads – that’s generally fine. The question of “how many threads
should I have” is replaced by “how many concurrent tasks do I have”
– ideally, one thread per task. If your application never has more
than a few thousand concurrent tasks, you might not benefit much
from Loom (because you were within limits of normal threads), but if
you do hit tens of thousands, that’s where Loom shines.
- Avoid
Mixing Blocking Code with Reactive in the Same Component: It’s
okay to have separate modules of your app, some using reactive, some
using blocking with Loom. But within the same flow, avoid
unnecessary complexity by mixing paradigms. For example, don’t use
WebFlux and then inside a handler use a blocking repository call
without scheduling – that could block the event loop. If you plan to
use blocking calls, either switch that component to Spring MVC with
Loom, or ensure you schedule it on a virtual thread (using Reactor’s
scheduler). As a rule, choose a concurrency
model per component to keep things
straightforward – either fully reactive or fully Loom. Spring allows
both to coexist (and you might have reactive streams between
services and Loom threads at the edges), just be deliberate in
architecture.
- Watch Out
for synchronized Blocks
with Blocking Calls: One of the initial
limitations of Loom was pinning of virtual threads when they enter a
synchronized block and perform I/O. Essentially, if a virtual thread
is in a synchronized method
and blocks on I/O, it can’t relinquish its carrier thread – it gets
“pinned” to that OS thread until it leaves the synchronized block.
This reduces concurrency because that OS thread is stuck. JDK 21
alleviated many cases of this, but the general best practice
remains: avoid long or blocking operations
inside synchronized sections or locks. This
is a good practice even without Loom (it can reduce contention). For
example, don’t hold a lock while calling a slow webservice. If you
need to synchronize, keep the critical section small. The Spring
team reviewed their own usage of synchronized to minimize any such
issues, so the framework is mostly fine. But as an app developer, be
mindful of your synchronized blocks.
- Use Thread
Local Variables Sparingly: Virtual
threads support ThreadLocals, but remember you could have many more
threads than before. If you use ThreadLocal to hold large objects
(like a big buffer or user session data), having thousands of
threads means thousands of copies, which can increase memory usage.
Also, ThreadLocals that are never removed could accumulate if
threads churn a lot. Consider using alternative patterns (like
parameter passing or context objects) if possible. If you do use
ThreadLocal (e.g., MDC for logging), try to remove entries when done
to avoid leaks (this is standard advice, not Loom-specific).
- Resource
Limits (Connections, Memory, etc.): Don’t assume
that because you can create 1 million virtual threads, your app can
truly do 1 million things concurrently well.
Other resources will be limiting factors: e.g., database connection
pools still have maybe 100 connections available – if 1000 threads
all try to query the DB at once, 900 will wait for a connection.
That’s fine (they’ll just park), but throughput might not improve
after a certain point. Similarly, each thread (even virtual) uses
some memory for its stack and task – if you spawn millions, you can
still run out of heap if you’re not careful. Monitor memory (heap)
when using very large numbers of threads, and consider increasing
heap size if needed to accommodate many thread stacks (which are on
heap for virtual threads). Also be aware of the stack
trace depth – a deeply nested call
stack on a virtual thread will consume more heap. Typically not an
issue, but something to note.
- Cooperative
Scheduling – Avoid Blocking the CPU Too Long: The
scheduling of virtual threads is mostly done by the JVM, and when a
virtual thread does a blocking operation the JVM knows to swap it
out. However, if a virtual thread is doing a compute-heavy
task and never hits a blocking call, it won’t yield
control unless the scheduler time-slices (as of now, the Loom
scheduler relies on either blocking points or until the OS preempts
the carrier thread). This means if you write a virtual thread that
runs an endless CPU loop, it can still hog a carrier OS thread. So,
virtual threads don’t magically preempt your
code more often. They rely on either I/O or calling Thread.yield() or
similar to give up the CPU. In practice, for normal workloads this
isn’t an issue, but just know that if you have long CPU-bound loops,
cooperative scheduling means they might not switch until completion.
This is why Loom is best for I/O-heavy scenarios. If needed, you can
break up long computations or explicitly yield periodically.
- Debugging
and Profiling Large Numbers of Threads: When you
have thousands of virtual threads, operations like thread dumps can
produce a lot of output. Tools like IntelliJ debugger or VisualVM
have been updated to handle virtual threads, allowing you to filter
or group them (for example, the debugger can hide all virtual
threads or only show ones at breakpoints). Take advantage of these
features to avoid being overwhelmed. Use meaningful thread names
where possible to identify tasks (Java will name threads for you if
using executors, but you can give custom names when creating threads
via builder APIs). In a Spring Boot app, by default the virtual
threads for requests might have names like http-nio-8080-exec-VirtualThread-... which include “VirtualThread” if you print
them. Logging frameworks like Tomcat’s access log might not be aware
of Loom (they just log thread names) but you can interpret from
context.
- Testing
Under Load: If you plan to go to production with
Loom enabled, it’s wise to do some load testing with a concurrency
level higher than you’d normally test with platform threads. For
example, if previously you’d test 100 concurrent users, try 1000 or
more with Loom to see how the system behaves. Sometimes you might
find new bottlenecks (like a database that can’t handle that many
simultaneous queries or an external service rate limiting you). It’s
better to know that and then perhaps intentionally limit concurrency
at a higher layer if needed (through a semaphore or request
throttle) rather than disabling Loom. You might decide, for
instance, “I’ll use Loom for efficiency, but I’ll still only allow
at most 2000 concurrent requests to hit this endpoint because beyond
that our downstream system fails.” You can implement such limits via
a simple counter or using something like Semaphore permits to gate execution.
- Stay
Updated: Project Loom is an ongoing project. While
virtual threads in Java 21 are production-ready, new improvements
(like pinning elimination in JDK 24, or structured concurrency utilities) are on the
horizon. Spring Boot and other frameworks will continue to adapt.
Keep your Java version and Spring version up-to-date to benefit from
the latest fixes and enhancements. For example, switching from Java
21 to Java 24 in the future might give an automatic performance
boost for certain synchronized scenarios. Similarly, Spring
Framework 6.1 to 6.2 could bring new features or better defaults for
Loom.
By following these best practices, you can make the most of virtual threads while avoiding potential pitfalls. In essence, Loom doesn’t remove the need for architectural thinking – it just removes a big limitation (thread exhaustion) and a lot of boilerplate. You still design your system with capacity and efficiency in mind. Used well, virtual threads can significantly increase the scalability of your Spring Boot applications with minimal changes to your code.
Conclusion
Virtual threads (Project Loom) represent a significant leap forward in Java concurrency. They allow us to write simple, synchronous code that can effortlessly handle huge numbers of concurrent operations – an ideal combination for many server-side applications. I’ve explored what virtual threads are, how they differ from classic OS-backed threads, and the advantages they offer such as lightweight thread creation, reduced memory usage, and easier-to-read code.
Spring Boot’s integration of virtual threads (since 3.2 on Java 21) makes it incredibly easy for Spring developers to tap into Loom’s power. With a single configuration switch, Spring Boot can run all your request handlers, @Async methods, and more on virtual threads, boosting scalability while you write code the same way you always have. The real-world analogy of bartenders and customers hopefully made it clear why this is such a game-changer – no more idle threads tied up waiting; the JVM efficiently utilizes a small pool of carrier threads to serve a vast crowd of tasks.
I also walked through code examples demonstrating how to create virtual threads, how to configure Spring Boot to use them, and a sample Spring controller benefiting from Loom. I discussed how programming models shift – for many cases, you can forget the complexity of reactive or callback-based concurrency and just let each request or task have its own thread. At the same time, I acknowledged that tools like reactive streams still have their place and that Loom doesn’t make everything magically faster (CPU work still is CPU-bound).
As you embrace virtual threads in your projects, keep best practices in mind: treat threads as cheap and plentiful, avoid long blocking inside synchronized sections, be mindful of thread-local usage, and test your system with the higher concurrency it now enables. The Java platform and Spring will continue to evolve together – for instance, watch for structured concurrency APIs and further Spring Boot optimizations in upcoming versions.
In summary, Java virtual threads + Spring Boot = simpler code and scalable performance. It’s a rare win-win in software engineering where you get both better developer experience and better runtime efficiency. If you’re a Java/Spring developer and haven’t tried virtual threads yet, now is the time – upgrade to JDK 21+, bump your Spring Boot version, turn on spring.threads.virtual.enabled, and see how your application can handle loads that would have been daunting before. Happy coding, and may your apps run with the lightweight freedom of virtual threads!
Sources:
- Mark Paluch, "Embracing Virtual Threads",
Spring Blog (Oct 11, 2022)
- InfoQ News, "Spring Boot 3.2 Delivers Support for Virtual Threads..." (Dec 15, 2023)
- Oracle Documentation,
"Virtual Threads",
Java Core Libraries Guide (Java 21)
- Spring Boot 3.4 Release Notes – Spring Project Wiki (Nov 2024)
- Quarkus Guide, "Virtual Thread support reference"
- Dan Vega, "Spring into the Future: Virtual Threads with Project Loom" (Apr 2023)
- Gianluca Lucci, "Why Upgrade to Spring Boot 3.4.5: Performance, Security, and Cloud‑Native Benefits".
Comments
Post a Comment