2760 字
约 9 分钟
17
S15-A07 · 容器内存账:limit、堆与页缓存的分配

"limit 设了 2G,堆也配了 2G,跑半天被 kill,日志里连 OOM 都没打出来。"——问题不是参数写错,而是把 limit 当成了堆的上限。容器内存是一个账本,堆只是其中一栏。

下面是分账方法与判据:五个消费者各占多少、页缓存算不算进 limit、137 与 OOM 怎么区分、比例该落在哪个区间,以及什么时候这本账不值得细算。


1. 一张分配账:五个消费者

容器内存不等于堆。limit 是给整个 cgroup 的上限,下面五项都从同一个池子里取。

消费者 典型量级 观测口径 是否受 -Xmx 约束
Java 堆 最大的一项,通常占大头 jcmd GC.heap_info、jstat -gc 是
元空间 + 代码缓存 百 MB 级,随类数增长 jcmd VM.metaspace、jstat -gc 的 MC/MU 否(另有 MaxMetaspaceSize)
线程栈 每线程约 0.5–1 MB(-Xss 定) 线程数 × -Xss,jcmd Thread.print 计数 否
直接缓冲 / mmap 取决于 NIO 用量,默认上限等于 -Xmx BufferPool MXBean、pmap 的匿名映射 否(MaxDirectMemorySize 单独管)
页缓存 与读写量同量级,可回收 memory.stat 的 file 字段 否
# cgroup v2:当前用量、硬上限、事件计数
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.events

memory.current 已把页缓存算进去,这就是"什么都没干、用量却在涨"的来源:日志写入、JAR 解压都会留下 file 页。

2. JVM 对 cgroup 的感知

参数 / 行为 作用 不成立时
-XX:+UseContainerSupport 按 cgroup 限额计算可用内存 按宿主机内存算,堆远超 limit
-XX:MaxRAMPercentage 堆占"可用内存"的比例 默认值偏保守,堆偏小
-XX:InitialRAMPercentage 初始堆比例 初始堆过小,早期频繁扩容
-XX:MinRAMPercentage 可用内存很小时才生效的分支 小规格容器堆被算得过小
# 按容器限额分配,而不是写死 -Xmx
java -XX:MaxRAMPercentage=60 -XX:InitialRAMPercentage=40 \
     -XX:MaxMetaspaceSize=256m -XX:MaxDirectMemorySize=128m \
     -XX:NativeMemoryTracking=summary -jar app.jar

旧版本的坑:JDK 8 早期(8u191 之前)不读 cgroup 限额,Runtime.maxMemory() 报的是宿主机内存,"按比例自动算"会直接算出几倍于 limit 的堆。核对实际值,不看版本号:

jcmd 1 VM.flags | tr ' ' '\n' | grep -Ei 'MaxHeapSize|MaxRAM'

3. 页缓存算不算 limit:v1 与 v2 的差别

维度 cgroup v1 cgroup v2
记账口径 memory.usage_in_bytes 含 page cache memory.current 含 page cache
硬上限 memory.limit_in_bytes memory.max
软限/节流 无统一软限 memory.high:先限流再回收
事件 memory.oom_control 的计数 memory.events 的 max / oom_kill
swap 记账 memory.memsw.* 单独一套 memory.swap.max 并入统一层级
# v2:分清匿名页(不可回收)与文件页(可回收)
grep -E '^(anon|file|slab|sock|kernel_stack|file_dirty) ' /sys/fs/cgroup/memory.stat
# 只看"工作集":排除可回收的 inactive_file

判据:页缓存计入 limit,但它是可回收的。接近 memory.max 时内核先回收 file 页,回收不动才 OOM。所以"贴着 limit"不等于"要炸",要看 anon 是否也贴顶——anon 贴顶才没退路。anon 单调上行才是泄漏,file 页高只说明 IO 留下了缓存。

4. 137 与 JVM OOM 的区分

判据 容器被 OOMKilled JVM 自身 OOM
退出码 137(128 + SIGKILL) 通常非 137,进程可能存活
应用日志 无异常栈,日志突然截断 java.lang.OutOfMemoryError: ...
内核/编排事件 memory.events 的 oom_kill 增长、kubectl describe 显示 OOMKilled 无 cgroup 事件
JVM 现场文件 无 hs_err_pid 常有 hs_err_pid*.log、堆转储
触发点 任意分配点,含 native 分配 大多是堆分配点
# 三处对照:退出码 / cgroup 事件 / JVM 现场文件
kubectl describe pod POD | grep -E 'Last State|Reason|Exit Code'
cat /sys/fs/cgroup/memory.events | grep oom_kill
ls -l /app/hs_err_pid*.log 2>/dev/null || echo "no hs_err"

高频误判:日志里确实有 OutOfMemoryError,进程却是被 137 杀的。两者可以同时成立——堆先撑爆,JVM 打印现场时又申请 native 内存,正好越过 limit。有 OOM 日志不代表不是 137,退出码与 oom_kill 计数才是判决书。

5. 比例区间:给判据,不给唯一数字

组成 占 limit 的经验区间 依据
堆 50%–70% 剩余要留给元空间、线程栈、直接缓冲
元空间 + 代码缓存 10%–20% 或固定上限 类多、动态代理多的服务取高
线程栈 线程数 × -Xss 显式计入 200 线程 × 1 MB ≈ 200 MB
直接缓冲 按 NIO/Netty 用量估 默认上限等于 -Xmx,容易被无视
余量 至少 15%–20% 吸收页缓存、碎片、瞬时峰值

