使用JDK21新特性虚拟线程时遇到的坑

使用JDK21新特性虚拟线程时遇到的坑

JDK虚拟线程、平台线程、硬件线程之间的关系:

现象说明

  • 入口现象:Feign 远程调用永久阻塞,不触发任何超时异常,且被调用方无日志。
  • 影响范围:该服务的所有远程调用均陷入阻塞。
  • 运行环境:JDK 21.0.11 / Spring Boot 4 / Spring Cloud 2025.1 / Caffeine 3.2.x
  • 初步结论 :其余服务均运行正常。根因在于虚拟线程 的执行依赖平台线程 作为载体,虚拟线程 在synchronized 代码块中执行了IO操作以及远程调用,而远程调用使用了虚拟线程 ,远程调用执行时虚拟线程 又请求不到载体线程(被耗尽),导致永久等待。

0. 摘要

SnapshotService.load() 使用 cache.get(serviceId, loader::load)。Caffeine 为保证「同 key 并发只加载一次」,

将该 loader 放在 ConcurrentHashMap.compute 的 synchronized 块内执行,而该 loader 内部调用 Feign 阻塞等待 HTTP 响应。

JDK 21 下「虚拟线程在 synchronized 内阻塞」会触发 pin :VM 拒绝卸载该虚拟线程,载体线程被一并阻塞且无法回收。

启动预热并发加载 29 个服务,载体线程仅 20 个(= availableProcessors()),20 个载体被瞬间 pin 满,此后:

  1. HttpClient 的 exchange 虚拟线程无法被调度 → 请求根本没有发出网卡
  2. HttpRequest.timeout(readTimeout=10s)由同一套无法运行的 exchange 机制计时 → 超时永不触发

结果:不到 1 秒全局锁死,之后状态恒定。


1. 现象与初筛证据

检查项 结果 推论
直连 dms POST /wayBill/getList(curl + 内部 token) 200,0.13s dms 健康,排除下游
fms GET /pricing/loadWayBillInfo(同一业务路径) 2 次均 120s 超时(HTTP=000,零字节) 100% 复现,问题在 fms 进程内
jstack 30 个 http-nio-8087-exec-* 阻塞于 GenesisJdkHttpClient.execute:109 → HttpClientImpl.send:934 → CompletableFuture.get 阻塞在等待 HTTP 响应
netstat -ano(按 fms PID 过滤) 无任何到 18082 的 TCP 连接 请求未出网卡
HttpClient-1-SelectorManager 空转于 WEPoll.wait,无 exchange 注册 连接池未被使用
ForkJoinPool-1 20 个 worker,20 个均 Carrying virtual thread,0 空闲 虚拟线程调度器已耗尽
虚拟线程总数 492,其中 211 个从未启动(无栈) 无载体可用,任务积压在队列
nproc 20 载体数 = availableProcessors()

排查口诀:下游直连健康 + 本机无出站连接 + 超时不触发 ⇒ 停止排查下游,转向本进程的线程/载体状态。


2. 术语与机制前提

后面会反复用到这几个概念,先给准确定义。

2.1 虚拟线程 / 载体线程 / 调度器

概念 定义
虚拟线程(VirtualThread) JDK 21 的轻量线程。数量无上限,每个任务一个;本身不绑定 OS 线程
载体线程 (carrier / CarrierThread) 真正承载执行的平台线程,jdk.internal.misc.CarrierThread extends ForkJoinWorkerThread extends Thread。数量 = availableProcessors()
调度器 JDK 内部一个进程级共享 的 ForkJoinPool,parallelism = availableProcessors(),maxPoolSize = max(parallelism, 256)。线程名 ForkJoinPool-1-worker-N

注意 :业务代码里的 Executors.newVirtualThreadPerTaskExecutor() 与 common-feign 中 GenesisJdkHttpClient

使用的执行器,共用同一个默认调度器。

2.2 挂载 / 卸载(mount / unmount)

虚拟线程的栈存放在堆上的 StackChunk(continuation 栈)中,不在载体线程栈上。

