2183 字
约 7 分钟
20
S05-A13 · JVM 的容器感知:堆、CPU 与 cgroup 的差
2024-12-31
2026-11-09

一个服务在容器里配了 4G 内存上限,JVM 启动参数没设堆大小,按老版本行为它读到了宿主机的 64G,把最大堆设成了 16G。运行一段时间后容器被 OOM 杀掉,而 GC 日志显示堆只用了 3G。

问题不在堆用得多,而在 JVM 看到的"可用内存"与容器实际给的限额不是同一个数。容器感知这个能力直接决定 JVM 按哪个数去算堆、算线程、算 GC 目标。


1. 三个数先对齐

数 来源 用途
宿主机物理内存 /proc/meminfo 老版本 JVM 的默认依据
cgroup 限制 /sys/fs/cgroup/memory.max 容器真实上限
进程实际占用 RSS + 堆外 OOM 判定

容器感知要做的就是把第一项换成第二项。 8u191 之后默认开启(-XX:+UseContainerSupport),但仍有三种情况会失效:显式设了 -Xmx、用了容器限额之外的 MaxRAM 覆盖、或者运行在限额未设置的环境里(此时按宿主机算,反而是对的)。

// 确认 JVM 看到的限额与堆设置
java -XX:+PrintFlagsFinal -version | grep -E "MaxHeapSize|UseContainerSupport|MaxRAM"
cat /sys/fs/cgroup/memory.max            # 容器真实上限(v2)
// 若 MaxHeapSize 明显大于容器上限,容器感知没有生效或被人为覆盖

2. 堆之外还有四块内存

区域 典型量级 是否受 Xmx 约束
Java 堆 由 Xmx 决定 是
元空间 数十到数百 MB 否(有独立上限)
直接内存(NIO) 与网络/文件 IO 相关 否(需单独设)
线程栈 线程数 × 1MB 级 否
代码缓存 数十 MB 否(有独立上限)

估算时有个容易漏的量:线程数乘栈大小。两百个线程各 1MB 就是 200MB,而多数人只盯着堆。线程一多,留给堆的实际空间就被压缩了。

容器限额管的是这五项之和,而 Xmx 只管第一项。 这是"堆只用了 3G 却被杀"的标准解释:剩下 1G 被元空间、直接内存、线程栈与代码缓存分掉了。

// 一个可用的组合:按容器限额算总账,而不是只设 Xmx
-XX:MaxRAMPercentage=65           // 堆 = 容器上限 × 65%
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=256m
-XX:ReservedCodeCacheSize=128m
-Xss=512k                         // 线程栈,按线程数估算
// 65% + 25% + 10% ≈ 容器上限,剩余作为浮动余量

3. CPU 感知与 GC 线程

参数 依据 未感知的后果
GC 线程数 可用 CPU 数 线程过多,互相争抢
JIT 编译线程数 可用 CPU 数 编译占用过多 CPU
ForkJoin 并行度 可用 CPU 数 并行任务实际串行
堆大小 可用内存 见上一节

这类问题的排查入口很明确:看 GC 日志里的线程数与停顿分布。如果 GC 线程多但每次停顿并不短,说明线程在争抢而不是并行工作。

在 0.5 核或 1 核的容器里,JVM 若按宿主机 32 核去设 GC 线程,会造成严重的线程争抢。 这类问题的表现是"GC 时间占比不高但应用很慢"。

// 小数核容器必须显式约束并行度
-XX:ActiveProcessorCount=2        // 显式告诉 JVM 有几个核
-XX:ParallelGCThreads=2
-XX:ConcGCThreads=1
-XX:ForkJoinPool.common.parallelism=2
// 注意:ActiveProcessorCount 会覆盖 cgroup 探测结果,需与限额一致

4. 容器内排查的三个入口

入口 能看到什么
-Xlog:gc* 堆使用、GC 停顿、晋升
/sys/fs/cgroup/memory.events OOM 次数与限流事件
/sys/fs/cgroup/cpu.stat 节流次数

第二项的 oom_kill 计数是判断"是否被 cgroup 杀"的直接证据,比看应用日志可靠。

排查顺序也有讲究:先看 memory.events 有没有 OOM,再看 cpu.stat 有没有节流,最后才看堆。先从"容器层"看,再进"JVM 层",能避免在堆上做无用调优。