判据顺序:先算 线程数 × Xss + 直接缓冲 + 元空间,这部分不可回收;limit 减掉它,剩下的才是堆空间。"按 limit 的 75% 配堆、其他不管"就是把不可回收部分当成了零。

另一条方向性结论:堆越大,GC 停顿的绝对时长越难压。压小堆换来更频繁的 GC 与更差的尾延迟,放大堆又被 limit 卡住,区间就是这两股力的平衡点。

6. 堆外内存的排查工具

工具 看什么 代价 适用
NMT(NativeMemoryTracking) 按类别拆 native 分配 开启后有小幅开销,需重启 首选,能直接定位类别
pmap -x / smaps_rollup 进程 RSS 按映射拆 瞬时采样 确认 native 总量
jcmd VM.native_memory NMT 的分类明细与 diff 需 NMT 已开启 对比两个时间点
jcmd GC.heap_info 堆的提交与使用 几乎无 先排除堆
# 开 NMT 后取基线,业务跑一段再 diff,差值即增量来源
jcmd 1 VM.native_memory baseline
jcmd 1 VM.native_memory summary.diff scale=MB

# RSS 与堆的差额就是"堆外 + 页缓存"的粗略上界
pmap -x 1 | tail -1

pmap 末行是 RSS 合计。RSS 减堆不等于泄漏量,它含页缓存与已释放未归还的部分,只能当上界。判泄漏要看趋势与 NMT 分类的持续增长。

7. swap、超卖与扩容

决策 选它的情况 代价
关闭 swap(memory.swap.max=0) 延迟敏感、GC 停顿不可接受 峰值时直接 OOMKilled,无缓冲
允许少量 swap 低频大对象、批处理 换入换出导致停顿抖动
节点超卖(Σlimit > 物理内存) 提高装箱率、成本优先 邻居抖动、驱逐不确定性
不超卖(Σlimit ≤ 物理内存) 稳态延迟优先 资源闲置,单位成本高
单实例加大内存 堆内结构难拆、跨对象引用多 GC 停顿变长、故障域变大

swap 的关键不是"开不开",而是开了之后延迟由谁承担:JVM 的页被换出,一次 GC 可能触发大量换入,表现为"GC 时间忽然变成几秒"。这类抖动看着像 GC 问题,实际是账没算平。超卖的风险则不在总量,而在驱逐的不可预测性。

8. 告警水位与压测口径

# 工作集水位(排除可回收页缓存),按容器聚合
container_memory_working_set_bytes / container_spec_memory_limit_bytes
# OOMKill 计数增长,比水位更硬的信号
increase(container_oom_events_total[10m])
告警项 阈值思路 误报来源
工作集 / limit 长期高于 80%–85% 且 anon 同步上行 压测或批量导入造成的临时缓存
oom_kill 增长 大于 0 即告警 无,属硬事件
GC 后老年代残留 基线逐次上移 缓存预热期

压测时最容易漏掉的是页缓存压力:只打 API 不读写文件,永远造不出 file 页占比高的现场,于是"线上被 kill、压测一切正常"。

# 造页缓存压力:读大文件把 file 页填进去(不落盘写,避免污染)
find /data/fixtures -type f -size +100M | head -5 | xargs -r cat > /dev/null &
# 同时保持业务流量,观察 memory.current 与 memory.stat 的 file 字段
watch -n 5 'grep -E "^(anon|file) " /sys/fs/cgroup/memory.stat'

判据:file 涨而 anon 平稳、无 oom_kill,说明账是平的;anon 跟着涨才是真问题。

9. 排错

现象 最可能原因 核对方式
无 OOM 日志却被杀 cgroup 硬限触发,SIGKILL 不给打印机会 退出码 137 + oom_kill 计数
jcmd 显示堆远小于 limit 仍被杀 堆外(元空间/直接缓冲/线程栈)超支 NMT summary
用量长期贴 limit 但服务正常 页缓存占用,可回收 memory.stat 的 anon 是否平稳
堆按宿主机内存计算 旧 JDK 不识别 cgroup VM.flags 看实际 MaxHeapSize
压测通过、线上被杀 压测没造页缓存/native 压力 补文件读写与长连接场景
GC 停顿忽然变长 页面被换出,触发换入 memory.swap.current、换页计数

10. 边界

场景 结论 理由
无状态小服务、内存占用稳定在限额一半 不必精细分账 余量本身就是安全垫
纯批处理、短生命周期任务 用足够大的 limit,不调比例 任务结束即释放,账算不准也无害
内存本就宽裕(limit 远大于实际峰值) 不必开 NMT 开销换不来结论
单容器独占整台机器 按物理内存算账即可 cgroup 边界与机器边界重合
服务以 native 计算为主(JNI/向量库) 优先按 RSS 与 NMT 分账 堆占比小,-Xmx 不是主要变量
弹性伸缩、实例随时重建 先保 limit 余量,再谈比例 缩放窗口内的抖动比精度更值钱

11. 自测

  1. limit 是给谁的约束?为什么"堆 = limit"必然出事?
  2. memory.current 里的页缓存算不算超限?为什么它和 anon 要分开看?
  3. 日志里有 OutOfMemoryError,能否断定不是 OOMKilled?依据是什么?
  4. 旧 JDK 上"按 75% 自动配堆"为什么会在容器里算出几倍于 limit 的堆?
  5. 线程栈与直接缓冲在分账时为什么要单列,而不是算进堆的余量?
  6. 压测时怎么造出真实的页缓存压力?只看哪个字段判断账是否平?
S15-A07 · 容器内存账:limit、堆与页缓存的分配
https://www.clxhxhhr.top/posts/6193/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。