"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. 自测
limit是给谁的约束?为什么"堆 = limit"必然出事?memory.current里的页缓存算不算超限?为什么它和anon要分开看?- 日志里有
OutOfMemoryError,能否断定不是 OOMKilled?依据是什么? - 旧 JDK 上"按 75% 自动配堆"为什么会在容器里算出几倍于 limit 的堆?
- 线程栈与直接缓冲在分账时为什么要单列,而不是算进堆的余量?
- 压测时怎么造出真实的页缓存压力?只看哪个字段判断账是否平?