// 一次看全三个入口,判断是"堆不够"还是"总内存不够"
grep -E "oom|max" /sys/fs/cgroup/memory.events
grep -E "nr_throttled|throttled_usec" /sys/fs/cgroup/cpu.stat
jcmd 1 GC.heap_info        // 堆实际使用与容量

5. 什么时候不该用百分比

配置方式 适用 风险
MaxRAMPercentage 容器限额稳定 限额变动时堆跟着变,行为漂移
固定 Xmx 需要稳定行为 限额调小后没有余量
两者结合(百分比 + 上限) 多环境统一 配置略复杂
完全不设 开发环境 生产不可控

可以用一条启动检查把这件事固化下来,避免"改限额忘改堆":

// 启动脚本里校验:堆 + 堆外预算 ≤ 容器限额
LIMIT=$(cat /sys/fs/cgroup/memory.max)
HEAP=$(java -XX:+PrintFlagsFinal -version 2>/dev/null | awk '/MaxHeapSize/{print $4}')
OVERHEAD=$((512*1024*1024))     // 元空间+直接内存+栈的估算值
[ $((HEAP+OVERHEAD)) -le "$LIMIT" ] || { echo "堆预算超过容器限额"; exit 1; }

第二项的教训很具体:容器限额从 8G 调到 4G,而 Xmx 还是 6G,直接起不来。所以固定 Xmx 的环境必须有"限额 ≥ 堆 + 余量"的校验。

第三种是多数生产系统的合理选择:百分比给默认值,同时设一个绝对上限,避免大机型上堆被设得离谱。

还有一个常被忽略的量:page cache。容器内读写文件会在 cgroup 内存里计入页缓存,频繁读大文件的服务会因此提前触碰限额,而堆看起来毫无异常。

6. 排错:五种"容器里跑得不好"

现象 成因 处理方向
堆用得不多却被 OOM 堆外内存未计入预算 设直接内存与元空间上限
GC 停顿长但堆不大 堆过小或 GC 选型不当 调堆下限或换收集器
应用慢而 CPU 不高 被 CPU 限额节流 查 nr_throttled
启动慢 类加载与 JIT 受 CPU 限制 减少启动期工作或提高限额
同一镜像不同环境行为不同 限额不同导致堆不同 固定 Xmx 或固定限额

节省空间的另一种做法是把线程数与栈大小一起算:容器里 512k 的栈够用,而默认 1M 在两百线程时就多吃 100MB。这类调整的收益是确定的,风险也低。

第五项说明百分比配置的副作用:同一个镜像在不同环境表现不同,是因为堆跟着限额变。这在"测试环境小、生产环境大"的场景里会造成性能差异,需要显式认知。

7. 边界:什么情况不必细调

场景 为什么 做法
物理机独占部署 无 cgroup 限额 按物理内存设堆
开发环境 无稳定性要求 不设,用默认
极短生命周期任务(批处理) 启动即结束 只保证不 OOM
堆需求远小于限额 无压力 设一个安全上限即可

还有一条与版本有关的边界:不同 JDK 版本的默认行为不同。老版本不感知容器、新版本默认感知、部分发行版改了默认值。所以同一份参数在不同版本上可能表现不一致——升级 JDK 时把它当作一次行为变更来评估,而不是顺手升级。

判断口径一句话:容器的限额会不会小于 JVM 默认推断的值。会,容器感知与显式参数就是必需的;不会,默认行为反而是对的。


8. 自测

  1. 容器感知要做的事用一句话怎么说?哪三种情况会让它失效?
  2. 堆之外还有哪四块内存?为什么"堆只用了 3G 却被杀"是正常的?
  3. 小数核容器为什么必须显式约束并行度?典型症状是什么?
  4. 判断"是否被 cgroup 杀掉"最直接的证据在哪个文件?
  5. 固定 Xmx 与百分比配置各自的代价是什么?两者结合解决了什么问题?
  6. 为什么"同一镜像在不同环境行为不同"可能是堆配置造成的?
S05-A13 · JVM 的容器感知:堆、CPU 与 cgroup 的差
https://www.clxhxhhr.top/posts/6420/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。