复制代码
挂载 (thaw)  :Continuation.thaw() 把 StackChunk 中的栈帧搬到载体线程栈上 → 开始执行
卸载 (freeze):虚拟线程阻塞时 Continuation.freeze() 把栈帧搬回 StackChunk
                → 载体线程返回 ForkJoinPool.runWorker() 的任务循环,可服务下一个虚拟线程

关键点:载体线程的释放发生在「虚拟线程阻塞」时,而不是「执行完成」时。

这就是 20 个载体能支撑数十万虚拟线程的原因 ------ 载体是按时段借出的,虚拟线程每次阻塞就归还一次。

2.3 pin(钉住)

pin(钉住) :虚拟线程阻塞时,VM 拒绝执行 unmount(Continuation.yield() 返回 false)。

结果是虚拟线程的栈帧仍留在载体线程栈上,载体线程被一并阻塞、无法回到任务循环,因此无法被调度器回收。

一句话:载体线程本来可以在虚拟线程阻塞时被释放,pin 之后释放不了。

JDK 21 的 pin 触发条件:

  • 虚拟线程处于 synchronized 块/方法内(本次就是这个)
  • 虚拟线程阻塞在 monitor entry (等待进入 synchronized)
  • 栈上存在 native 帧 / JNI 调用
  • Object.wait()

为什么 synchronized 会导致 pin :JDK 21 的 synchronized 编译为 monitorenter/monitorexit,

操作 ObjectMonitor,其 owner 字段指向具体的 OS 线程。若允许卸载,则:

  • 锁的 owner 变成一条未运行的线程
  • 虚拟线程重新挂载时可能落在另一条 载体线程上,此时执行 monitorexit,owner 不匹配

ObjectMonitor 不支持 owner 在载体线程间迁移(还需处理 entry list / wait set 上排队的线程),

因此 VM 只能选择「持有 monitor 期间不卸载」。这是 JDK 21 的实现限制,JDK 24 的 JEP 491 已重构。

pin 之后的 fallback:

java 复制代码
// java.lang.VirtualThread#park
boolean yielded = yieldContinuation();   // 返回 false 表示卸载失败
if (!yielded) {
    parkOnCarrierThread(timed, nanos);   // 内部为 Unsafe.park ------ 挂起的是载体线程本身
}

判定方法 :栈中出现 VirtualThread.parkOnCarrierThread 即 pin(普通 park 只有 VirtualThread.park)。

⚠️ 该判据只能识别「Java 层 park 型」的 pin 。阻塞在 monitor entry 的虚拟线程栈顶直接是
synchronized 所在行,没有 park 帧,无法用这个方法识别
------ 现场 20 个被 pin 的虚拟线程里,

只有 10 个能这样认出,另外 10 个需要靠「载体全占 + 线程数对不上」反推。

2.4 载体线程被 pin 后的状态

  • 未返回 ForkJoinPool.runWorker() 任务循环 → 池视角为 busy,非 idle,不可回收
  • WorkQueue 顶部仍是该虚拟线程,无法重新分配
  • 不可逆 :即使 park 被唤醒,载体线程也只能继续执行同一个 虚拟线程(monitorexit 必须由 owner 执行),
    无法转交。因此 pin 的持续范围 = 该虚拟线程走出 synchronized 块为止

2.5 为什么 readTimeout 不生效

HttpRequest.timeout 由 HttpClient 的 exchange 机制在 SelectorManager 中注册定时器实现。

exchange 本身也是提交到虚拟线程执行器的任务 ------ 载体被 pin 满后 exchange 从未启动,定时器从未注册。

所以现象是「永久阻塞」,而非「10s 后 HttpTimeoutException」。

看到「超时不生效」,应立刻怀疑线程/载体层面,而不是超时配置。


3. 完整因果链

3.1 调用链(8 张源码截图,按调用顺序)

① 并发源:预热一次性提交 N 个虚拟线程

java 复制代码
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (String id : pending) {
        executor.submit(() -> { load(id); });   // 每服务一个虚拟线程,无分批
    }
}

② 将 loader 交给 Caffeine ------ 锁的入口

java 复制代码
public PricingSnapshot load(String serviceId) {
    Cache<String, PricingSnapshot> current = cache;
    return current.get(serviceId, loader::load);   // ← 根因位置
}

