Virtual Threads Are Production-Ready: A Practical Guide from JDK 21 to Spring Boot
Java Virtual Thread Practical Guide: From Thread API to Spring Boot High-Concurrency Applications
Introduction
Virtual Threads, also known as Virtual Thread, are a lightweight thread implementation brought by Project Loom.
Virtual threads went through two preview rounds in JDK 19 and JDK 20, and were officially released in JDK 21.
Simple understanding:
Platform Thread: Java threads are long-term bound to operating system threads
Virtual Thread: A large number of Java threads are scheduled by the JVM onto a small number of operating system threads
Traditional Java services often use a "one request, one thread" processing method.
The code is intuitive, but the number of platform threads is limited. When a large number of requests are waiting for databases, HTTP interfaces, files, or message queues, the threads themselves become the bottleneck first.
Virtual threads allow this synchronous writing style to regain higher concurrency capacity:
One task corresponds to one virtual thread
Temporarily releases the underlying platform thread when blocked
Continues executing the original code after I/O is ready
One sentence summary:
Virtual threads allow blocking Java code to handle a large number of concurrent I/O tasks with lower thread cost.
Version Requirements
| JDK Version | Virtual Thread Status |
|---|---|
| JDK 19 | First Preview |
| JDK 20 | Second Preview |
| JDK 21 | Official Feature |
| JDK 24+ | Significant improvement in thread pinning issues under synchronized scenarios |
Minimum runtime version:
JDK 21
JDK 21 can already use virtual threads in production projects.
If the project allows using a newer version, JDK 24 and above provide a more complete experience with virtual threads, mainly because JEP 491 improves the cooperation between synchronized and virtual threads.
Why Platform Threads Easily Become a Bottleneck
Traditional Thread creates platform threads by default.
Thread thread = new Thread(() -> {
System.out.println("Current thread: " + Thread.currentThread());
});
thread.start();
thread.join();
Platform threads and operating system threads have an approximately 1:1 relationship:
Java Platform Thread 1 -> OS Thread 1
Java Platform Thread 2 -> OS Thread 2
Java Platform Thread 3 -> OS Thread 3
Platform threads require operating system involvement for creation, scheduling, and context switching.
Therefore, platform threads are usually placed in a thread pool for reuse:
ExecutorService executor = Executors.newFixedThreadPool(200);
Thread pools can reduce the cost of repeatedly creating threads, but they do not increase the number of simultaneously available platform threads.
Assuming the thread pool has only 200 threads, and each task needs to wait for a remote interface for 1 second, then at most 200 tasks can be executed at the same time, and the remaining tasks must queue.
How Virtual Threads Work
Virtual threads are also instances of java.lang.Thread, but they do not permanently occupy a specific operating system thread throughout their lifecycle.
The general relationship is as follows:
Virtual Thread 1 --\
Virtual Thread 2 ---\
Virtual Thread 3 ----> Small number of platform threads -> Operating system threads
Virtual Thread 4 ---/
Virtual Thread 5 --/
The platform thread responsible for carrying the execution of virtual threads is also called a Carrier Thread.
When a virtual thread performs normal computation, it is mounted onto a carrier thread.
When a virtual thread performs perceivable blocking I/O, the JVM can suspend the virtual thread and hand over the carrier thread to other tasks for use.
Virtual thread initiates I/O
|
v
Virtual thread pauses and unmounts
|
v
Carrier thread executes other virtual threads
|
v
I/O completes
|
v
Virtual thread remounts and continues execution
This process is handled by the JVM, and the business code can still maintain a synchronous writing style.
Virtual Threads Improve Throughput
Virtual threads are not faster threads.
For example, if an interface call itself takes 500 milliseconds, switching to a virtual thread usually still takes close to 500 milliseconds.
The change is mainly reflected in the number of concurrent tasks:
Single task duration: usually not significantly reduced
Number of tasks processed simultaneously: can be significantly increased
Overall system throughput: may improve in I/O-intensive scenarios
Therefore, virtual threads are more suitable for:
- JDBC database access
- Synchronous HTTP calls
- RPC calls
- File and network I/O
- Message consumption
- Large numbers of concurrent requests
For CPU-intensive computing tasks that occupy the CPU for a long time, using virtual threads will not provide higher computing power once the number of threads exceeds the number of CPU cores.
First Virtual Thread Demo
The simplest way to create is Thread.startVirtualThread.
public class FirstVirtualThreadDemo {
public static void main(String[] args) throws InterruptedException {
Thread thread = Thread.startVirtualThread(() -> {
Thread current = Thread.currentThread();
System.out.println("Thread name: " + current.getName());
System.out.println("Is virtual thread: " + current.isVirtual());
System.out.println("Thread info: " + current);
});
thread.join();
}
}
Output similar to:
Thread name:
Is virtual thread: true
Thread info: VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
join() is used to wait for the virtual thread to finish execution.
Virtual threads are daemon threads. If the main thread ends first, the JVM will not continue running just to wait for virtual threads, so standalone demos usually need join() or executor shutdown waiting.
Using Thread.ofVirtual
Thread.ofVirtual() can set the thread name and choose to start immediately or later.
Start immediately:
Thread thread = Thread.ofVirtual()
.name("order-query")
.start(() -> {
System.out.println(Thread.currentThread());
});
thread.join();
Create first, then start:
Thread thread = Thread.ofVirtual()
.name("report-task")
.unstarted(() -> {
System.out.println("Generating report");
});
thread.start();
thread.join();
Using ThreadFactory
When unified thread names are needed, a virtual thread factory can be created.
import java.util.concurrent.ThreadFactory;
public class VirtualThreadFactoryDemo {
public static void main(String[] args) throws InterruptedException {
ThreadFactory factory = Thread.ofVirtual()
.name("worker-", 0)
.factory();
Thread first = factory.newThread(() -> printTask("Task A"));
Thread second = factory.newThread(() -> printTask("Task B"));
first.start();
second.start();
first.join();
second.join();
}
private static void printTask(String taskName) {
System.out.println(taskName + ", thread=" + Thread.currentThread().getName());
}
}
Output similar to:
Task A, thread=worker-0
Task B, thread=worker-1
One Virtual Thread Per Task
In business code, it's more common to use:
Executors.newVirtualThreadPerTaskExecutor()
It creates a new virtual thread for each submitted task.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualThreadExecutorDemo {
public static void main(String[] args) {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= 5; i++) {
int taskId = i;
executor.submit(() -> {
try {
Thread.sleep(500);
System.out.printf(
"Task %d completed, thread=%s, virtual=%s%n",
taskId,
Thread.currentThread().getName(),
Thread.currentThread().isVirtual()
);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
}
}
ExecutorService here is not a fixed-size thread pool in the traditional sense.
Its meaning is:
Submit a task
|
v
Create a virtual thread
|
v
Task ends
|
v
Virtual thread ends
Virtual threads have low creation cost and do not need to be pooled and reused like platform threads.
10,000 Blocking Tasks Demo
The following example executes 10,000 sleeping tasks simultaneously to observe how virtual threads handle a large number of blocking tasks.
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class ManyVirtualThreadsDemo {
public static void main(String[] args) {
int taskCount = 10_000;
Instant start = Instant.now();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, taskCount).forEach(taskId ->
executor.submit(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
})
);
}
long millis = Duration.between(start, Instant.now()).toMillis();
System.out.println("Task count: " + taskCount);
System.out.println("Total time: " + millis + " ms");
}
}
The tasks here consume almost no CPU; most of the time is spent waiting.
Therefore, 10,000 tasks can be in a waiting state simultaneously without creating 10,000 operating system threads.
This code is suitable for observing the mechanism, not as a rigorous performance benchmark. Formal stress testing also needs to consider JVM warm-up, connection pool size, downstream capacity, memory, and monitoring overhead.
Callable and Future
Virtual threads support Callable and Future just like a regular ExecutorService.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class VirtualThreadFutureDemo {
public static void main(String[] args) throws Exception {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> userFuture = executor.submit(() -> {
Thread.sleep(300);
return "User info";
});
Future<String> orderFuture = executor.submit(() -> {
Thread.sleep(500);
return "Order info";
});
String result = userFuture.get() + " + " + orderFuture.get();
System.out.println(result);
}
}
}
Two tasks can wait concurrently, and the code still maintains a top-down synchronous structure.
Practical: Concurrent Aggregation of Multiple HTTP Interfaces
Below uses the synchronous send method of Java HttpClient to simulate interface aggregation.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class HttpAggregationService {
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
public UserPage loadUserPage(long userId) throws Exception {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> userFuture = executor.submit(() ->
get("http://localhost:8081/users/" + userId)
);
Future<String> orderFuture = executor.submit(() ->
get("http://localhost:8082/orders?userId=" + userId)
);
Future<String> accountFuture = executor.submit(() ->
get("http://localhost:8083/accounts/" + userId)
);
return new UserPage(
userFuture.get(),
orderFuture.get(),
accountFuture.get()
);
}
}
private String get(String url) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(3))
.GET()
.build();
return httpClient.send(
request,
HttpResponse.BodyHandlers.ofString()
).body();
}
public record UserPage(
String user,
String orders,
String account
) {
}
}
The three remote calls have no dependencies on each other, so they can each be placed in a virtual thread for concurrent execution.
Exception and Interruption Handling
Virtual threads still follow Java's thread interruption rules.
After catching InterruptedException, a common practice is to restore the interrupt flag:
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
When using Future, tasks can be cancelled:
Future<String> future = executor.submit(() -> loadRemoteData());
if (requestCancelled) {
future.cancel(true);
}
cancel(true) will attempt to interrupt the thread running the task.
Business code needs to correctly respond to interruptions to stop tasks in a timely manner.
Limiting Downstream Concurrency
The number of virtual threads can be very large, but databases, remote services, connection pools, and file handles are still finite resources.
For example, if a third-party interface allows a maximum of 20 concurrent requests, you can use Semaphore for rate limiting.
import java.util.concurrent.Semaphore;
public class LimitedRemoteClient {
private final Semaphore semaphore = new Semaphore(20);
public String call(String request) throws InterruptedException {
semaphore.acquire();
try {
return callRemoteService(request);
} finally {
semaphore.release();
}
}
private String callRemoteService(String request) throws InterruptedException {
Thread.sleep(200);
return "result:" + request;
}
}
This limits the "number of tasks accessing the downstream simultaneously", not the total number of virtual threads.
Simple relationship:
Virtual threads are responsible for expressing tasks
Semaphore is responsible for limiting concurrency
Connection pool is responsible for limiting database connections
Virtual Threads and Database Connection Pools
After enabling virtual threads, database connection pools are still necessary.
For example:
5000 virtual threads query the database simultaneously
HikariCP maximum connection count is 30
At the same time, only 30 tasks can obtain a database connection; the remaining tasks will wait for a connection.
Virtual threads reduce the cost of "waiting threads", but they cannot magically increase the database's processing capacity.
Connection pool size still needs to be set considering the following factors:
- Database maximum connections
- SQL execution time
- Database CPU and disk capacity
- Number of application instances
- Transaction hold time on connections
Virtual Threads and ThreadLocal
Virtual threads support ThreadLocal.
Common usages like request context, transaction information, and tracing IDs can continue to work.
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
public void handle(String traceId) {
TRACE_ID.set(traceId);
try {
process();
} finally {
TRACE_ID.remove();
}
}
Another type of usage needs attention: caching expensive objects in ThreadLocal, hoping to reuse them across multiple tasks.
Virtual threads are typically used once per task. If each virtual thread creates a large object, memory consumption will grow with the number of concurrent tasks.
Therefore, what is suitable for ThreadLocal is usually lightweight context, not connections, clients, or large cache objects.
Virtual Threads and synchronized
This part needs to be distinguished by JDK version.
JDK 21 to JDK 23
When a virtual thread blocks inside a synchronized block or synchronized method, it may be pinned to its carrier thread.
public synchronized String loadData() throws InterruptedException {
Thread.sleep(1000);
return "data";
}
If a large number of virtual threads block in this way for a long time, the number of available carrier threads decreases, and throughput may drop.
JDK 24 and Above
JEP 491 improves the JVM's monitor implementation.
Virtual threads can also release their carrier thread when in synchronized methods, synchronized blocks, or waiting on monitors. The pinning issue caused by synchronized is largely eliminated.
Therefore, in JDK 24 and above, there is no need to change all synchronized to ReentrantLock just for virtual threads.
The choice of lock can return to code semantics:
synchronized: simple syntax, suitable for ordinary mutual exclusion
ReentrantLock: suitable for advanced needs like timeout, interruptibility, fair locks, multiple Conditions
Even with newer JDKs, it's still good practice to keep lock scopes small; holding a lock for a long time while performing I/O can cause contention at the business level.
Scenarios Where Pinning Can Still Occur
After JDK 24, there are still a few pinning scenarios, mainly related to native code, Foreign Function calls, and some internal JVM processes.
Such issues usually need to be located using JFR and thread dumps.
Virtual Threads and CPU-Intensive Tasks
The following task continuously occupies the CPU:
public long calculate() {
long result = 0;
for (long i = 0; i < 5_000_000_000L; i++) {
result += i;
}
return result;
}
This type of task has little waiting time.
If you create 100,000 virtual threads simultaneously to perform calculations, the number of CPU cores does not increase, but it brings more scheduling and contention.
CPU-intensive tasks are usually still suitable for using a bounded platform thread pool:
int processors = Runtime.getRuntime().availableProcessors();
ExecutorService cpuExecutor = Executors.newFixedThreadPool(processors);
It can be distinguished with one sentence:
A lot of time waiting for I/O: consider virtual threads
A lot of time performing calculations: consider a bounded platform thread pool
Enabling Virtual Threads in Spring Boot
Newer Spring Boot projects can enable virtual threads through configuration:
spring:
threads:
virtual:
enabled: true
Properties format:
spring.threads.virtual.enabled=true
Virtual threads require Java 21 or higher.
After enabling, Spring Boot will use virtual threads in supported auto-configured locations, such as task execution, Spring MVC asynchronous processing, and web container request handling.
If the project has custom Executor, AsyncConfigurer, or applicationTaskExecutor beans, you need to confirm whether the custom bean overrides the auto-configuration.
Spring Boot Web Demo
Maven dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
Configuration:
spring:
threads:
virtual:
enabled: true
datasource:
url: jdbc:mysql://localhost:3306/virtual_thread_demo
username: root
password: 123456
hikari:
maximum-pool-size: 20
server:
port: 8080
Prepare the data table:
CREATE DATABASE virtual_thread_demo DEFAULT CHARACTER SET utf8mb4;
USE virtual_thread_demo;
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL
);
INSERT INTO users (username, email, status) VALUES
('Zhang San', '[email protected]', 'ACTIVE'),
('Li Si', '[email protected]', 'ACTIVE');
Entity:
public record User(
Long id,
String username,
String email,
String status
) {
}
Repository:
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public class UserRepository {
private final JdbcTemplate jdbcTemplate;
public UserRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public Optional<User> findById(Long id) {
String sql = """
select id, username, email, status
from users
where id = ?
""";
return jdbcTemplate.query(sql, (rs, rowNum) -> new User(
rs.getLong("id"),
rs.getString("username"),
rs.getString("email"),
rs.getString("status")
), id).stream().findFirst();
}
}
Service:
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("User not found, id=" + id));
}
}
Controller:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public Map<String, Object> findById(@PathVariable Long id) {
Thread thread = Thread.currentThread();
User user = userService.findById(id);
return Map.of(
"thread", thread.toString(),
"virtual", thread.isVirtual(),
"user", user
);
}
}
Access:
GET http://localhost:8080/api/users/1
Response example:
{
"thread": "VirtualThread[#42,tomcat-handler-0]/runnable",
"virtual": true,
"user": {
"id": 1,
"username": "Zhang San",
"email": "[email protected]",
"status": "ACTIVE"
}
}
This writing style is still synchronous blocking:
Controller
|
v
Service
|
v
JdbcTemplate
|
v
Database
The difference is that each request can be handled by an independent virtual thread, and waiting for the JDBC response does not long-term occupy scarce platform threads.
Spring @Async Demo
Enable asynchronous support:
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
public class AsyncConfig {
}
Asynchronous task:
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;
@Service
public class ReportService {
@Async
public CompletableFuture<String> generate(Long reportId) {
try {
Thread.sleep(1000);
String result = "Report generation complete, id=" + reportId
+ ", virtual=" + Thread.currentThread().isVirtual();
return CompletableFuture.completedFuture(result);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return CompletableFuture.failedFuture(e);
}
}
}
Without a custom executor overriding the auto-configuration, after enabling spring.threads.virtual.enabled=true, Spring Boot's auto-configured asynchronous task executor will use virtual threads.
Difference Between Virtual Threads and WebFlux
Both virtual threads and WebFlux can improve the concurrency capacity of I/O-intensive services, but the programming models differ.
| Comparison Item | Virtual Threads | Spring WebFlux |
|---|---|---|
| Programming Style | Synchronous Blocking | Reactive Non-blocking |
| Code Structure | Regular methods, loops, try/catch |
Mono, Flux, operator chains |
| JDBC / JPA | Can be used directly | Blocks the event loop, requires isolation or use of R2DBC |
| Debugging Method | Regular thread stack | Reactive call chain |
| Common Scenarios | Traditional MVC, synchronous SDKs, JDBC | Streaming responses, gateways, end-to-end reactive systems |
The two are not simple replacements.
For regular Spring MVC, JDBC, JPA, and synchronous HTTP client projects, virtual threads are usually easier to adopt.
For SSE, backpressure, continuous data streams, and reactive data sources, WebFlux still has clear value.
Why Thread Pool Parameters May Become Invalid
After enabling Spring Boot virtual threads, parameters previously used to configure the platform thread pool size may no longer take effect.
The reason is that virtual threads are not repeatedly reused in the application's own fixed thread pool, but are run by the JVM's global virtual thread scheduler.
For example, the following types of parameters need re-evaluation:
Core thread count
Maximum thread count
Thread idle time
Task queue capacity
In the virtual thread scenario, more attention should be paid to:
- Database connection pool
- HTTP connection pool
- Downstream concurrency limits
- Request timeouts
- Memory usage
- CPU usage
- Task backlog and failure rate
Monitoring and Troubleshooting
The number of virtual threads can be very large, and the traditional jstack displaying all threads in a flat list is not convenient.
You can use jcmd to generate new thread dumps.
Text format:
jcmd <PID> Thread.dump_to_file -format=text thread-dump.txt
JSON format:
jcmd <PID> Thread.dump_to_file -format=json thread-dump.json
JDK Flight Recorder can also record virtual thread related events.
Record at startup:
java -XX:StartFlightRecording=filename=virtual-thread.jfr,duration=60s -jar app.jar
For JDK 21 to JDK 23, you can also use the following parameter to troubleshoot pinning caused by synchronized:
java -Djdk.tracePinnedThreads=full -jar app.jar
JDK 24 has removed the practical effect of this diagnostic parameter through JEP 491, because synchronized is no longer the main source of pinning. Newer versions are more suitable for using JFR to observe remaining pinning scenarios.
Common Usage Recommendations
One Task Corresponds to One Virtual Thread
Virtual threads are very lightweight; there is no need to create a fixed-size virtual thread pool.
Recommended entry point:
Executors.newVirtualThreadPerTaskExecutor()
Use Dedicated Mechanisms to Limit Resource Concurrency
Use Semaphore to limit concurrent access to external interfaces, and use database connection pools to limit database concurrency.
Virtual threads are responsible for running tasks; resource components are responsible for controlling capacity.
Set Timeouts for I/O
Virtual threads reduce the cost of waiting, but infinite waiting still accumulates tasks and memory.
Databases, HTTP, RPC, and message processing all benefit from setting reasonable timeouts.
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(3))
.GET()
.build();
Preserve Interrupt Status
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Interruption is an important coordination mechanism for task cancellation, application shutdown, and timeout control.
Stress Testing Needs to Cover Real Downstream
Using only Thread.sleep can demonstrate that virtual threads can handle a large number of waiting tasks, but it does not represent real business performance.
Formal verification should at least include:
- Database connection pool capacity
- SQL and index performance
- HTTP connection pool configuration
- Downstream rate limiting strategy
- JVM heap memory
- P95, P99 latency
- Error rate and timeout rate
Common API Summary
| API | Purpose |
|---|---|
Thread.startVirtualThread(task) |
Create and immediately start a virtual thread |
Thread.ofVirtual() |
Create a virtual thread builder |
Thread.Builder.start(task) |
Create and start a thread |
Thread.Builder.unstarted(task) |
Create a thread that has not yet started |
Thread.Builder.factory() |
Create a thread factory |
Thread.isVirtual() |
Check if it is a virtual thread |
Executors.newVirtualThreadPerTaskExecutor() |
Create one virtual thread per task |
Thread.join() |
Wait for a thread to finish |
Thread.interrupt() |
Request to interrupt a thread |
Future.cancel(true) |
Cancel a task and attempt to interrupt the thread |
Semaphore |
Limit concurrent access to a resource |
Summary
Virtual threads have not changed the basic writing style of Java code.
Ordinary synchronous code can still use:
Method calls
Loops
try/catch
JDBC
Synchronous HTTP clients
ThreadLocal
The change occurs at the thread implementation level:
The number of platform threads is limited and requires pooling for reuse
Virtual threads have lower cost; one task can correspond to one thread
When blocking on I/O, virtual threads can release the underlying carrier thread
Virtual threads are suitable for applications with a large number of concurrent, I/O-wait-intensive tasks.
CPU-intensive tasks, database capacity, connection pool size, and downstream rate limiting issues will not automatically disappear with virtual threads.
A more accurate way to use them is: use virtual threads to carry a large number of tasks, and then use connection pools, semaphores, timeouts, and monitoring to control finite resources.