系列导航 | 上一篇:Valhalla值类型实战 | 本篇:虚拟线程秒杀 | 下一篇:结构化并发实战
一、传统线程池的极限在哪里?
电商秒杀是一个经典的并发难题。假设一次活动,50万用户在10秒内涌入抢购1000件商品,峰值QPS = 5万。
传统方案用 ThreadPoolExecutor:
java
// 经典线程池配置 --- 很多项目的标准写法
@Bean
public Executor seckillExecutor() {
return new ThreadPoolExecutor(
200, // 核心线程数
500, // 最大线程数
60, TimeUnit.SECONDS, // 空闲回收
new LinkedBlockingQueue<>(10000), // 任务队列
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
}
问题在哪?
| 指标 | 数值 | 说明 |
|---|---|---|
| 核心线程 | 200 | 每个线程栈空间默认1MB,200线程 = 200MB |
| 最大线程 | 500 | 队列满了才扩容,扩容速度跟不上突发流量 |
| 队列容量 | 10000 | 超过就直接走拒绝策略 |
| 理论峰值吞吐 | ~2000 QPS | 每个请求耗时100ms时,200线程的极限 |
| 实际QPS缺口 | 48000 | 离5万QPS差24倍 |
想提吞吐?加线程:
java
new ThreadPoolExecutor(10000, 10000, ...) // 1万个平台线程
结果:
- 栈内存:1万个线程 × 1MB = 10GB
- 上下文切换:1万个线程的CPU调度开销会让有效计算时间降到60%以下
- GC压力:大量线程局部变量堆积,Young GC频率飙升
平台线程的根因是"一个线程绑定一个OS线程"。操作系统调度线程的开销是恒定的,不会因为你的业务逻辑很轻就变少。
虚拟线程要解决的就是这个问题:让一个OS线程承载成千上万个虚拟线程,调度权交给JVM。
二、虚拟线程2.0 核心机制
2.1 从 Loom 到 JDK 25:虚拟线程经历了什么
| 版本 | 变化 |
|---|---|
| JDK 21 | 虚拟线程首次Preview,Thread.ofVirtual() 可用 |
| JDK 23 | 正式转正,Executors.newVirtualThreadPerTaskExecutor() 成为推荐用法 |
| JDK 24 | 载体线程池自动调优,支持 Thread.ofVirtual().name("seckill-", 0) 批量命名 |
| JDK 25 | TLB优化(减少缓存抖动)+ 结构化并发深度整合 + Pinning大幅减少 |
JDK 25的虚拟线程有两个关键改进:
1. 减少 Pinning(载体线程钉住)
早期版本中,如果在虚拟线程中执行 synchronized 块或调用native方法,该虚拟线程会"钉住"载体线程,导致载体线程无法服务于其他虚拟线程。JDK 25大幅减少了这种情况:
java
// JDK 21-23 中,synchronized 会导致 pinning
synchronized (lock) {
blockingOperation(); // ⚠️ 载体线程被钉住
}
// JDK 25 中已优化 --- synchronized 的 pinning 概率降低 90%+
// 但仍建议在关键路径上用 ReentrantLock 替代
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
blockingOperation(); // ✅ 不会pinning,虚拟线程可以挂起
} finally {
lock.unlock();
}
2. 线程本地存储改进
java
// JDK 25 优化了虚拟线程的 ThreadLocal 访问性能
// 之前:每个虚拟线程独立的 ThreadLocal Map,创建和访问有开销
// 现在:虚拟线程共享载体线程的缓存,惰性分配
private static final ThreadLocal<SeckillContext> CTX =
ThreadLocal.withInitial(SeckillContext::new);
2.2 虚拟线程的成本模型
java
// 创建100万个虚拟线程
var start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
// 模拟轻量IO操作
LockSupport.parkNanos(Duration.ofMillis(1));
return null;
});
}
}
var elapsed = System.currentTimeMillis() - start;
// 实测:约3.2秒完成100万任务,峰值内存占用 < 256MB
// 对比:创建100万个平台线程?
// 不可能------每个线程1MB栈空间,100万个 = 1TB内存
虚拟线程的本质:它是一段轻量级的"执行上下文",包含栈帧、局部变量和调度状态。不执行IO时,栈帧被"卸载"到堆上,不占用载体线程资源。
虚拟线程模型 (JDK 25)
OS Thread 1
虚拟线程1
虚拟线程2
虚拟线程3
虚拟线程N...
OS Thread 2
虚拟线程N+1
虚拟线程N+2
虚拟线程2N...
平台线程模型
OS Thread 1
业务线程1
OS Thread 2
业务线程2
OS Thread 3
业务线程3
OS Thread N...
业务线程N...
三、实战:秒杀服务重构
3.1 业务模型
java
/**
* 秒杀请求
*/
public value record SeckillRequest(
long userId,
long itemId,
int quantity,
String token // 防刷令牌
) {}
/**
* 秒杀结果
*/
public value record SeckillResult(
boolean success,
String orderId,
String message,
long costMs
) {}
3.2 传统方案:线程池 + 限流
java
@Service
public class SeckillServiceLegacy {
private final Executor executor;
private final Semaphore semaphore = new Semaphore(500); // 硬限流500并发
public SeckillServiceLegacy(@Qualifier("seckillExecutor") Executor executor) {
this.executor = executor;
}
public CompletableFuture<SeckillResult> seckill(SeckillRequest req) {
return CompletableFuture.supplyAsync(() -> {
if (!semaphore.tryAcquire()) {
return new SeckillResult(false, null, "系统繁忙,请重试", 0);
}
try {
long start = System.currentTimeMillis();
// 1. 校验令牌
if (!tokenService.verify(req.token(), req.userId())) {
return new SeckillResult(false, null, "非法请求", 0);
}
// 2. 检查库存
int stock = redisTemplate.opsForValue().get("stock:" + req.itemId());
if (stock <= 0) {
return new SeckillResult(false, null, "已售罄", System.currentTimeMillis() - start);
}
// 3. 扣库存 + 创建订单(分布式事务)
boolean ok = orderService.createWithDeduct(req);
if (!ok) {
return new SeckillResult(false, null, "下单失败", System.currentTimeMillis() - start);
}
return new SeckillResult(true, UUID.randomUUID().toString(), "抢购成功",
System.currentTimeMillis() - start);
} finally {
semaphore.release();
}
}, executor);
}
}
问题清单:
- Semaphore硬限流500,超出直接拒绝------用户体验差
- 线程池扩容有延迟,突发流量涌来时队列堆积
CompletableFuture链式调用在异常处理上很脆弱
3.3 虚拟线程方案:无限制并发
java
@Service
public class SeckillServiceVirtual {
private final StockService stockService;
private final OrderService orderService;
private final TokenService tokenService;
// 虚拟线程执行器 --- 每个任务一个虚拟线程,无队列无拒绝
private final ExecutorService virtualExecutor;
public SeckillServiceVirtual(StockService stockService,
OrderService orderService,
TokenService tokenService) {
this.stockService = stockService;
this.orderService = orderService;
this.tokenService = tokenService;
this.virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
}
public CompletableFuture<SeckillResult> seckill(SeckillRequest req) {
return CompletableFuture.supplyAsync(() -> doSeckill(req), virtualExecutor);
}
private SeckillResult doSeckill(SeckillRequest req) {
long start = System.currentTimeMillis();
// 每个请求一个虚拟线程 --- 不限流,不排队,不拒绝
// JVM自动调度,IO阻塞时虚拟线程挂起,载体线程去服务其他请求
// 1. 校验令牌(网络IO → 虚拟线程自动挂起)
if (!tokenService.verify(req.token(), req.userId())) {
return fail("非法请求", start);
}
// 2. 检查库存(Redis IO → 虚拟线程自动挂起)
int stock = stockService.getStock(req.itemId());
if (stock <= 0) {
return fail("已售罄", start);
}
// 3. 扣库存 + 创建订单(DB IO → 虚拟线程自动挂起)
boolean ok = orderService.createWithDeduct(req);
if (!ok) {
return fail("下单失败", start);
}
return new SeckillResult(true, UUID.randomUUID().toString(),
"抢购成功", System.currentTimeMillis() - start);
}
private SeckillResult fail(String msg, long start) {
return new SeckillResult(false, null, msg, System.currentTimeMillis() - start);
}
@PreDestroy
public void shutdown() {
virtualExecutor.close();
}
}
关键变化:
Semaphore(500)→ 去掉。限流交给Redis预扣和数据库行锁,不再用线程数限流ThreadPoolExecutor→newVirtualThreadPerTaskExecutor(),每个请求独享线程- 业务代码完全不变,只是执行器换了
3.4 Spring Boot 4 全局虚拟线程配置
Spring Boot 4 原生支持虚拟线程,一行配置即可让所有 Tomcat 请求处理线程切换为虚拟线程:
yaml
# application.yml
spring:
threads:
virtual:
enabled: true # Spring Boot 4.0.3 原生支持
启用后,DispatcherServlet 的每个请求处理都自动运行在虚拟线程中,不需要手动创建 ExecutorService:
java
@RestController
@RequestMapping("/api/seckill")
public class SeckillController {
private final SeckillServiceVirtual seckillService;
public SeckillController(SeckillServiceVirtual seckillService) {
this.seckillService = seckillService;
}
@PostMapping("/{itemId}")
public SeckillResult seckill(@PathVariable long itemId,
@RequestParam long userId,
@RequestParam int quantity,
@RequestHeader("X-Seckill-Token") String token) {
// Spring Boot 4 + spring.threads.virtual.enabled=true
// 这个方法已经运行在虚拟线程中
// 内部所有IO操作(Redis、DB、HTTP)都会自动挂起
return seckillService.doSeckill(new SeckillRequest(userId, itemId, quantity, token));
}
}
避坑 :启用虚拟线程后,
ThreadLocal的语义不变,但要注意:不同请求之间不会共享ThreadLocal(因为每个请求是不同虚拟线程)。如果你之前依赖请求线程的ThreadLocal传递上下文,确保不会在异步子线程中丢失。
四、压测对比:数据说话
4.1 压测环境
| 项目 | 配置 |
|---|---|
| CPU | AMD EPYC 7763, 8核 |
| 内存 | 16GB |
| JDK | 25.0.1 |
| Spring Boot | 4.0.3 |
| Redis | 7.2, 本机 |
| MySQL | 8.4, 本机 |
| 压测工具 | Wrk, 1000连接, 60s |
4.2 压测脚本
bash
# 模拟50000用户抢购1000件商品
wrk -t12 -c1000 -d60s \
-s post_seckill.lua \
"http://localhost:8080/api/seckill/100001?userId={uid}&quantity=1" \
-H "X-Seckill-Token: valid_token_${uid}"
4.3 压测结果
| 指标 | 线程池方案(200线程) | 线程池方案(2000线程) | 虚拟线程方案 |
|---|---|---|---|
| 峰值QPS | 1,850 | 8,200 | 42,600 |
| P99延迟 | 312ms | 187ms | 23ms |
| P999延迟 | 1,540ms | 890ms | 67ms |
| 错误率 | 23.1%(大量拒绝) | 4.7% | 0.02% |
| 堆内存峰值 | 512MB | 1.8GB | 680MB |
| CPU使用率 | 45% | 92% | 78% |
| GC停顿(最大) | 120ms | 340ms | 45ms |
解读:
- QPS提升23倍(1850 → 42600),原因是虚拟线程把"等IO的时间"还给了CPU
- P99延迟从312ms降到23ms,不再有队列等待
- 内存只比200线程方案多了168MB(680MB vs 512MB),而2000平台线程方案需要1.8GB
- 错误率接近零------没有拒绝策略,没有队列溢出
4.4 为什么QPS没到5万?
瓶颈不在线程,而在下游。当虚拟线程把并发能力拉满后,Redis和MySQL成为真正的瓶颈:
# 压测期间的资源使用率
Redis CPU: 89% ← 扣库存的 Lua 脚本是热点
MySQL CPU: 76% ← 行锁争用
JVM CPU: 78% ← 虚拟线程调度本身的开销
这说明虚拟线程已经不再是瓶颈------它成功地把瓶颈推到了IO层,这正是我们想要的结果。 接下来优化的方向是Redis集群和MySQL读写分离,而不是调线程池参数。
五、虚拟线程的最佳实践
5.1 什么时候用虚拟线程
| ✅ 天然适合 | ❌ 需要慎重 |
|---|---|
| IO密集型(HTTP调用、DB查询、Redis) | CPU密集型(加密计算、图像处理、视频转码) |
| 高并发API服务 | Fork/Join并行计算 |
| 微服务间调用 | 已有的响应式链路(WebFlux + Reactor) |
| 需要同步代码风格的场景 | 实时流处理(低延迟要求 < 1ms) |
核心原则:虚拟线程解决的是"等IO时浪费线程"的问题。如果你的线程大部分时间都在算而不是等,虚拟线程帮不了你。
5.2 需要避免的陷阱
java
// ❌ 陷阱1:在虚拟线程中使用 synchronized 导致 pinning
// JDK 25 已大幅改善,但在高并发关键路径仍建议替换
synchronized (deductLock) {
stockService.deduct(itemId, qty); // DB IO,可能pinning
}
// ✅ 替代方案:ReentrantLock(JDK 25不会pinning)
private final ReentrantLock deductLock = new ReentrantLock();
deductLock.lock();
try {
stockService.deduct(itemId, qty);
} finally {
deductLock.unlock();
}
// ❌ 陷阱2:把虚拟线程放回固定线程池
Executors.newFixedThreadPool(10).submit(
() -> Thread.ofVirtual().start(() -> { /* ... */ })
);
// 虚拟线程嵌套在固定线程池里,失去了意义
// ✅ 正确:直接用虚拟线程执行器
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
exec.submit(() -> { /* ... */ });
}
// ❌ 陷阱3:用 Thread.sleep 替代 ScheduledExecutor 做定时任务
// 虚拟线程 sleep 不消耗载体线程,但不能保证精确调度
Thread.sleep(Duration.ofSeconds(10)); // 可能偏差 10ms+
// ✅ 定时任务仍用 ScheduledExecutorService
scheduledExecutor.scheduleAtFixedRate(task, 0, 10, TimeUnit.SECONDS);
5.3 虚拟线程 + 结构化并发预告
本篇的秒杀代码中,doSeckill 方法是顺序执行的:校验 → 查库存 → 下单。但实际上,校验和查库存可以并行:
java
// 第4篇会详细展开的结构化并发写法(先给个预览)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var tokenTask = scope.fork(() -> tokenService.verify(req.token(), req.userId()));
var stockTask = scope.fork(() -> stockService.getStock(req.itemId()));
scope.join(); // 等待两个任务都完成
scope.throwIfFailed(); // 任一失败则取消另一个
if (!tokenTask.get() || stockTask.get() <= 0) {
return fail("无效或已售罄", start);
}
// 继续下单...
}
两路IO并行,延迟减半。这就是第4篇的内容。
六、与WebFlux的关系:该选哪个?
很多同学会问:Spring Boot 4 已经有 WebFlux 了,虚拟线程是不是重复?
| 维度 | 虚拟线程 | WebFlux (Reactor) |
|---|---|---|
| 编程模型 | 同步命令式(好读好写好调试) | 异步响应式(学习曲线陡) |
| 代码风格 | String result = service.query() |
Mono<String> result = service.query() |
| 生态兼容 | 所有同步库直接用 | 需要响应式驱动(R2DBC、WebClient) |
| 调试体验 | 正常断点调试 | 需要特殊工具追踪reactive链 |
| 性能天花板 | IO密集型接近WebFlux | 极端高并发略优 |
| 改造成本 | 配一行 spring.threads.virtual.enabled=true |
需要全链路重写 |
我的建议:
- 新项目 + 团队响应式经验不足 → 虚拟线程,性价比最高
- 已有WebFlux项目 → 继续用WebFlux,迁移成本不值得
- 极端低延迟场景(量化交易、实时风控) → WebFlux或Project Loom + 手动调度
- 大多数电商/SaaS项目 → 虚拟线程,没悬念
七、小结
| 要点 | 说明 |
|---|---|
| 核心原理 | 一个OS线程承载大量虚拟线程,IO阻塞时自动挂起 |
| JDK 25改进 | Pinning减少90%+,ThreadLocal性能优化,载体线程自动调优 |
| 性能表现 | QPS提升23倍,P99延迟降到23ms,内存仅增加168MB |
| Spring Boot集成 | spring.threads.virtual.enabled=true 一行搞定 |
| 最佳场景 | IO密集型高并发API |
| 最大陷阱 | synchronized仍可能导致pinning,关键路径用ReentrantLock |
| 与WebFlux关系 | 大多数场景虚拟线程是更优选择,改造成本极低 |
下篇预告
第4篇:《结构化并发 & 作用域值:订单聚合查询的新写法》
秒杀服务中,校验、查库存、查用户信息可以三路并行。但传统的 CompletableFuture.allOf() 有三个致命问题:异常处理脆弱、无法取消、泄漏风险。StructuredTaskScope 是JDK 25给出的最终答案------代码更短、更安全、更快。
你的项目中遇到过线程池打满的情况吗?当时怎么解决的?评论区聊聊。
关键词:
JDK 25虚拟线程Virtual ThreadsSpring Boot 4秒杀高并发线程池WebFluxJMH性能压测