Skip to content
Go back

虚拟线程的Pinning问题

Java 21 虚拟线程的 Pinning 问题:synchronized 为什么钉住了 Carrier Thread

一句话结论(30s)

我给「吃什么」本地生活点评平台做了虚拟线程 IO 优化:把高并发 IO 密集路径从传统线程池换成 Java 21 虚拟线程。因为虚拟线程在 IO 阻塞时能自动 unmount、释放 Carrier Thread,用少量载体线程即可支撑数万并发,解决了原来”线程阻塞排队、尾延迟被放大到分钟级”的问题。同场景压测下,P99 延迟从分钟级降到 2 秒,吞吐提升约 3 倍;中间最大的坑是 Pinning——历史 synchronized 锁钉住了载体线程,换 ReentrantLock 彻底解决。

背景诉求

平台是本地生活点评场景,高并发、IO 密集(查商家详情、下单、写点评都要等数据库 / 下游网络),原线程池是”一个请求一个平台线程”的模型:

本质诉求:在不重写业务、不引入异步编程的前提下,把阻塞排队导致的尾延迟降下来。

目标边界

核心难点

难点不在”换线程池”这一行配置,而在换完之后 P99 不降反升、还偶尔冒出 2 秒以上的毛刺——这就是 Pinning 问题,也是整个优化里最难发现、最值得讲的部分:

难在:代码能跑、功能正常,只有压测 + JFR 抓 jdk.VirtualThreadPinned 事件才能暴露。(其中 Pinning 的底层机制最有意思,要不要展开?)

关键取舍

为什么不继续调线程池参数、也不上异步,而是选虚拟线程:

核心权衡:用”运行时的调度改造”换”代码不改异步”,代价是要对较新特性保持谨慎——必须用 JFR 验证,且要排查 Pinning 这类兼容性坑。

💭 思考:为什么「运行时调度改造」比「改异步」划算?因为改异步要重写业务、引入回调/响应式,心智负担和排错成本都高;虚拟线程让你继续写同步阻塞代码,JVM 在 IO 阻塞时替你 unmount、调度别的线程。用运行时的复杂度换代码的简单。

个人行动

  1. 判断场景:先确认平台请求是 IO 密集(等数据库 / 网络)而非 CPU 密集,判定适合虚拟线程;
  2. 改运行模型:把 Tomcat 线程池替换为 Java 21 虚拟线程;
  3. 压测验证:换完后发现 P99 不降反升、偶发 2 秒毛刺,没有盲目相信”换了就快”;
  4. 定位根因:用 JFR 抓 jdk.VirtualThreadPinned 事件,定位到历史代码里的 synchronized 方法(如 OrderService.createOrder);
  5. 修复:把 synchronized 换成 ReentrantLock(AQS 纯 Java 层,LockSupport.park() 触发 unmount,兼容虚拟线程);
  6. 延伸排查:顺带识别了 StringBuffer / Vector / Hashtable 等 JDK 内部用 synchronized 的隐蔽 Pin 点。

结果与复盘

三版本回答

30 秒版(一句话):我给「吃什么」点评平台做了虚拟线程 IO 优化——高并发 IO 密集场景原来线程阻塞排队、尾延迟到分钟级,改用 Java 21 虚拟线程后 P99 降到 2 秒、吞吐提升 3 倍;中间最大的坑是 Pinning,历史 synchronized 锁钉住载体线程,换 ReentrantLock 解决。

2 分钟版(电梯陈述):背景是平台高并发 IO 密集、原线程池阻塞排队导致尾延迟放大到分钟级。难点是换成虚拟线程后 P99 不降反升、偶发 2 秒毛刺——JFR 抓 jdk.VirtualThreadPinned 事件发现是历史 synchronized 的 Monitor 锁绑定 Carrier Thread,导致虚拟线程无法 unmount(Pinning)。方案是把运行模型换虚拟线程 + 把 synchronized 替换成 ReentrantLock(AQS 纯 Java 层,park 触发 unmount)。结果是同场景压测 P99 从分钟级降到 2 秒、吞吐提升约 3 倍。

