作者:谢望舒-838|中间件研发,5 年 栈:JDK 17.0.5(升级前)/ 17.0.11(升级后)、Spring Boot 3.1.5、Kubernetes 1.28.4、节点 32C128G(cgroup v2)、被迁移服务 2C/4Gi limit 结论先行:同一份 Java 代码,从裸机迁到 K8s 后吞吐腰斩、P99 翻 4 倍,根因是
parallelStream背后的ForkJoinPool.commonPool按「宿主机 32 核」开了 32 个工作线程,而 cgroup 只给了 2C 的 CPU 配额——16:1 的过订阅把上下文切换成本全部摊到了延迟上。修复不是调线程数,而是让 JDK「看见」容器配额,或干脆消灭这条并行路径。
一、现象与背景:迁移当天全绿,一周后才爆
- 服务:一个报表聚合服务(Spring Boot 3.1.5 / JDK 17.0.5),单实例从裸机 8C16G 迁入 K8s,平台统一规格 2C/4Gi(
cpu.max: "200000 100000",cgroup v2)。 - 代码里有一处历史遗留:把 128 个上游字段的逐项转换写成
fields.parallelStream().map(transform).collect(...),单次调用约 40ms(裸机上)。 - 迁移后压测(JMeter 200 并发):
- 裸机基线:QPS 310,P50 61ms,P99 182ms;
- K8s 首次:QPS 148(-52%),P50 97ms,P99 741ms(约 4 倍);
- 迷惑点:CPU 监控「只」用了 1.9C(贴着 limit),GC 无毛刺,下游 RT 正常,JVM 各项都「健康」。
- 更迷惑的是:低并发(≤20)时 P99 只有 95ms,几乎正常——问题只在高并发下出现,导致前两次排查(查网络、查下游)都扑空。
二、根因:JDK 默认并行度看的是「核数」,cgroup 限的是「时间片」
机制拆开讲,两层错位:
Runtime.availableProcessors()到底返回几?- JDK 10 起(JDK-8146112)在容器里会感知 cgroup:cgroup v1 读
cpu.cfs_quota_us / cpu.cfs_period_us,cgroup v2 读cpu.max。 - 但关键分支是:只有 cgroup v2 的配额感知在 JDK 17 才补齐。我们节点是 cgroup v2,JDK 17.0.5 这条路径正常,返回 2——所以「JDK 不认识容器」这个最常见锅先排除掉。
- 真正的问题是:
ForkJoinPool.commonPool的并行度默认是Math.max(2, availableProcessors() - 1),它只在池创建时算一次。而我们的 JVM 参数里有一条祖传的-XX:+UseContainerSupport旁边的「帮凶」:-XX:ActiveProcessorCount=32(当年为裸机 32C 调 GC 线程加的,迁移时没人删)。它同时覆盖了availableProcessors(),于是 commonPool 按 31~32 开 worker。 - 验证命令(容器内):
cat /sys/fs/cgroup/cpu.max # 200000 100000 → 2C java -XX:ActiveProcessorCount=32 -XshowSettings:vm -version 2>&1 | grep -i processor # 输出: -XX:ActiveProcessorCount=32 ← 确认覆盖了 cgroup 感知
- JDK 10 起(JDK-8146112)在容器里会感知 cgroup:cgroup v1 读
- 为什么 16:1 过订阅这么贵?
- 32 个 worker 抢 2 个 CPU 时间片,每次
parallelStream还会触发工作窃取(work-stealing)的 CAS 自旋——自旋不干活也在烧配额。 - 用
pidstat -t -u 2 <pid>抓了 10 秒:高峰期进程内 Runnable + 自旋线程数均值 27,每线程实际 CPU 占比 6%~9%;vmstat 1的cs(context switch)从基线 ~800/s 涨到 ~9400/s,runq 长期 15+。 - 这正好解释了「低并发正常、高并发爆」:并发低时 32 个 worker 大多在睡,过订阅无感;并发一上来,27 个线程抢 2 个核,调度延迟直接进 P99。
- 32 个 worker 抢 2 个 CPU 时间片,每次
一句话总结:cgroup 限的是 CPU 时间,不限线程数;JDK 按核数开线程时,如果「核数」来自一条过期的 JVM 参数,过订阅就是必然的。(这个「JDK 默认值 × 容器限额」的错位模式,站里有人从 GC 线程角度写过,本质同源。)
三、具体做法:三层修复,成本从低到高
第 1 步:删掉过期参数,让 JDK 感知配额(10 分钟,收益 80%)
# 从启动脚本 / Helm values 里删掉:
-XX:ActiveProcessorCount=32
# 确认 commonPool 并行度回到 1(max(2, 2-1)=2,实际观察为 1~2)
jcmd <pid> VM.flags | grep -i ActiveProcessorCount # 应为空
复测(200 并发):QPS 148 → 289,P99 741ms → 203ms,context switch 回落到 ~1100/s。基本追平裸机基线(裸机 8C 本来就该比 2C 强,这里追平即说明过订阅损耗已消除)。
第 2 步:给 commonPool 显式兜底,防「未来谁再把参数加回来」
-Djava.util.concurrent.ForkJoinPool.common.parallelism=2 # = 容器 CPU limit
注意这个属性只在 commonPool 首次创建时读一次,必须在第一个 parallelStream 触发建池前设置(JVM 参数最稳)。
第 3 步:消灭这条并行路径(治本,我们最终选了它)
parallelStream 对这种「逐项独立 map、无短路、元素 128 个」的场景,收益本来就是负的(分块、任务分发、工作窃取的固定开销 > 并行收益,128 个元素连 ForkJoinPool 的默认分块阈值都不一定划算)。我们改成:
// Before:伪并行,实际是过订阅的放大器
List<Field> out = fields.parallelStream().map(transform).collect(toList());
// After:单线程顺序执行,128 项实测 11ms,比「并行」还快
List<Field> out = fields.stream().map(transform).collect(toList());
改完 JFR(-XX:StartFlightRecording)里 jdk.ForkJoinPoolTask 相关事件直接归零。
配套监控(建议所有 K8s Java 服务加):
# 1) 进程内 runnable 线程 vs CPU limit 的比值,>3 告警(过订阅信号)
# 用 JMX thread.runnable / 容器 cpu.max 配额
# 2) 上下文切换率
rate(process_context_switch_total[5m]) # 需要 -XX:+EnableDynamicAgentLoading 或 OS 层
# 3) JVM 实际看到的核数(自定义 MBean 暴露 Runtime.availableProcessors())
四、踩过的坑与反直觉之处
- 「JDK 不认识容器」是伪命题:JDK 17 + cgroup v2 的
cpu.max感知是好的,我们的坑来自一条人为的ActiveProcessorCount覆盖。排查时先cat /sys/fs/cgroup/cpu.max和jcmd VM.flags对账,别急着怀疑 JDK。 common.parallelism设晚了没用:它在 commonPool 懒加载时读取一次,运行中改系统属性、或用反射改字段都不生效(JDK 内部类字段,改了也是未定义行为)。- CPU 打满 ≠ 忙得有效率:监控里 CPU 1.9C「很健康」,但 16:1 过订阅下有效吞吐只有配额的一半。看
cs和 runq 比看 CPU 利用率诚实。 - 低并发压测会放过这类问题:过订阅在低并发下线程大多睡眠,必须用目标并发(200+)压才能复现。我们前两轮 20 并发压测全绿,差点带病上线。
parallelStream本身没有背压:它不像业务线程池能拒绝任务,过订阅时所有代价都转嫁成延迟,且共享池还会污染同 JVM 里其他parallelStream调用方——这是它「看着优雅、出事连坐」的结构性原因。
五、结论与可复用清单
核心结论:容器化 Java 服务的「核数」有三个来源——物理机、cgroup 配额、JVM 参数覆盖,三者不一致时,按核数派生的线程池(commonPool、GC 线程、并行流)全部错位。治理原则:删除一切 ActiveProcessorCount 类硬编码,让 JDK 以 cgroup 为准;并行度敏感组件显式设值;对收益存疑的 parallelStream 直接改回顺序流。
清单(下次遇到「迁移 K8s 后 P99 劣化、CPU 贴 limit 但吞吐不涨」):
cat /sys/fs/cgroup/cpu.max与jcmd <pid> VM.flags | grep -i processor对账,确认 JVM 看到的核数 == 配额;- 查启动参数里的
ActiveProcessorCount/parallelism/ 按核数算的线程池大小,逐项改为配额值或显式常量; pidstat -t -u+vmstat看 runnable 线程数与 context switch,比值 >3 即过订阅;- 压测并发必须打到目标并发,20 并发复现不了调度类问题;
- 对
parallelStream/IntStream.range(...).parallel()做一次「删掉 parallel 前后对比」,128 元素级别几乎总是更快(本次实测顺序 11ms < 并行 40ms); - 把
Runtime.availableProcessors()暴露成监控指标,和 CPU 配额做比值告警。