一、引言
"把线程池换成虚拟线程后,我们的 API 延迟降低了 85%,吞吐量提升了 4 倍。"
这不是空谈——这是我们在生产环境中真实的测试结果。
但故事并没有到此结束。当我们沉浸在性能提升的喜悦中时,数据库连接池突然成为了新的瓶颈。虚拟线程的高效率让连接池的消耗速度超出预期,大量请求因为获取不到数据库连接而超时。
今天,我们就来完整复盘这次迁移实战——从实验设计到结果分析,再到瓶颈解决。
二、对照实验设计
2.1 实验环境
| 项目 | 配置 |
|---|---|
| CPU | Intel i9-13900K(24 核) |
| 内存 | 32GB DDR5 |
| JDK | OpenJDK 21.0.2(Temurin) |
| Spring Boot | 3.2.5 |
| 数据库 | MySQL 8.0.33(本地 Docker) |
| 连接池 | HikariCP 5.0.1 |
| 压测工具 | JMeter 5.6.2 |
2.2 测试场景
模拟一个典型的电商订单查询接口:
- 每个请求需要执行一次数据库查询(模拟 100ms 延迟)
- 并发数:10,000
- 持续时间:60 秒
- 数据库连接池大小:20
2.3 两组对比方案
方案 A:传统线程池
// 核心线程数:200,最大线程数:200,队列:1000
ExecutorService executor = Executors.newFixedThreadPool(200);
方案 B:Virtual Threads
// 每个任务创建一个虚拟线程
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
三、实验结果对比
3.1 性能数据对比
┌─────────────────────────────────────────────────────────────────────────┐
│ │
│ 实验结果对比表 │
│ │
│ ┌──────────────┬──────────────────────┬──────────────────────┐ │
│ │ 指标 │ 方案 A:传统线程池 │ 方案 B:虚拟线程 │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ 并发数 │ 10,000 │ 10,000 │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ 成功请求数 │ 7,700 │ 10,000 │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ 拒绝请求数 │ 2,300 │ 0 │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ P50 延迟 │ 2.1s │ 150ms │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ P90 延迟 │ 8.3s │ 520ms │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ P99 延迟 │ 12.0s │ 1.8s │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ 吞吐量 │ 128 req/s │ 538 req/s │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ CPU 使用率 │ 92% │ 45% │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ OS 线程数 │ 215 │ 12 │ │
│ └──────────────┴──────────────────────┴──────────────────────┘ │
│ │
│ 吞吐量提升:538 / 128 = 4.2 倍 │
│ P99 延迟降低:(12.0 - 1.8) / 12.0 = 85% │
│ OS 线程数减少:(215 - 12) / 215 = 94% │
│ │
└─────────────────────────────────────────────────────────────────────────┘
3.2 关键发现
发现一:虚拟线程的吞吐量优势
传统线程池:队列满(1000)→ 拒绝新请求(2300)
虚拟线程:无队列限制 → 零拒绝 → 全部处理完毕
发现二:延迟分布的巨大差异
传统线程池:
P50: 2.1s → 一半请求等待超过 2 秒
P99: 12s → 1% 的请求等待超过 12 秒(超时!)
虚拟线程:
P50: 150ms → 一半请求在 150ms 内完成
P99: 1.8s → 最差请求也在 2 秒内完成
发现三:资源占用的大幅降低
CPU 使用率:92% → 45%(降低 51%)
OS 线程数:215 → 12(降低 94%)
内存占用:2.1GB → 1.2GB(降低 43%)
四、⚠️ 新瓶颈:数据库连接池
4.1 问题现象
在虚拟线程方案中,虽然请求不再被拒绝,但我们发现:
1. 大量请求卡在 "获取数据库连接" 阶段
2. HikariCP 连接池的 waiting threads 持续增长
3. 数据库连接池的 maximumPoolSize=20 成为新瓶颈
4. 请求延迟虽然比传统方案低,但远未达到预期
4.2 根因分析
传统线程池 vs 虚拟线程的连接池行为差异:
传统线程池(200 线程):
├── 最多 200 个线程同时运行
├── 但数据库连接只有 20 个
├── 180 个线程在等待连接(线程阻塞)
├── 线程阻塞期间占用 OS 线程资源
└── 资源浪费严重,但不会有更多线程进入
虚拟线程(无限制):
├── 可以创建数万甚至数百万个虚拟线程
├── 数据库连接只有 20 个
├── 数万虚拟线程在等待连接(虚拟线程挂起)
├── 虚拟线程挂起不占用 OS 线程(资源占用低)
└── 但连接池成为瓶颈,请求堆积
核心问题:虚拟线程的高效让连接池的限制暴露无遗。
4.3 线程 Dump 证据
jstack <pid> | grep -A 5 "waiting on"
输出:
"vthread-1001" #1001 prio=5 os_prio=0 cpu=0.00ms elapsed=5.23s
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:341)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:169)
- waiting on <0x0000000085a00000> (a com.zaxxer.hikari.pool.HikariPool)
"vthread-1002" #1002 prio=5 os_prio=0 cpu=0.00ms elapsed=5.18s
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:341)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:169)
- waiting on <0x0000000085a00000> (a com.zaxxer.hikari.pool.HikariPool)
# ... 重复数百次 ...
五、解决方案:连接池信号量限流
5.1 原理分析
问题本质:连接池是有限资源,但虚拟线程没有上限。
解决方案:在连接池之上再加一层信号量(Semaphore),控制并发访问数据库的虚拟线程数量。
┌─────────────────────────────────────────────────────────────┐
│ │
│ 请求 → 虚拟线程 → Semaphore(限制并发数)→ 连接池 → 数据库 │
│ │
│ Semaphore 作用: │
│ ├── 限制同时访问数据库的虚拟线程数量 │
│ ├── 防止连接池成为瓶颈 │
│ ├── 让多余的请求在 Semaphore 层面等待 │
│ └── 可以设置比连接池更大的值(如连接池 20,Semaphore 50) │
│ │
└─────────────────────────────────────────────────────────────┘
5.2 代码实现
方案一:手动 Semaphore 控制
@Component
public class DatabaseAccessService {
private static final int MAX_CONCURRENT_DB_ACCESS = 50;
private final Semaphore semaphore = new Semaphore(MAX_CONCURRENT_DB_ACCESS);
@Autowired
private JdbcTemplate jdbcTemplate;
public <T> T executeWithLimit(Supplier<T> task) {
try {
semaphore.acquire();
try {
return task.get();
} finally {
semaphore.release();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Database access interrupted", e);
}
}
}
方案二:AOP 切面自动控制
@Aspect
@Component
public class DatabaseAccessAspect {
private static final int MAX_CONCURRENT_DB_ACCESS = 50;
private final Semaphore semaphore = new Semaphore(MAX_CONCURRENT_DB_ACCESS);
@Around("@annotation(com.example.demo.annotation.LimitedDatabaseAccess)")
public Object limitDatabaseAccess(ProceedingJoinPoint joinPoint) throws Throwable {
semaphore.acquire();
try {
return joinPoint.proceed();
} finally {
semaphore.release();
}
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LimitedDatabaseAccess {
}
方案三:HikariCP 配置优化
spring:
datasource:
hikari:
maximum-pool-size: 50 # 增加连接池大小
minimum-idle: 20 # 最小空闲连接
idle-timeout: 300000 # 空闲超时时间
connection-timeout: 10000 # 获取连接超时时间
max-lifetime: 1800000 # 连接最大生命周期
leak-detection-threshold: 60000 # 泄漏检测阈值
5.3 优化后的测试结果
┌─────────────────────────────────────────────────────────────────────────┐
│ │
│ 优化后的实验结果对比表 │
│ │
│ ┌──────────────┬──────────────────────┬──────────────────────┐ │
│ │ 指标 │ 虚拟线程(优化前) │ 虚拟线程(优化后) │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ 并发数 │ 10,000 │ 10,000 │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ P50 延迟 │ 150ms │ 80ms │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ P90 延迟 │ 520ms │ 180ms │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ P99 延迟 │ 1.8s │ 350ms │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ 吞吐量 │ 538 req/s │ 820 req/s │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ 等待连接数 │ 数百个 │ < 10 个 │ │
│ └──────────────┴──────────────────────┴──────────────────────┘ │
│ │
│ P99 延迟进一步降低:(1.8 - 0.35) / 1.8 = 80.6% │
│ 吞吐量进一步提升:820 / 538 = 1.52 倍 │
│ │
└─────────────────────────────────────────────────────────────────────────┘
六、Spring Boot 3.2 虚拟线程配置
6.1 最简单的启用方式
# application.yml
spring:
threads:
virtual:
enabled: true # 一行配置启用虚拟线程
作用:
- Web 请求处理使用虚拟线程
@Async方法默认使用虚拟线程TaskExecutor默认使用虚拟线程
6.2 完整配置示例
server:
port: 8080
shutdown: graceful
spring:
threads:
virtual:
enabled: true
# 虚拟线程工厂配置(可选)
factory:
name-prefix: vthread-
daemon: true
datasource:
url: jdbc:mysql://localhost:3306/example_db
username: admin
password: password
hikari:
maximum-pool-size: 50
connection-timeout: 10000
leak-detection-threshold: 60000
management:
endpoints:
web:
exposure:
include: health,metrics
metrics:
tags:
application: virtual-thread-demo
6.3 自定义虚拟线程 Executor
@Configuration
public class VirtualThreadConfig {
@Bean
public ExecutorService virtualThreadExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
@Bean(name = "limitedDatabaseExecutor")
public ExecutorService limitedDatabaseExecutor() {
return Executors.newThreadPoolExecutor(
0,
Integer.MAX_VALUE,
60L,
TimeUnit.SECONDS,
new SynchronousQueue<>(),
Thread.ofVirtual().name("db-vthread-", 0).factory()
);
}
}
七、完整代码示例
7.1 基准测试控制器
@RestController
@RequestMapping("/api")
public class BenchmarkController {
@Autowired
private OrderService orderService;
@GetMapping("/order/{id}")
public ResponseEntity<OrderDTO> getOrder(@PathVariable Long id) {
OrderDTO order = orderService.getOrderById(id);
return ResponseEntity.ok(order);
}
@GetMapping("/orders")
public ResponseEntity<List<OrderDTO>> getOrders(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
List<OrderDTO> orders = orderService.getOrders(page, size);
return ResponseEntity.ok(orders);
}
}
7.2 订单服务(带信号量控制)
@Service
public class OrderService {
private static final int MAX_CONCURRENT_DB_ACCESS = 50;
private final Semaphore dbSemaphore = new Semaphore(MAX_CONCURRENT_DB_ACCESS);
@Autowired
private OrderRepository orderRepository;
@Autowired
private DatabaseAccessService dbAccessService;
public OrderDTO getOrderById(Long id) {
return dbAccessService.executeWithLimit(() -> {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Order not found"));
return convertToDTO(order);
});
}
public List<OrderDTO> getOrders(int page, int size) {
return dbAccessService.executeWithLimit(() -> {
Pageable pageable = PageRequest.of(page - 1, size);
Page<Order> orders = orderRepository.findAll(pageable);
return orders.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
});
}
private OrderDTO convertToDTO(Order order) {
OrderDTO dto = new OrderDTO();
dto.setId(order.getId());
dto.setOrderNo(order.getOrderNo());
dto.setAmount(order.getAmount());
dto.setStatus(order.getStatus());
dto.setCreateTime(order.getCreateTime());
return dto;
}
}
7.3 数据库访问服务
@Service
public class DatabaseAccessService {
private static final int MAX_CONCURRENT_DB_ACCESS = 50;
private final Semaphore semaphore = new Semaphore(MAX_CONCURRENT_DB_ACCESS);
public <T> T executeWithLimit(Supplier<T> task) {
try {
semaphore.acquire();
try {
return task.get();
} finally {
semaphore.release();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Database access interrupted", e);
}
}
public void executeWithLimit(Runnable task) {
try {
semaphore.acquire();
try {
task.run();
} finally {
semaphore.release();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Database access interrupted", e);
}
}
public int getAvailablePermits() {
return semaphore.availablePermits();
}
public int getQueueLength() {
return semaphore.getQueueLength();
}
}
八、K8s 部署配置
8.1 Deployment 配置
apiVersion: apps/v1
kind: Deployment
metadata:
name: virtual-thread-demo
labels:
app: virtual-thread-demo
spec:
replicas: 3
selector:
matchLabels:
app: virtual-thread-demo
template:
metadata:
labels:
app: virtual-thread-demo
spec:
terminationGracePeriodSeconds: 45
containers:
- name: virtual-thread-demo
image: virtual-thread-demo:latest
ports:
- containerPort: 8080
name: http
resources:
limits:
memory: "1Gi"
cpu: "1000m"
requests:
memory: "512Mi"
cpu: "500m"
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["/bin/sleep", "10"]
env:
- name: JAVA_OPTS
value: "-XX:+UseZGC -Xmx1g -Xms512m"
8.2 Service 配置
apiVersion: v1
kind: Service
metadata:
name: virtual-thread-demo-service
spec:
selector:
app: virtual-thread-demo
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: ClusterIP
九、基准测试脚本
9.1 JMeter 测试计划(benchmark.jmx)
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.2">
<hashTree>
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="Virtual Thread Benchmark">
<stringProp name="TestPlan.comments"></stringProp>
<boolProp name="TestPlan.functional_mode">false</boolProp>
<boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
<elementProp name="TestPlan.user_defined_variables" elementType="Arguments">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="TestPlan.user_define_classpath"></stringProp>
</TestPlan>
<hashTree>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="Benchmark Thread Group">
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
<elementProp name="ThreadGroup.main_controller" elementType="LoopController">
<boolProp name="LoopController.continue_forever">false</boolProp>
<stringProp name="LoopController.loops">10</stringProp>
</elementProp>
<stringProp name="ThreadGroup.num_threads">1000</stringProp>
<stringProp name="ThreadGroup.ramp_time">10</stringProp>
<boolProp name="ThreadGroup.scheduler">false</boolProp>
<stringProp name="ThreadGroup.duration"></stringProp>
<stringProp name="ThreadGroup.delay"></stringProp>
<boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
</ThreadGroup>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Get Order">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">8080</stringProp>
<stringProp name="HTTPSampler.protocol">http</stringProp>
<stringProp name="HTTPSampler.contentEncoding"></stringProp>
<stringProp name="HTTPSampler.path">/api/order/${__Random(1,10000)}</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout">5000</stringProp>
<stringProp name="HTTPSampler.response_timeout">30000</stringProp>
</HTTPSamplerProxy>
<hashTree/>
</hashTree>
</hashTree>
</hashTree>
</jmeterTestPlan>
9.2 运行测试
# 运行 JMeter 测试
jmeter -n -t benchmark.jmx -l results.jtl -e -o report/
# 使用 wrk 进行压测
wrk -t10 -c1000 -d60s http://localhost:8080/api/order/1
十、生产环境注意事项
10.1 监控指标
| 指标 | 监控工具 | 告警阈值 |
|---|---|---|
| 虚拟线程数量 | Micrometer + Prometheus | > 10000 |
| 信号量等待队列 | Micrometer + Prometheus | > 100 |
| 数据库连接池使用率 | HikariCP metrics | > 90% |
| 连接池获取延迟 | HikariCP metrics | > 100ms |
| CPU 使用率 | Prometheus | > 80% |
10.2 配置建议
// 虚拟线程配置建议
// 1. 启用虚拟线程
spring.threads.virtual.enabled=true
// 2. 调整连接池大小(根据实际负载)
spring.datasource.hikari.maximum-pool-size=50
// 3. 添加信号量限流(推荐)
// Semaphore 大小 = 连接池大小 * 2~3
// 4. 使用 ZGC(低延迟垃圾回收器)
// JVM 参数:-XX:+UseZGC
// 5. 监控连接泄漏
// HikariCP leak-detection-threshold=60000
10.3 常见坑点
┌─────────────────────────────────────────────────────────────┐
│ │
│ 虚拟线程迁移常见坑点 │
│ │
│ ❌ 坑一:直接替换线程池,不调整连接池 │
│ 后果:连接池成为瓶颈,延迟反而上升 │
│ 解决:增加连接池大小 + 信号量限流 │
│ │
│ ❌ 坑二:使用 synchronized 关键字 │
│ 后果:虚拟线程被 Pin 到 Carrier Thread,失去优势 │
│ 解决:替换为 ReentrantLock │
│ │
│ ❌ 坑三:使用 ThreadLocal 存储大量数据 │
│ 后果:虚拟线程数量多,内存占用激增 │
│ 解决:尽量不使用 ThreadLocal,或使用弱引用 │
│ │
│ ❌ 坑四:不调整 GC 算法 │
│ 后果:大量虚拟线程创建/销毁导致 GC 压力大 │
│ 解决:使用 ZGC 或 ShenandoahGC │
│ │
│ ❌ 坑五:忽略日志框架的线程模型 │
│ 后果:日志框架使用传统线程,成为瓶颈 │
│ 解决:使用支持虚拟线程的日志框架版本 │
│ │
└─────────────────────────────────────────────────────────────┘
十一、总结
11.1 性能提升总结
┌─────────────────────────────────────────────────────────────────┐
│ │
│ 性能提升总结 │
│ │
│ 传统线程池 → 虚拟线程(优化前)→ 虚拟线程(优化后) │
│ ↓ ↓ ↓ │
│ 128 req/s 538 req/s 820 req/s │
│ ↓ ↓ ↓ │
│ P99: 12s P99: 1.8s P99: 0.35s │
│ │
│ 最终结果: │
│ ├── 吞吐量提升:820 / 128 = 6.4 倍 │
│ ├── P99 延迟降低:(12 - 0.35) / 12 = 97.1% │
│ ├── OS 线程数减少:94% │
│ └── CPU 使用率降低:51% │
│ │
└─────────────────────────────────────────────────────────────────┘
11.2 迁移步骤总结
迁移步骤:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ 1. 升级到 Java 21 + Spring Boot 3.2 │
│ ↓ │
│ 2. 启用虚拟线程(spring.threads.virtual.enabled=true) │
│ ↓ │
│ 3. 替换 synchronized 为 ReentrantLock(如果有) │
│ ↓ │
│ 4. 增加数据库连接池大小(根据负载调整) │
│ ↓ │
│ 5. 添加信号量限流(Semaphore) │
│ ↓ │
│ 6. 切换到 ZGC 垃圾回收器 │
│ ↓ │
│ 7. 运行基准测试验证 │
│ ↓ │
│ 8. 灰度发布 │
│ │
└─────────────────────────────────────────────────────────────────┘
11.3 适用场景与不适用场景
| 场景 | 适用虚拟线程 | 原因 |
|---|---|---|
| HTTP 服务 | ✅ | 大量 IO 等待 |
| 消息队列消费 | ✅ | 大量等待消息 |
| 数据库查询 | ✅(需配置) | 大量等待响应 |
| CPU 密集计算 | ❌ | 无法利用虚拟线程优势 |
| 频繁 synchronized | ⚠️ | 需要改造 |
| 大量 ThreadLocal | ⚠️ | 内存占用问题 |
💡 互动话题:你在虚拟线程迁移中遇到过哪些坑?连接池瓶颈是怎么解决的?欢迎在评论区分享你的经验!
