上一批文章里我写过虚拟线程的实战经验,说"10倍吞吐量是真实存在的"。有读者评论说"别光说好的,踩了什么坑也说说啊"。
好,这篇文章就是来还债的。
虚拟线程上线后第一周,确实跑得很好。QPS从800涨到8000+,CPU和内存都没什么异常。我当时还发了条朋友圈,意思就是"虚拟线程真香"。
然后第八天,翻车了。
事故经过
那天下午2点左右,线上流量高峰期。运维群突然告警:order-service的P99响应时间从200ms飙到了8秒,但CPU使用率才30%,内存也正常。
更诡异的是:QPS反而下降了------从平时的8000掉到了2000左右。像是服务在"故意"拒绝处理请求。
看日志,大量请求超时:
csharp
java.util.concurrent.TimeoutException: null
at java.util.concurrent.FutureTask.get(FutureTask.java:204)
...
// 等数据库连接超时了
HikariCP的日志显示:
csharp
HikariPool-1 - Connection is not available, request timed out after 30000ms
数据库连接池被打满了。但数据库本身的连接数监控显示只有50个活跃连接(连接池配的也是50),也就是说------连接池里的50个连接全被占用了,新的请求拿不到连接。
但CPU才30%。50个数据库连接都被占着,但CPU又没在干活,说明大量线程在等待------在等什么?
排查过程
第一反应:数据库慢查询
先排查是不是数据库变慢了。看慢查询日志------确实有几个查询超过1秒,但数量不多,不至于把50个连接全占住。
第二步:看线程状态
用jstack抓线程快照:
bash
kubectl exec -it order-service-pod-xxx -- jcmd <pid> Thread.print > thread_dump.txt
看线程状态分布的时候,我注意到一个异常:
yaml
线程总数:12,847(虚拟线程+平台线程)
其中:
RUNNABLE: 23
WAITING: 4,521
BLOCKED: 8,303 ← 8300个线程处于BLOCKED状态!
8300个线程BLOCKED?这不对。虚拟线程的设计是在I/O阻塞时"让出"载体线程,不会处于BLOCKED状态------它应该是WAITING(让出后等待I/O完成)。
继续看这8300个BLOCKED的线程在等什么:
php
"order-virtual-thread-4521" #8452 virtual
java.lang.Thread.State: BLOCKED
at com.xxx.OrderService.processOrder(OrderService.java:87)
- waiting to lock <0x000000076a3b2c10> (a java.lang.Object)
- locked <0x000000076a3b2d20> (a com.xxx.OrderService)
BLOCKED on a monitor lock 。这些虚拟线程在等一个synchronized锁!
找到代码
打开OrderService.java第87行:
java
public class OrderService {
private final Object lock = new Object();
// 这就是第87行附近
public void processOrder(Order order) {
synchronized (lock) { // ← 就是这里
// 调用外部API查询库存
String stockInfo = inventoryClient.queryStock(order.getProductId()); // 网络调用!
// 数据库写入
orderRepository.save(order);
}
}
}
synchronized块里做了网络调用和数据库操作。
为什么这会导致问题?
这就涉及到虚拟线程的"pinning"问题了。
虚拟线程运行在载体线程(platform thread)上。当虚拟线程做I/O操作时,正常情况下它会"让出"载体线程,让其他虚拟线程使用。这是虚拟线程高并发的核心机制。
但有一个例外:如果虚拟线程在synchronized代码块内被阻塞,它无法让出载体线程。 载体线程被"钉住"(pinned)了,只能干等着。
Java 21中,synchronized块内的I/O阻塞会导致pinning。Java 24+已经修复了大部分pinning场景(JEP 491),但------
我们用的Spring Boot 4默认配置的JDK版本是21。
来看一下发生了什么:
arduino
场景:8000+虚拟线程在处理订单
每个虚拟线程都要进入synchronized块
synchronized块里有网络调用(库存查询,平均300ms)
→ 虚拟线程在synchronized块内被网络I/O阻塞
→ 载体线程被pinned,无法执行其他虚拟线程
→ Spring Boot默认的载体线程池只有200个(ForkJoinPool)
→ 200个载体线程全被pin住后,其他8000个虚拟线程排队等待载体线程
→ 实际并发度从8000骤降到200
→ 每个载体线程被pin 300ms(网络调用时间)
→ 8000个请求 / 200个载体线程 × 300ms = 12秒排队时间
这就是为什么P99飙到了8秒,QPS反而下降了------大量虚拟线程在排队等载体线程,而不是在做有用的工作。
而数据库连接被打满的原因是:200个载体线程虽然被pin住了,但它们的虚拟线程在进入synchronized之前已经拿到了数据库连接。这些连接一直被持有(因为虚拟线程被pin住了,没法释放),新的虚拟线程又拿不到连接,最终连接池超时。
解决方案
紧急修复:把synchronized换成ReentrantLock
java
public class OrderService {
private final ReentrantLock lock = new ReentrantLock();
public void processOrder(Order order) {
lock.lock();
try {
String stockInfo = inventoryClient.queryStock(order.getProductId());
orderRepository.save(order);
} finally {
lock.unlock();
}
}
}
ReentrantLock在虚拟线程阻塞时不会pin载体线程------它使用的是JUC框架的park/unpark机制,虚拟线程可以正常让出载体线程。
改完后重新部署,P99从8秒降回200ms,QPS恢复到8000+。
根本修复:真的需要这个锁吗?
冷静下来想想------这个synchronized块保护的逻辑是"查询库存 + 写入订单"。真的需要加锁吗?
查询库存和写入订单之间,并不存在需要原子性保护的共享状态。这个锁大概率是原作者为了"防止并发"随手加的,但它保护的粒度太粗了。
更合理的做法:
java
public class processOrder(Order order) {
// 查库存------不需要锁,外部API自己处理并发
String stockInfo = inventoryClient.queryStock(order.getProductId());
// 写订单------数据库行锁就够了,不需要应用层锁
orderRepository.save(order);
}
直接去掉锁。经评估,库存查询和订单写入之间确实没有竞态条件。
如何检测Pinning问题
这次事故让我意识到------虚拟线程的pinning问题不会在开发阶段暴露,因为开发环境的并发量不够。只有在生产环境的高并发下才会显现。
几个检测手段:
1. 启动时加JVM参数(开发/预发环境)
bash
-Djdk.tracePinnedThreads=short
这个参数会在发生pinning时打印日志:
css
Thread[#45,ForkJoinPool-1-worker-3] pinned at:
com.xxx.OrderService.processOrder(OrderService.java:87)
java.lang.Thread.State: BLOCKED
每次pinning都会打印一行,你可以统计pinning的频率和位置。
2. 用JFR(Java Flight Recorder)持续监控(生产环境)
bash
# 启动时开启JFR
-XX:StartFlightRecording=duration=60s,filename=/tmp/jfr/vt-pin.jfr,settings=profile
# 分析JFR文件里的VirtualThreadPinned事件
jfr print --events jdk.VirtualThreadPinned vt-pin.jfr
JFR的开销很小,可以在生产环境持续运行。pinning事件会被记录下来,事后分析。
3. Micrometer监控(推荐)
java
@Bean
public MeterRegistry meterRegistry() {
// 监控虚拟线程pinning事件
// Spring Boot 4 + Micrometer 1.13+ 支持
}
在Grafana里加一个pinning事件的告警面板,pinning频率超过阈值就告警。
排查清单:虚拟线程常见问题
这次踩坑后,我整理了一个排查清单,以后上线虚拟线程前过一遍:
synchronized + I/O 检查
java
// 全局搜索 synchronized,检查每个synchronized块里有没有I/O操作
// ❌ 危险
synchronized (lock) {
httpClient.call(); // 网络调用
database.query(); // 数据库查询
file.read(); // 文件读取
}
// ✅ 安全
lock.lock();
try {
httpClient.call();
} finally {
lock.unlock();
}
ThreadLocal使用检查
java
// ThreadLocal在虚拟线程下会爆炸------10万个虚拟线程 × 每个ThreadLocal 1KB = 100MB
// ❌ 危险
private static final ThreadLocal<UserContext> context = new ThreadLocal<>();
// ✅ 用ScopedValue替代(JDK 21+)
private static final ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();
第三方库的synchronized检查
java
// 有些第三方库内部用了synchronized
// 检查方式:看文档是否声明支持虚拟线程
// 或者用tracePinnedThreads跑一遍压测,看有没有报pinning
关于Java版本
这次事故的根因之一是我们用的JDK 21,synchronized块内的pinning问题在JDK 21中是已知的。
JDK 24(JEP 491)已经修复了这个问题------synchronized块内的I/O阻塞不再pin载体线程。JDK 25(2025年9月LTS)也包含了这个修复。
如果你正在用JDK 21 + 虚拟线程,要么:
- 升级到JDK 25(推荐,但需要评估兼容性)
- 排查并替换所有synchronized + I/O的组合(我们目前的选择)
我们的计划是下半年的维护窗口升级到JDK 25,到时候就不需要这么小心翼翼了。
最后说一句
虚拟线程确实是个好东西,我用了一年多,大部分时间确实很香。但这次事故提醒我------再好的技术也有适用边界和注意事项 。不是加了spring.threads.virtual.enabled=true就万事大吉了,底层原理你得搞明白。
特别是synchronized和虚拟线程的交互,这是个很容易踩的坑。我见过好几个团队都遇到了同样的问题------开发时好好的,上了生产高并发就翻车。
希望这篇文章能帮你提前避坑。