5-10 分钟版(深度展开):先讲架构——传统”一个请求一个平台线程”在 IO 密集下大量线程空等,换成虚拟线程的 mount/unmount 模型后,8 个 Carrier Thread 就能支撑数万并发。再讲数据链路——请求进 Tomcat 分配到虚拟线程,IO 阻塞时 unmount、Carrier Thread 去调度别的虚拟线程,IO 就绪再 mount 回来继续。然后讲方案取舍——调线程池参数治标不治本、异步模型改动大,虚拟线程用”运行时调度改造”换”代码不改异步”。接着讲异常路径——Pinning:synchronized 的 ObjectMonitor 绑定在 Carrier Thread 上,虚拟线程持有 Monitor 期间不能安全卸载,退化成平台线程;除了显式 synchronizedStringBuffer / Vector / Hashtable 内部也用 synchronized,是更隐蔽的 Pin 点。最后讲稳定性——用 JFR jdk.VirtualThreadPinned 事件定位 + 上线前检查,P99 从分钟级降到 2 秒、吞吐提升约 3 倍。

底层深入(技术细节)

问题背景

Java 21 虚拟线程上线后,我在秒杀项目里把 Tomcat 线程池替换为虚拟线程。压测结果很奇怪——P99 延迟不降反升,偶尔出现 2 秒以上的毛刺。用 JFR 抓了 jdk.VirtualThreadPinned 事件才发现:历史代码里残留的 synchronized 方法在虚拟线程环境下成了性能杀手。

虚拟线程的 mount/unmount 机制

虚拟线程是 JVM 管理的轻量级线程,核心机制是”mount/unmount”:

  1. 虚拟线程执行到 IO 阻塞时,从 Carrier Thread(平台线程)上 unmount——Continuation 状态保存在堆上
  2. Carrier Thread 立刻去调度其他虚拟线程
  3. IO 就绪后,虚拟线程 mount 回任意一个空闲的 Carrier Thread 继续执行
虚拟线程 A ──IO阻塞──→ unmount → Carrier Thread 释放 → 调度虚拟线程 B
虚拟线程 A ──IO就绪──→ mount 到 Carrier Thread → 继续执行

核心优势:8 个 Carrier Thread(= CPU 核数)就能支撑数万虚拟线程并发 IO

💭 思考:为什么 8 个 Carrier Thread 能撑数万虚拟线程?因为虚拟线程阻塞时不是「占着线程等」,而是把 Continuation 状态存到堆上、unmount 释放 Carrier Thread,让这 8 个平台线程去调度别的虚拟线程。阻塞不再消耗平台线程,所以并发数不再受线程数限制。

synchronized 为什么会 Pin 住

synchronized 的 Monitor 锁(ObjectMonitor)依赖 OS 层面的互斥量,锁状态绑定在 Carrier Thread 上。JVM 规范要求持有 Monitor 期间虚拟线程不能安全地从 Carrier Thread 上卸载——因为 OS 锁的状态和 Carrier Thread 绑在一起,卸载后其他虚拟线程 mount 到同一个 Carrier Thread 会继承这个锁状态。

所以虚拟线程进入 synchronized 块后,Carrier Thread 被”钉死”(Pinning),直到 synchronized 块退出。效果等同于退化成平台线程——一个虚拟线程占住一个 Carrier Thread,其他虚拟线程等着。

💭 思考:为什么 synchronized 能钉住 Carrier Thread?因为 Monitor 锁状态绑定在 OS 线程(Carrier Thread)上,虚拟线程持有锁时如果卸载,别的虚拟线程 mount 上来会继承这个锁状态,出现锁语义错乱。所以 JVM 规范规定持有 Monitor 期间不能 unmount——宁可退化成平台线程也不冒锁状态泄漏的风险。

ReentrantLock 为什么可以

ReentrantLock 是纯 Java 层面的 AQS 实现,获取锁失败时调用的是 LockSupport.park()

// AbstractQueuedSynchronizer.java
final boolean acquireQueued(final Node node, int arg) {
    for (;;) {
        if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
            interrupted = true;
    }
}

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);  // 虚拟线程的 park → 触发 unmount
    return Thread.interrupted();
}

虚拟线程的 park() 会触发 unmount,Carrier Thread 立刻释放去调度其他虚拟线程,无 OS 介入,完全兼容 mount/unmount 机制。

