虚拟线程上线一周后翻车了——pinning问题排查实录

上一批文章里我写过虚拟线程的实战经验,说"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 + 虚拟线程,要么:

  1. 升级到JDK 25(推荐,但需要评估兼容性)
  2. 排查并替换所有synchronized + I/O的组合(我们目前的选择)

我们的计划是下半年的维护窗口升级到JDK 25,到时候就不需要这么小心翼翼了。


最后说一句

虚拟线程确实是个好东西,我用了一年多,大部分时间确实很香。但这次事故提醒我------再好的技术也有适用边界和注意事项 。不是加了spring.threads.virtual.enabled=true就万事大吉了,底层原理你得搞明白。

特别是synchronized和虚拟线程的交互,这是个很容易踩的坑。我见过好几个团队都遇到了同样的问题------开发时好好的,上了生产高并发就翻车。

希望这篇文章能帮你提前避坑。


相关推荐
wuminyu1 小时前
JDK21解决虚拟线程IO阻塞原理剖析
java·linux·c语言·jvm·c++
用户6919026813391 小时前
Harness工程的概念,以及简单的代码示例
javascript·架构
Dr.kangder1 小时前
嵌入式面试总结(一)——嵌入式系统实时性
面试·职场和发展·架构·嵌入式·虚拟化
带刺的坐椅1 小时前
Solon AOT & Native:三段式编译,从 Java 到原生可执行文件
java·aot·solon·native
AI_paid_community1 小时前
如何使用 Claude 在 AI 时代快速入局新的行业?(经验贴)
前端·javascript·后端
AI多Agent协作实战派1 小时前
AI多Agent协作系统实战(三十六):代码里明明写了,编译完怎么没了?
后端
柠檬味拥抱1 小时前
代码看腻了,我让 Seed Evolving 把整个仓库搓成了一座能走进去的 3D 城市
后端
程序员良辰1 小时前
【TongWeb8】使用 Crontab 定时重启和检测 TongWeb 服务
java·中间件·tomcat
mit6.8242 小时前
archived
后端·python·flask