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:






















Comments

Popular posts from this blog

Monitoring and Logging with Prometheus: A Practical Guide

Creating a Complete CRUD API with Spring Boot 3: Authentication, Authorization, and Testing

Why Upgrade to Spring Boot 3.4.5: Performance, Security, and Cloud‑Native Benefits