💭 思考:ReentrantLock 为什么不 Pin?因为它是纯 Java 层的 AQS,抢锁失败时调 LockSupport.park()——虚拟线程的 park 会触发 unmount,Carrier Thread 立刻释放去调度别人,整个过程无 OS 介入。这就是「Java 层锁」和「OS 层锁」的本质差别。

排查过程

# 1. 启动时开启 JFR
java -XX:StartFlightRecording=filename=pin.jfr ...

# 2. JMC 查看 jdk.VirtualThreadPinned 事件
#    → 定位到 OrderService.createOrder 方法

# 3. 代码定位
public synchronized OrderResult createOrder(OrderRequest req) {  // PIN!
    // 业务逻辑
}

修复

// Before(虚拟线程 Pin)
public synchronized OrderResult createOrder(OrderRequest req) {
    // ...
}

// After(虚拟线程友好)
private final ReentrantLock lock = new ReentrantLock();

public OrderResult createOrder(OrderRequest req) {
    lock.lock();
    try {
        // ...
    } finally {
        lock.unlock();
    }
}

一个更隐蔽的场景

不是只有显式的 synchronized 方法才 Pin。StringBufferVectorHashtable 等 JDK 内部使用了 synchronized 的类在虚拟线程调用时同样触发 Pin——JFR 上 jdk.VirtualThreadPinned 的堆栈可能指向 StringBuffer.append()

💭 思考:为什么 StringBuffer/Vector/Hashtable 也是 Pin 点?因为它们内部大量用 synchronized 方法,你根本没显式写锁,却在不知不觉中进了 Monitor。所以排查 Pin 不能只看自己代码的 synchronized,还要警惕这些「历史遗留」的同步类。

总结

场景synchronizedReentrantLock
锁实现JVM Monitor(OS 互斥量)AQS 纯 Java 层
虚拟线程 Pin✅ 会 Pin❌ 不 Pin
阻塞时 Carrier Thread被钉死释放给其他虚拟线程
空虚拟线程并发数等效平台线程数可达数万

JFR jdk.VirtualThreadPinned 事件是排查 Pin 的瑞士军刀——先用它定位,再把 synchronized 换成 ReentrantLock,P99 恢复正常。

章末提问

追问 1:什么是 Pinning?synchronized 为什么会钉住虚拟线程?

结论先行:Pinning 是虚拟线程进入 synchronized 块后无法从 Carrier Thread 卸载、载体线程被钉死的现象,根源是 Monitor 锁状态绑定在 OS 线程上。

因为:synchronized 的 ObjectMonitor 依赖 OS 互斥量、锁状态和 Carrier Thread 绑定,JVM 规范要求持有 Monitor 期间虚拟线程不能安全 unmount(否则其他虚拟线程 mount 上来会继承锁状态)。于是虚拟线程退化成「一个占住一个平台线程」,其他虚拟线程干等,优势全部失效。

追问 2:ReentrantLock 为什么不会导致 Pinning?

结论先行:因为 ReentrantLock 是纯 Java 层的 AQS 实现,抢锁失败时调 LockSupport.park(),虚拟线程的 park 会触发 unmount 而非占用 Carrier Thread。

因为:AQS 的 acquireQueued 循环里 parkAndCheckInterrupt 最终调 LockSupport.park(this),虚拟线程 park 时把 Continuation 状态存堆上、释放 Carrier Thread 去调度别的线程,无 OS 互斥量介入,与 mount/unmount 机制天然兼容。

追问 3:除了显式 synchronized,还有哪些隐蔽的 Pin 点?怎么定位 Pin?

结论先行:JDK 内部用 synchronized 的 StringBuffer、Vector、Hashtable 等类也是隐蔽 Pin 点,定位靠 JFR 的 jdk.VirtualThreadPinned 事件。

因为:这些历史类的方法体里全是 synchronized,你调用它们时不知不觉进入 Monitor 触发 Pin,JFR 堆栈可能直接指向 StringBuffer.append()。排查时先用 -XX:StartFlightRecording 开启 JFR,再用 JMC 看 jdk.VirtualThreadPinned 事件定位到具体方法,最后把 synchronized 换 ReentrantLock。


Share this post on:

Previous Post
G1垃圾回收器——Region、RSet和SATB
Next Post
Stream惰性求值——为什么中间操作不会立刻执行?