27 万个虚拟线程,把 4 核 8G 的容器拖死了
12 月 6 号凌晨,告警电话把我叫醒。AI 批处理服务的响应时间从正常的 300 毫秒涨到 30 秒以上,CPU 100%,但业务吞吐几乎归零。
# 监控截图里的数据
jvm_threads_live 276341 # 平时 800 左右
process_cpu_usage 0.998
jvm_memory_used_after_gc{area="heap"} 7.82 GB # 上限 8 GB
http_server_requests_p99 30.1 s
llm_batch_processed_total 12/min # 平时 900/min
这个服务 10 月刚迁到 JDK 21,所有阻塞调用都换成了虚拟线程。出问题前跑了两个月都很稳,这次是第一次翻车。
排查:先拿到线程快照
虚拟线程数量太大,jstack 打出来的文件有 400 多 MB,根本没法看。我用 JDK 21 新增的 JSON 格式 dump:
$ jcmd 1 Thread.dump_to_file -format=json -overwrite /tmp/threads.json
$ jq -r '.threadDump.threadContainers.monitors | length' /tmp/threads.json
$ jq '[.threadDump.threads[] | select(.isVirtual == true)] | length' /tmp/threads.json
276128
27.6 万个虚拟线程,其中 27.4 万个处于 RUNNABLE 但堆栈停在同一个地方。抽 20 个看:
$ jq -r '[.threadDump.threads[]
| select(.isVirtual == true)
| .stack[0] ] | group_by(.) | sort_by(-length) | .[0:5]
| .[] | "\(length) \(.[0])"' /tmp/threads.json
271402 "app//RateLimiter.acquire(java.base@21/RateLimiter.java:89)"
3011 "app//LlmClient.call(java.base@21/HttpClient.java:412)"
892 "java.base/java.lang.VirtualThread.park(...)"
341 "app//ResultBuffer.append(...)"
27.1 万个虚拟线程堵在 RateLimiter.acquire() 上。这个方法是我们自己写的限流器,核心就是一个 synchronized 块。
根因:三个错误叠加
错误一:无界创建虚拟线程
出问题的代码是这样的,看着很"现代":
@Scheduled(fixedDelay = 1000)
public void pollAndProcess() {
List<Task> tasks = queue.poll(5000); // 一次拉 5000 条
for (Task t : tasks) {
executor.submit(() -> { // 一个任务一个虚拟线程
llmClient.call(t).thenAccept(this::save);
});
}
}
// executor 的定义
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
问题在于调度频率是每秒一次,而单任务耗时平均 1.8 秒(LLM 调用)。拉任务的速度远快于处理速度,积压无限增长,虚拟线程数也就无限增长。
以前用固定线程池的时候,队列满了会拒绝或者阻塞,天然有背压。换成 newVirtualThreadPerTaskExecutor() 之后,这个背压消失了。虚拟线程便宜,但不是免费的。
错误二:synchronized 导致 pinning,调度器被榨干
如果仅仅是线程多,还不至于把 CPU 打到 100% 且吞吐归零。真正的杀手是这个限流器:
public class RateLimiter {
private final Semaphore permits = new Semaphore(200);
public synchronized boolean acquire() { // ← 问题在这
if (permits.tryAcquire()) {
return true;
}
LockSupport.parkNanos(10_000_000); // 等 10ms 重试
return false;
}
}
JDK 21 里,虚拟线程进入 synchronized 块会被钉在载体线程(carrier thread)上。如果在这个块里发生阻塞(这里是 parkNanos),载体线程就被这个虚拟线程独占,不能去执行别的虚拟线程。
我们的容器是 4 核,JDK 21 的虚拟线程调度器默认并行度等于 CPU 核数,也就是只有 4 个载体线程。4 个载体线程一旦全被 pin 住,整个进程的所有虚拟线程全部停摆,剩下 23 万个虚拟线程排队等着,CPU 却用在反复 park/unpark 的空转上。
用 JFR 验证:
$ java -XX:StartFlightRecording:filename=vt.jfr,settings=profile,duration=120s -jar app.jar
$ jfr summary vt.jfr | grep -iE "pinned|VirtualThread"
jdk.VirtualThreadPinned 1,842,203 events
jdk.VirtualThreadStart 276,341 events
jdk.VirtualThreadEnd 1,207 events
jdk.VirtualThreadSubmitFailed 0 events
184 万次 pin,启动 27.6 万个虚拟线程但只有 1207 个跑完了。这就是典型的调度器饱和。
错误三:每个虚拟线程都攥着一块堆内存
虚拟线程的栈是存在堆上的,会按需增长。更严重的是我们的任务对象引用链太长:每个任务持有一个 ByteArrayOutputStream 缓冲 LLM 的完整响应,平均 42 KB。
27.6 万 × (线程栈约 20 KB + 响应缓冲 42 KB + 任务对象 8 KB)
≈ 27.6 万 × 70 KB
≈ 19.3 GB
远超 8 GB 堆上限。实际上 JVM 一直在 Full GC,但这些都是强引用,GC 什么都回收不了,最后就是 GC overhead limit exceeded 和 OOMKill 循环。
修复:四步走
第一步,先恢复服务——把限流器的 synchronized 拿掉
这是止血最快的动作,改一行:
public class RateLimiter {
private final Semaphore permits = new Semaphore(200);
public boolean acquire() throws InterruptedException {
permits.acquire(); // 用 Semaphore 自己的阻塞,不占载体线程
return true;
}
public void release() {
permits.release();
}
}
Semaphore.acquire() 在虚拟线程里是协作式挂起,虚拟线程会让出载体线程,不会 pin。上线后 CPU 从 100% 降到 34%,但吞吐还是上不去,因为线程数依然在涨。
顺便把代码里所有 synchronized 过了一遍,凡是块里有 IO、sleep、锁等待的,全换成 ReentrantLock:
$ grep -rn "synchronized" src/main/java --include=*.java | wc -l
23
# 逐个确认后改了 7 处,剩余 16 处是纯内存操作,无阻塞,保留
第二步,给虚拟线程加上界的背压
虚拟线程不该被"池化",但并发度必须有上限。正确做法是用信号量控制并发数,而不是限制线程数:
@Component
public class LlmBatchExecutor {
// 并发上限:下游 LLM 供应商限流 2000 RPM,留 30% 余量
private final Semaphore inflight = new Semaphore(1200);
private final ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor();
public void submit(Task t) {
if (!inflight.tryAcquire()) {
// 拿不到就退回队列,不无限创建
queue.requeue(t);
Metrics.counter("batch.backpressure").increment();
return;
}
vt.submit(() -> {
try {
llmClient.call(t).thenAccept(this::save);
} finally {
inflight.release();
}
});
}
}
这里的关键点是在提交前获取信号量,而不是在任务里获取。如果写在任务体里,虚拟线程已经创建好了,等于没做限制。
第三步,调整调度器参数(作为兜底,不是主要手段)
-Djdk.virtualThreadScheduler.parallelism=8
-Djdk.virtualThreadScheduler.maxPoolSize=32
-Djdk.virtualThreadScheduler.minRunnable=2
parallelism:载体线程目标数量,默认等于 CPU 核数。我们有少量 pin 无法避免(第三方库里的),调大一点留余量。maxPoolSize:载体线程上限,默认 256。minRunnable:保证至少有几个载体线程可运行,防止全部被 pin 住时彻底停摆。
强调一下:这只是兜底,不是解决方案。如果代码里到处是 pinning,把 parallelism 调到 100 也只能延缓崩溃,还会引入载体线程上下文切换的开销。真正的解法是消除 pinning。
第四步,改成结构化并发,让生命周期可控
原来的代码里,任务提交出去就不管了,异常、超时、取消全靠天。我们改成了 JDK 21 的结构化并发(预览 API,需要 --enable-preview,我们只在批处理这个非核心服务上用):
try (var scope = StructuredTaskScope.open(
Joiner.awaitAllSuccessfulOrThrow())) {
List<Subtask<Result>> subtasks = tasks.stream()
.map(t -> scope.fork(() -> llmClient.call(t)))
.toList();
scope.joinUntil(Instant.now().plusSeconds(30)); // 整体超时
for (Subtask<Result> s : subtasks) {
if (s.state() == Subtask.State.SUCCESS) {
save(s.get());
} else {
log.warn("task failed", s.exception()); // 失败的单条不影响整体
}
}
}
// scope 关闭时,所有子任务必然已结束,不会有孤儿线程
结构化并发最大的价值不是代码好看,是它保证了子任务的生命周期不会超出作用域。以前那种"submit 出去就忘了"的写法,出问题时你根本不知道有多少任务在飞。
顺带修掉的 ThreadLocal 问题
排查时还发现一个隐患:我们在拦截器里用 ThreadLocal 存 traceId。虚拟线程也支持 ThreadLocal,但问题是——27 万个虚拟线程各自一份 ThreadLocalMap,内存放大非常可观,而且虚拟线程是短生命周期的,这些 ThreadLocal 根本起不到复用作用。
改成 ScopedValue(JDK 21 预览)在核心链路,其他场景改成显式传参:
private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
ScopedValue.where(TRACE_ID, traceId).run(() -> {
handle(request); // 子虚拟线程也能读到,不需要拷贝
});
ScopedValue 是不可变的,且能被子虚拟线程继承而不需要拷贝,比 InheritableThreadLocal 在虚拟线程场景下的开销小得多。
修复后的数据
| 指标 | 事故时 | 止血后 | 完整修复后 |
|---|---|---|---|
| 存活虚拟线程数 | 276341 | 18920 | 1240 |
| CPU 使用率 | 99.8% | 34% | 46% |
| 堆内存占用 | 7.82 GB | 3.1 GB | 1.4 GB |
| pin 事件数(2 min) | 184 万 | 0 | 0 |
| 批处理吞吐 | 12/分钟 | 680/分钟 | 1840/分钟 |
| P99 延迟 | 30.1 s | 1.9 s | 420 ms |
吞吐比事故前(900/分钟)还高了一倍,因为 Pinning 消除后 4 个载体线程能跑满,且并发上限提到了 1200。
怎么在事故前发现 pinning
这次事故之前,其实有信号。我们后来翻 JFR 历史记录,发现 pin 事件数在事故前两周就已经在缓慢上升了,只是没人看这个指标。补了三件事。
一、开发期常开 tracePinnedThread
# full 会打印完整堆栈,short 只打一行
-Djdk.tracePinnedThread=full
这个参数在 pin 发生时往标准错误打堆栈,性能损耗大,只在开发和预发开。我们开了两周,抓出 11 处 pinning,其中 4 处是第三方库里的(一个老版本的 JSON 库在解析时用了 synchronized)。
二、预发环境做 pinning 冒烟
在预发的压测脚本里加了一个断言:跑 5 分钟,JFR 里 jdk.VirtualThreadPinned 事件数必须为 0。这个检查放在发布流水线里,不为 0 就阻断发布。
#!/bin/bash
# ci/check-pinning.sh
java -XX:StartFlightRecording:filename=smoke.jfr,settings=profile,duration=300s -jar app.jar &
PID=$!
./scripts/load-test.sh --qps 500 --duration 300s
wait $PID
N=$(jfr print --events jdk.VirtualThreadPinned smoke.jfr | grep -c "jdk.VirtualThreadPinned")
if [ "$N" -gt 0 ]; then
echo "FAIL: 检测到 $N 次虚拟线程 pinning"
jfr print --events jdk.VirtualThreadPinned smoke.jfr | grep -A5 "Stack Trace" | head -40
exit 1
fi
三、生产环境的常态监控
用 JFR 的事件流(JEP 349,JDK 14 就有)做实时监控,不用落盘再分析:
try (var rs = new RecordingStream()) {
rs.enable("jdk.VirtualThreadPinned").withStackTrace();
rs.enable("jdk.VirtualThreadStart");
rs.enable("jdk.VirtualThreadEnd");
rs.onEvent("jdk.VirtualThreadPinned", e -> {
String top = e.getStackTrace().getFrames().get(0).toString();
Metrics.counter("vt.pinned", "frame", top).increment();
});
rs.start();
}
配上两个告警:
# 活跃虚拟线程数(start - end 的差值),超过 5 万告警
jvm_vt_active > 50000
# pin 速率,5 分钟内超过 100 次告警
rate(vt_pinned_total[5m]) > 0.33
这两个指标上线后,我们在两个月内提前发现了三次问题:一次是某个三方 SDK 升级后引入了 synchronized,一次是有人新写的代码里用了 Collections.synchronizedMap,还有一次是连接池配置被误改小了导致虚拟线程堆积。
我现在对虚拟线程的六条使用纪律
- 并发度必须显式设限。
newVirtualThreadPerTaskExecutor()没有队列,没有上限,它会把背压问题从"线程耗尽"变成"内存耗尽",后者的故障表现更难排查。用Semaphore在提交前限流。 synchronized块里绝对不能有阻塞操作。JDK 21 会 pin,JDK 23 的 JEP 491 才解决(目前还是预览)。项目里用-Djdk.tracePinnedThread=full可以在 pin 发生时打印堆栈,建议开发期常开。- 不要在虚拟线程里用 ThreadLocal 存大对象。虚拟线程数量级是十万级的,ThreadLocal 的内存放大效应会被放大同样倍数。
- 调整调度器参数只是兜底。
parallelism调大能缓解 pinning 的影响,但掩盖问题,还会带来额外的上下文切换。 - 优先用结构化并发管理生命周期。
StructuredTaskScope保证退出作用域时所有子任务结束,不会出现孤儿任务。 - 虚拟线程适合 IO 密集,不适合 CPU 密集。我们的 PDF 解析服务试过用虚拟线程并行解析,结果 CPU 密集的解析任务把 4 个载体线程全占满,比固定线程池还慢 15%。
# 建议开发期常开的参数
-Djdk.tracePinnedThread=full
# 生产期用 JFR 监控
-XX:StartFlightRecording:filename=/var/log/vt.jfr,settings=profile,maxage=1h
留个问题
关于《一次线程数暴涨的排查:虚拟线程不是万能的》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。