spring.threads.virtual.enabled
Switches the embedded server, task executor and scheduler to Java virtual threads, so blocking code scales without large thread pools.
spring.threads.virtual.enabled=true- Added in Spring Boot Spring Boot 4.1 (verified present)
Parameters
| Parameter | Type | Description |
|---|---|---|
$valueoptional | Boolean= false | true to use virtual threads. Needs Java 21 or later; on older runtimes it has no effect. |
$spring.main.keep-aliveoptional | Boolean= false | Keep the JVM running even when no non-daemon threads remain. Useful when virtual threads (always daemon) are the only work. |
Return value
n/aWhether Boot's auto-configured components run work on virtual threads: the embedded web server's request handling, the applicationTaskExecutor used by @Async, the task scheduler, and supported messaging listeners.
A typical REST endpoint spends most of its time waiting: for the database, for another service, for a file. With platform threads every waiting request holds an operating system thread, so the server's thread pool (200 for Tomcat by default) caps how many requests can wait at once. Virtual threads are cheap JVM-managed threads that release their carrier while blocked, so waiting costs almost nothing.
spring.threads.virtual.enabled=trueWith this one property, on Java 21 or later, Tomcat and Jetty handle each request on a new virtual thread, the applicationTaskExecutor behind @Async starts a virtual thread per task, scheduled tasks run on virtual threads, and supported messaging listeners follow. Your blocking code stays as it is. There is no reactive rewrite.
What it does not fix
Virtual threads remove the thread limit, which moves the bottleneck somewhere else. The usual next limit is the database connection pool. Hikari's default of ten connections now has thousands of virtual threads queuing for it, and requests start failing with connection timeouts rather than waiting quietly in the server's accept queue. Downstream services see the same jump in concurrency. Put explicit limits where the scarce resource is: pool sizes, timeouts, and a concurrency limit around calls to fragile services.
Code that pins a carrier thread defeats the benefit. Since Java 24, synchronized no longer pins, so on Java 25 the remaining cases are mostly native calls and some older libraries. Thread-local caches sized for a fixed pool also behave differently when every task gets a fresh thread.
The application that exits at once
Virtual threads are always daemon threads. A web application is kept alive by its server, but a worker that only runs @Scheduled jobs or listens to a queue may find the JVM exits as soon as startup finishes, because no non-daemon thread is left. Set spring.main.keep-alive=true for those applications.
When to switch it on
It suits I/O-bound services with blocking clients: JDBC, RestClient, messaging. It does little for CPU-bound work, which is limited by cores whatever the thread type. Measure under realistic load before and after; the win shows up as higher throughput at the same latency once concurrency is above the old pool size.
Example
spring.threads.virtual.enabled=true
# The pool, not the thread count, is now the limit: size it on purpose.
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.connection-timeout=2s
# For a worker with no web server: stay up even though all threads are daemon threads.
spring.main.keep-alive=true
When to use it
- Raising throughput of a JDBC and RestClient based API without a reactive rewrite
- Running many slow @Async tasks without sizing a thread pool by hand
- Building a queue consumer or scheduler service with no web server that must stay running
Frequently asked questions
What does spring.threads.virtual.enabled change?
The embedded server handles requests on virtual threads, and the auto-configured task executor, task scheduler and supported messaging listeners use them too. Your own Executor beans are not changed.
Why does my application stop right after startup with virtual threads?
Virtual threads are daemon threads, so an application without a web server may have no non-daemon thread left. Set spring.main.keep-alive=true.
Do I still need a connection pool with virtual threads?
Yes. The database still accepts a limited number of connections, and the pool is now the main thing limiting concurrency. Size it deliberately and set a connection timeout.
More examples
Blocking calls on a virtual thread
With the property on, each request runs on its own virtual thread and the response reports virtualThread: true. The code is the same blocking style you would write without virtual threads, which is the whole point.
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
@RestController
public class ShippingQuoteController {
private final RestClient carrier;
ShippingQuoteController(RestClient.Builder builder) {
this.carrier = builder.baseUrl("https://carrier.example.com").build();
}
@GetMapping("/api/quotes/{postcode}")
Map<String, Object> quote(@PathVariable String postcode) {
// A plain blocking call: while it waits, the virtual thread releases its carrier.
String body = carrier.get()
.uri("/rates/{postcode}", postcode)
.retrieve()
.body(String.class);
return Map.of(
"postcode", postcode,
"rates", body,
"virtualThread", Thread.currentThread().isVirtual());
}
}
Configuration that depends on the threading model
@ConditionalOnThreading reads the same property, so the explicit limit only appears when virtual threads remove the implicit one. A semaphore is a good fit for virtual threads: blocking on acquire costs nothing.
import java.util.concurrent.Semaphore;
import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading;
import org.springframework.boot.thread.Threading;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
class CarrierGate {
private final Semaphore permits;
CarrierGate(int maxConcurrent) {
this.permits = new Semaphore(maxConcurrent);
}
<T> T call(java.util.function.Supplier<T> work) throws InterruptedException {
permits.acquire();
try {
return work.get();
} finally {
permits.release();
}
}
}
@Configuration
public class CarrierLimitsConfig {
// Platform threads were capped by the server pool; virtual threads are not.
@Bean
@ConditionalOnThreading(Threading.VIRTUAL)
CarrierGate virtualCarrierGate() {
return new CarrierGate(50);
}
@Bean
@ConditionalOnThreading(Threading.PLATFORM)
CarrierGate platformCarrierGate() {
return new CarrierGate(Integer.MAX_VALUE);
}
}
A scheduled worker that would otherwise exit
With spring.threads.virtual.enabled=true and no keep-alive, this application starts, schedules the job on a virtual thread, and exits because nothing non-daemon is left running. The property and setKeepAlive do the same thing; pick whichever keeps the intent visible.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
class OutboxRelay {
private static final Logger log = LoggerFactory.getLogger(OutboxRelay.class);
@Scheduled(fixedDelayString = "5s")
void relay() {
log.info("Relaying pending events on {}", Thread.currentThread());
}
}
@SpringBootApplication
@EnableScheduling
public class RelayApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(RelayApplication.class);
app.setWebApplicationType(WebApplicationType.NONE);
// Same effect as spring.main.keep-alive=true: virtual threads are daemon threads.
app.setKeepAlive(true);
app.run(args);
}
}
Discussion