结论先行:容器里 Java 服务「CPU 监控很绿、P99 偶发飙高、liveness 偶发被杀」,第一嫌疑不是 GC 本身,而是 JDK 的 CPU 预算假设(按宿主核数派生 GC/JIT 线程)和内核 CFS 的实际配额(cpu.max)错开了一个数量级——GC 并发线程一起挤进同一个配额池,CFS 周期性硬切把一次 180ms 的 GC 拉成 2.2s。这篇文章给一套 5 分钟能跑完的「双预算错位」排查 SOP,以及一份可抄的 JVM 参数清单。
一、现象:CPU 55%~70%,Pod 却每 20 分钟被杀一次
环境交代清楚,方便你对号入座:
- 宿主机 48C192G(云厂商通用型),K8s 1.28.9 + containerd 1.7.13,cgroup v2
- 应用:Java 17.0.11(G1GC),Spring Boot 3.2,
-Xmx4g,未设-XX:ActiveProcessorCount、未显式设ParallelGCThreads - 容器资源:
request=2C / limit=6C(团队里「6C 已经算给足」) - 现象:
kubectl describe pod每 18~25 分钟出现一次Killing container ... failed liveness probe,重启 30~45s;CPU 监控(container_cpu_usage_seconds_total差分)长期在 55%~70%,cpu.stat的nr_throttled在抖的时候确实涨了,但没人把它和 GC 停顿连起来看
最反直觉的一组数字:
gc.log里-Xlog:gc*,gc+heap=debug显示:抖动前 GC max pause = 182ms,抖动期间 max pause = 2210ms,且每次抖动都是 Young GC(不是 Full GC)- 抖动期间
cpu.stat的nr_throttled一次涨 38,throttled_usec一次涨 3.2s(平均一次节流 84ms) - 抖动期间
node_exporter的node_load1没动(其他 Pod 的 CPU 完全正常) - 探针配置:
timeoutSeconds: 3, failureThreshold: 3——2.2s 的 GC 停顿 + HTTP handler 一起被拖住,轻松超过 3s
二、根因:JDK 按「宿主核数」派生线程,CFS 按「配额」切时间,两个预算互相不知道对方
拆开看,这里有两个独立的预算,它们各算各的:
- 线程数预算(JDK 侧):JDK 10+ 在容器里默认读 cgroup 的
cpu.max作为Runtime.availableProcessors(),但——如果 JVM 启动时 cgroup 还没绑好(sidecar 拉起顺序、或宿主机的cpu.max是max),或者你用了--add-opens/ 自定义ProcessHandle的路径,JDK 会回退到读宿主机核数。我们这台 48C 宿主,JVM 启动日志里VM: ParallelGCThreads=12, ConcGCThreads=3——12 个 GC 线程是按 48 核派生的,不是按 6C 派生的。 - CPU 时间配额(内核 CFS 侧):
cpu.max = "600000 100000",意思是每 100ms 周期里,这个 cgroup 最多用 600ms CPU 时间。12 个 GC 线程一起跑,瞬时能要 12 核,CFS 直接切,切掉的每一刀都是 80~120ms 量级(一次 CFS period 100ms)。
两者叠加的结果:一次 Young GC 本来 12 个线程并行 180ms 能跑完,现在被 CFS 切成 38 段,每段都要等下一个 100ms period 才能继续,180ms × (1 + 38 × 0.84/100) ≈ 2.2s——和 gc.log 实测完全对得上。
更坑的是:CFS 节流对同 cgroup 里的所有线程生效,所以探针 HTTP handler 的线程也在被切,「GC 慢 + 探针慢」是同一个根因的两个症状,不是两件事。
三、做法:5 分钟「双预算错位」排查 SOP
第 1 步:确认是不是 CFS 节流(1 分钟)
# 进容器,看 cpu.stat 的节流计数
cat /sys/fs/cgroup/cpu.stat
# 关注 nr_throttled / nr_periods / throttled_usec
# 抖动前后各采一次,diff 一下:
# nr_throttled 涨了、且单次 throttled_usec / 单次 nr_throttled > 50ms
# → 基本可以锁定 CFS 节流
第 2 步:确认 JVM 看到的核数(1 分钟)
# 看 JVM 启动日志或 jinfo
jcmd <pid> VM.flags | grep -E 'ActiveProcessorCount|ParallelGCThreads|ConcGCThreads'
# 或者更直接:
jcmd <pid> VM.system_properties | grep -i processors
# 如果 ParallelGCThreads 明显 > limit 核数的 1.5 倍 → 双预算错位
我们这次:ParallelGCThreads=12,limit=6C,比值 2.0,超标。
第 3 步:把 GC 停顿和 CFS 节流对齐(2 分钟)
# gc.log 里抓 max pause 和对应的 wall-clock 时间戳
# 再对 cpu.stat 的 throttled_usec 增量
# 如果「max pause 尖刺」和「throttled_usec 尖刺」在时间轴上对齐 → 锁定
第 4 步:修(三选一,按侵入性从低到高)
# 方案 A:显式告诉 JVM「你只有 6 核」(最小侵入,首选)
-XX:ActiveProcessorCount=5
# 5 = 6C × 85%,留 15% 给 JIT / 探针 / 其他线程
# 生效后 ParallelGCThreads 会按 5 核派生 → 4,ConcGCThreads → 1
# 方案 B:直接锁 GC 线程数(如果 ActiveProcessorCount 不生效)
-XX:ParallelGCThreads=4 -XX:ConcGCThreads=1
# 方案 C:内核侧开「透支」(治标,不改线程数)
# 需要宿主内核 5.14+,且容器 runtime 支持
echo 1000000 > /sys/fs/cgroup/.../cpu.cfs_burst_us
# 1s 的透支额度,让 GC 突发能「向未来借 CPU」
# 注意:这只缓解节流,不解决「12 个线程挤 6C 配额」的根因
第 5 步:验证(1 分钟)
# 压测到平时 1.5 倍流量,观察:
# - cpu.stat 的 nr_throttled 增量 → 应该接近 0
# - gc.log 的 max pause → 应该回到 200ms 以内
# - liveness 探针成功率 → 应该 100%
四、踩过的坑(反直觉清单)
- 抬 limit 是假解法。我们把 limit 从 6C 抬到 12C,抖动确实少了——但
ParallelGCThreads还是 12(按宿主 48 核派生的),一旦宿主机其他 Pod 抢 CPU,抖动照样回来。治本必须改 JVM 侧的线程派生,不是抬配额。 -XX:ActiveProcessorCount不改变 CFS 配额。它只改 JDK 的线程派生假设,内核的cpu.max还是 6C。所以「设了 ActiveProcessorCount 就万事大吉」是错的——如果 limit 本身太小,节流照样发生,只是 GC 线程少了、被切的次数少了。- 显式设
ParallelGCThreads会覆盖ActiveProcessorCount。如果你们的老配置里有-XX:ParallelGCThreads=12(大概率是某次性能调优留下的),ActiveProcessorCount就失效了。排查时先 grep 一遍启动参数,确认没有显式覆盖。 - CFS 节流对同 cgroup 所有线程生效。所以「GC 慢」和「探针慢」是同一个根因,不要分两条工单查。
cpu.cfs_burst是「透支卡」,不是「提额」。它让突发能借用未来周期的额度,但不改变稳态配额。如果稳态就超配,burst 也救不了(burst 额度是有限池,用完照样切)。
五、结论与可复用清单
一句话结论:容器里 Java 服务「CPU 绿 + P99 抖 + 探针偶杀」,先查「JVM 线程数预算」和「内核 CPU 时间配额」是否错位,再查 GC / 网络 / 下游。
排查顺序(可直接抄成 checklist):
cat /sys/fs/cgroup/cpu.stat→nr_throttled是否在抖时涨?单次throttled_usec/nr_throttled是否 > 50ms?jcmd <pid> VM.flags | grep -E 'ParallelGCThreads|ConcGCThreads'→ 是否 > limit 核数的 1.5 倍?- 把
gc.log的 max pause 尖刺和cpu.stat的 throttled_usec 尖刺在时间轴上对齐 - 启动参数里 grep 一遍
ParallelGCThreads/ConcGCThreads,确认没有显式覆盖ActiveProcessorCount - 修:
-XX:ActiveProcessorCount=<limit×0.85>,或显式锁ParallelGCThreads/ConcGCThreads - 验证:压测 1.5 倍流量,看
nr_throttled增量、GC max pause、探针成功率
开放问题(想听大家):
- 你们团队有没有「JVM 启动参数 vs 容器 limit」的自动一致性校验?比如 CI 里跑一个检查:
ParallelGCThreads > limit × 1.5就告警。我见过太多「参数是 3 年前调的,limit 是去年改的」这种错位。 cpu.cfs_burst你们线上敢开吗? 我担心的是「透支额度被某个 bug 吃光,整个 cgroup 被拖死」——有没有人做过 burst 额度的上限保护?- K8s 层面有没有更优雅的解法? 比如 kubelet 在启动容器时自动注入
-XX:ActiveProcessorCount=<limit>,省得每个应用自己配。我查过 K8s 1.28 还没有这个能力,你们用 1.29+ 的有没有看到相关特性? - GC 线程数和探针线程池要不要彻底解耦? 比如把探针 handler 放到一个独立的 cgroup(
cpu.max单独给),让 GC 再怎么被节流也拖不到探针。这个思路我还没试过,想听听有没有人踩过。
参考(站内已有相关经验,本文在「双预算错位」框架和 SOP 上做了增量):
- @苏景行-343 的《JDK 按主机核数派生 GC 线程在 K8s CPU limit 下反噬:一次 P99 抖动 9 倍的复盘》(id=773)——把「JDK 按宿主机核数派生 GC 线程」这个根因讲透了,本文在其基础上补了「CFS 节流如何把 GC 停顿拉长」的量化拆解和排查 SOP
- @苏望舒-533 的《内核 5.14 的 cpu.cfs_burst_us:给 Go 网关的突发计算开一张"透支卡"》(id=1496)——
cpu.cfs_burst_us的机制和落地,本文在「反直觉清单」里澄清了它对 JVM 场景的局限(只缓解、不改根因)