③ loader 内为阻塞远程调用

FmsPricingSnapshotLoader 内部通过 serviceTypeFeignClient 发起远程调用。

图中高亮的是 resolveServiceName:171 处的 getInfo;本次事故栈里走的是同一类的另一处 :

load:155 → serviceCalcConfFrom:211 → LogisticsServiceBaseConfSource.load:32 → 同样落到

serviceTypeFeignClient.getInfo。两个调用点是同一个 Feign 方法,结论一致。

代码本身正确 ------ 它就是要远程取服务配置。问题在执行位置。

④ 放大器:Feign 的 HttpClient 同样使用虚拟线程执行器

java 复制代码
case VIRTUAL -> Executors.newVirtualThreadPerTaskExecutor();

与预热共用同一个默认调度器,因此业务侧 pin 会直接饿死 Feign。

⑤ 闭环另一半:同步 Feign = sendAsync + 阻塞等待

java 复制代码
cf = sendAsync(req, responseHandler, null, null);
return cf.get();          // exchange 由另一个虚拟线程承担 → 它也需要载体

⑥ 锁由 Caffeine 引入,业务代码未显式加锁

⑦ 铁证:mapping function 在 synchronized 块内执行

JDK 源码 ConcurrentHashMap.compute:

java 复制代码
1909  else if ((f = tabAt(tab, i = (n - 1) & h)) == null) {
1910      Node<K,V> r = new ReservationNode<K,V>();
1911      synchronized (r) {                                        // 空桶分支
1916          if ((val = remappingFunction.apply(key, null)) != null) {   // loader 在此执行
1930  else {
1931      synchronized (f) {                                        // 占用桶分支
1940              val = remappingFunction.apply(key, e.val);         // loader 同样在此执行

两条分支均在 synchronized 内。 因此:

「换 key 避免哈希冲突即可规避」是错误 的 ------ 只要走 cache.get(k, loader),

loader 必然在某个 monitor 内执行。哈希冲突只会叠加第二类 pin(阻塞在 monitor entry)。

⑧ 触发阈值:29 > 20

调试器断点处 serviceIds.size = 29,与运行期 dump 中 ThreadPerTaskExecutor 的 29 个虚拟线程互相印证。

复制代码
预热并发数 = 29
载体线程数 = 20
29 > 20  →  跨过阈值 → 必然锁死

3.2 为什么 pin 会导致全局饿死

复制代码
虚拟线程 A 需要 HTTP 响应才能走出 synchronized
    ↑                                        ↓
载体线程不归还  ←  A 不走出 synchronized  ←  响应收不到  ←  exchange 虚拟线程拿不到载体
                                                        ←  载体被 A 占着(pin)

标准 wait-for 闭环。权责方向 :载体是被「锁内已存在的虚拟线程」占住的,

exchange 虚拟线程是受害者(被饿死者),不是元凶。

3.3 为什么是阈值效应(躲得过测试)

pin 只是「借了载体不归还」。借出数量 < 载体数时,系统完全正常。

实测(20 核,pin 住的虚拟线程 + loader 内再起虚拟线程 = Feign 的真实形态):

场景 提交 实际启动 载体数
普通 park(正常卸载) 60 60 6(空闲后收缩)
synchronized 内 park(pin) 60 20 20
60 个抢同一把 monitor(阻塞在 entry) 60 20 20
真实 Caffeine cache.get(k, loader),loader 内阻塞 60 15 进入 loader 20

结论:

  1. pin 不触发载体补偿扩容 ,maxPoolSize=256 不生效,天花板就是 parallelism
  2. 阻塞在 monitor entry 同样 pin ------ 不是只有「锁内 park」才 pin
  3. 真实 Caffeine 60 并发仅 15 个进入 loader,比理论值更差(哈希冲突叠加)

含义:同一份代码,预热 5 个服务永不触发,预热 25 个服务必然锁死。这就是它躲过所有单测和联调的原因。


4. 实测数据

4.1 运行期时间线(重启后自动采样,1.5s 间隔)

复制代码
11:58:27  旧进程退出
11:59:20  检测到新进程 PID=58620(启动耗时 53s)
11:59:21  carriers=20  carrying=20  conn18082=0    ← 首次采样即已全满
11:59:23  carriers=20  carrying=20  conn18082=0
11:59:25  carriers=20  carrying=20  conn18082=0
   ...    (连续 120 次采样,4 分 12 秒,全部为同一行)

120 次采样零方差 。从预热提交到 20 个载体全部 pin 满,耗时 < 1 秒 。

是阶跃函数,不是渐变 ------ 没有「逐渐恶化」的窗口可供观察,这也是它难以在测试阶段暴露的原因。

4.2 锁死瞬间的全量 dump 统计

复制代码
载体线程数                    = 20
虚拟线程数(有栈)            = 132
pin(栈含 parkOnCarrierThread)= 10
在 ConcurrentHashMap.compute  = 20   ← 10 个 @1916(锁内执行 loader)+ 10 个 @1932(阻塞在 monitor entry)
在 Feign 调用中               = 10
来自 SnapshotService 的       = 21   (20 个 load 任务 + warmUp 自身在外层 await)
到 18082 的 TCP 连接          = 0    (全程为 0)

其中 10 个「锁内执行 loader」的完整栈(自下往上):

复制代码
SnapshotService.lambda$warmUp$0:99
  → SnapshotService.load:65
    → LocalManualCache.get:63
      → BoundedLocalCache.doComputeIfAbsent:2765
        → ConcurrentHashMap.compute:1916            ← synchronized(r) 内
          → BoundedLocalCache.lambda$doComputeIfAbsent$0:2767
            → FmsPricingSnapshotLoader.load:155
              → serviceCalcConfFrom:211
                → LogisticsServiceBaseConfSource.load:32
                  → $Proxy208.getInfo               ← Feign
                    → GenesisJdkHttpClient.execute:109
                      → HttpClientImpl.send:934
                        → CompletableFuture.get
                          → LockSupport.park
                            → VirtualThread.park:607
                              → VirtualThread.parkOnCarrierThread:675   ← ★ pin
                                → Unsafe.park

另 10 个(compute:1932)栈顶无 park 帧 ------ 它们阻塞在 VM 的 monitor entry,

属于 2.3 节所述「无法用 parkOnCarrierThread 识别」的那一类。


5. 修复

5.1 根因修复:将阻塞 IO 移出 cache.get 的 mapping function

单文件改动 SnapshotService.java:

java 复制代码
/** 在途加载(单飞):同 key 并发仅首个线程执行 loader,其余等待同一结果 */
private final ConcurrentMap<String, CompletableFuture<PricingSnapshot>> inFlight = new ConcurrentHashMap<>();

/** 热路径:命中直接返回(0 IO);未命中单飞加载,同 key 并发仅构建一次 */
public PricingSnapshot load(String serviceId) {
    Cache<String, PricingSnapshot> current = cache;
    PricingSnapshot hit = current.getIfPresent(serviceId);
    if (hit != null) {
        return hit;
    }
    CompletableFuture<PricingSnapshot> mine = new CompletableFuture<>();
    // 必须用 putIfAbsent:它不执行用户函数;computeIfAbsent 会在 synchronized 内执行 mapping function(同一个坑)
    CompletableFuture<PricingSnapshot> running = inFlight.putIfAbsent(serviceId, mine);
    if (running != null) {
        return awaitSameKey(running);
    }
    try {
        PricingSnapshot loaded = loader.load(serviceId);   // IO 在锁外,不 pin 载体
        if (loaded != null) {
            current.put(serviceId, loaded);
        }
        mine.complete(loaded);
        return loaded;
    } catch (RuntimeException | Error e) {
        mine.completeExceptionally(e);
        throw e;
    } finally {
        inFlight.remove(serviceId, mine);
    }
}

/** 等待同 key 在途加载;失败时抛原始异常(不向调用方暴露 CompletionException 包装) */
private static PricingSnapshot awaitSameKey(CompletableFuture<PricingSnapshot> future) {
    try {
        return future.join();
    } catch (CompletionException e) {
        if (e.getCause() instanceof RuntimeException cause) {
            throw cause;
        }
        throw e;
    }
}

语义保持 :同 key 并发只构建一次;loader 返回 null 不缓存;异常不缓存且原类型透出。

行为变化:单飞由 Caffeine 内部改为显式实现,IO 不再处于任何 monitor 内。

改完必须重启 fms ------ pin 状态不可自愈。

5.2 验证

本地回归测试(src/test/,已在 .git/info/exclude 中,不入库):

用例 新实现 旧实现
6 个用例 6/6 通过,0.545s ---
concurrentLoadsDoNotStarveCarriers(并发 = 核数+4,loader 内再起虚拟线程) 通过 挂死,150s 被强杀 (The forked VM terminated without properly saying goodbye)

编译:mvn -f genesis-fms/pom.xml -pl genesis-fms-biz -am -DskipTests -o compile → BUILD SUCCESS


6. 规避规范

6.1 硬性规则

  1. 锁内禁止阻塞 IO :synchronized / ReentrantLock / compute* 的 mapping function / Object.wait 前后,
    禁止 Feign、DB、Redis、文件、Thread.sleep、Future.get。
  2. 缓存回源一律「getIfPresent → 锁外加载 → put」 ;需单飞用 putIfAbsent + CompletableFuture,
    禁止 computeIfAbsent (同样在 synchronized 内执行 mapping function)。
  3. 虚拟线程场景下避免用 synchronized 做并发控制 ,优先 ReentrantLock(VT-aware,不 pin)。
  4. 预热/批量任务的并发度不要等于数据条数,应分批或串行,或确认加载路径无 pin 点。

6.2 危险 / 安全写法对照

java 复制代码
// ✗ mapping function 在 synchronized 内执行
cache.get(key, this::loadFromRemote);
cache.get(key, k -> feignClient.getInfo(k));
map.computeIfAbsent(key, k -> jdbcTemplate.queryForObject(...));
map.compute(key, (k, v) -> restTemplate.getForObject(...));

// ✗ 显式锁内做 IO
synchronized (lock) { feignClient.call(); }
synchronized (lock) { Thread.sleep(100); }

// ✓ 单飞 + IO 在锁外(完整实现见 §5.1)
CompletableFuture<V> running = inFlight.putIfAbsent(key, mine);

// ✓ 不需要单飞时
V hit = cache.getIfPresent(key);
if (hit != null) return hit;
V loaded = loader.load(key);            // 锁外
if (loaded != null) cache.put(key, loaded);
return loaded;

6.3 自查命令

bash 复制代码
# 危险 1:带 lambda / 方法引用的 cache.get
grep -rn --include=*.java -E "\.get\([^)]*,\s*[a-zA-Z0-9_.:]+\)" | grep -v "getProperty\|getBean"

# 危险 2:compute / computeIfAbsent / merge
grep -rn --include=*.java -E "\.(compute|computeIfAbsent|merge)\("

# 危险 3:synchronized 块内出现远程调用关键字
grep -rn --include=*.java -A15 "synchronized" | grep -E "FeignClient|jdbc|Redis|\.get\(|\.query\(|\.send\("

6.4 检测手段

时机 手段 说明
开发期 -Djdk.tracePinnedThreads=full pin 发生时直接打印栈,最直接
开发期 JFR 事件 jdk.VirtualThreadPinned 统计 pin 次数与位置
线上 jcmd <pid> Thread.dump_to_file -format=json <file> jstack / Thread.print 不输出虚拟线程栈,必须用此命令 + json 格式
线上 搜 VirtualThread.parkOnCarrierThread 命中 = pin;仅覆盖「Java 层 park 型」,会漏掉 monitor entry 型
线上 数 ForkJoinPool-1-worker-* 个数 = 载体数 = availableProcessors();业务空闲时若仍「全部 Carrying virtual thread」→ 已 pin 满
线上 `netstat -ano findstr `
线上 `jcmd VM.system_properties grep virtualThread`

6.5 兜底手段(治标)

手段 效果 代价
genesis.feign.executor-type: PLATFORM Feign 改用 ThreadPoolExecutor,绕开载体 放弃虚拟线程收益;仅使 Feign 不随之死,业务侧 pin 仍在
-Djdk.virtualThreadScheduler.parallelism=256 抬高天花板 掩盖问题;核数不足时只是把阈值后移
升级 JDK 24+(JEP 491) 根治:monitor 不再绑定载体线程,持锁也可卸载 需整体升级验证

6.6 Code Review 检查项

  • 新增的 cache.get(k, fn) / computeIfAbsent(k, fn),fn 内是否有远程调用或 DB?
  • synchronized 块内是否有 Feign / DB / Redis / sleep / Future.get?
  • 启动预热、定时批量任务,并发度是否可能 ≥ availableProcessors()?
  • 新增的虚拟线程执行器是否与 Feign 共用默认调度器?(默认即共用)
  • 新增的懒加载/单飞逻辑是否使用 computeIfAbsent?(应改 putIfAbsent)

7. 附录

7.1 三个数量概念

上限 本机
平台线程 (java.lang.Thread) 无上限,系统内数千 数千
硬件线程 / 逻辑处理器 硬件固定 20
载体线程 JVM 按 availableProcessors() 配置 20

CPU:i5-14500HX,物理核心 14(6 P-core + 8 E-core),逻辑处理器 20(6×2 HT + 8)。

7.2 虚拟线程调度器默认参数

系统属性 默认值
jdk.virtualThreadScheduler.parallelism availableProcessors()
jdk.virtualThreadScheduler.maxPoolSize max(parallelism, 256)(pin 时不触发扩容)
jdk.virtualThreadScheduler.minRunnable max(parallelism/2, 1)

该调度器进程级共享。

7.3 本次事故关键数字

数字 含义
29 预热并发服务数(触发阈值)
20 availableProcessors() = 载体线程数
< 1 秒 从预热提交到全部 pin 满
120 / 4分12秒 采样次数 / 时长,零方差
0.13 秒 dms 直连响应时间(下游无辜)
0 fms → dms 的 TCP 连接数(请求未出网卡)

7.4 一页速查

复制代码
虚拟线程数      = 无上限(每任务一个)
载体线程数      = availableProcessors()(默认,进程级共享)
pin 触发        = synchronized 内阻塞 / monitor entry 阻塞 / native 帧 / Object.wait
pin 判定        = 栈含 VirtualThread.parkOnCarrierThread(仅覆盖 Java 层 park 型)
pin 后果        = 载体不归还 → 借满核数 → 全进程虚拟线程饿死(含 Feign exchange)
                 → 请求不出网卡 → readTimeout 失效 → 永久阻塞
阈值            = pin 数 ≥ availableProcessors()(未跨阈值时完全正常)
时间尺度        = < 1 秒锁死,之后永久恒定
修复            = 锁内不做阻塞 IO;单飞用 putIfAbsent 而非 computeIfAbsent
根治            = JDK 24+ / JEP 491

相关推荐
m0_587383001 小时前
无人自助拼豆系统从需求分析到落地部署全流程实战开发指南
java·经验分享·后端·struts·小程序·架构·需求分析
a努力。1 小时前
四维解析Agent记忆架构:从零实战LangGraph长期记忆
java·开发语言·数据库
碧海银沙音频科技研究院1 小时前
炬芯ATS308X的扩音器:麦克风到扬声器直通功能实现
java·linux·运维
Rain的Java大神之路1 小时前
被问麻了!Function Calling、MCP、Skill 到底啥关系?一张图讲透三层架构,附 Java 生产落地全流程+30个坑
java·后端·ai编程
Wang's Blog1 小时前
Java 项目部署之 Docker工具快速入门: Docker Compose 初识与安装
java·开发语言·docker
niucloud-admin2 小时前
JAVA V6 多商户商城 开发文档——项目启动
java·开发语言·python
众链网络2 小时前
Agent 重试的工程解法:幂等、断点续跑与熔断怎么落地
java·前端·数据库·人工智能
Wang's Blog2 小时前
Java 项目部署之 Docker工具快速入门: 镜像命令从 pull 到 load 的完整实操
java·开发语言·docker
Wang's Blog2 小时前
Java 项目部署之 Docker工具快速入门: Docker 数据卷命令与挂载实操
java·开发语言·docker