堆曲线平坦、老年代稳定、Full GC 之后也没涨回来,容器 RSS 却一路往上,直到某天 ExitCode 137。再看堆没有意义——涨的那部分不在 -Xmx 的账本里。
核心判断:-Xmx 只约束堆,堆之外的每条路径都有自己的分配器与回收触发条件。顺序永远是:分账 → 找增长源 → 判断会不会自动回收 → 选限额、池化还是改写结构。
1. 先分账:把 RSS 拆成可解释的几块
不做分账就抓堆 dump 是最常见的浪费。判据:RSS 与堆的稳定不同步时才轮到堆外。
| 组成 | 典型量级 | 上限参数 | 增长信号 |
|---|---|---|---|
| 堆 | -Xmx 决定 |
有 | GC.heap_info 已用贴近上限 |
| 元空间 | 几十到几百 MB | MaxMetaspaceSize |
动态代理后类数涨 |
| 线程栈 | 线程数 × 预留栈 | -Xss |
线程数单调上升 |
| 直接缓冲 | 单缓冲 × 持有数 | MaxDirectMemorySize |
池未释放、每次新建大 buffer |
| 映射文件 | 常远大于数据本身 | 无 | smaps 里 file-backed 段变大 |
| native / JIT / GC | 随代码量与分配速率变化 | 无 | NMT 里 Code、GC 抬头 |
分账第一步是减法:RSS − 堆 − 元空间 − 线程栈 − 直接缓冲 ≈ 剩余;剩余大才轮到 native 库与 JVM 内部结构。减法做不出来,缺的是数据源。
2. 五类来源与各自的生命周期
同一句"堆外泄漏"在五类来源里含义完全不同:"会不会自己好"取决于回收触发条件,不取决于有没有调用 free。
| 来源 | 谁释放 | 触发条件 | 关键约束 |
|---|---|---|---|
DirectByteBuffer |
Cleaner(JDK 9+ 共享线程) |
buffer 对象被 GC 回收 | 受 MaxDirectMemorySize 记账 |
MappedByteBuffer |
unmap 或进程退出 |
显式 unmap,或 GC 后 cleaner 触发 | 无公开 API,跨平台差异大 |
| JNI / native 库 | 库自己 free |
只由库的 API 决定 | JVM 侧看不见 |
| 线程栈 | 线程退出 | 线程终止 | RSS 只算触碰页 |
| GC / JIT 结构 | JVM 内部 | 回收器与编译器决定 | 只能用 NMT 看 |
最重要的推论:只有前两类能被 GC 间接管到,后三类不行。把它们当成"GC 该回收的东西"去等,会等到容器被杀。
3. 为什么 -Xmx 管不到堆外
堆外走 Unsafe.allocateMemory 或 mmap,不经过分代结构,既不给堆加已用字节,也不产生堆内回收压力。唯一记账在内部类 Bits:维护计数器并与 MaxDirectMemorySize 比较。
# 看实际生效的限额;未显式设置时这一项可能根本不出现
jcmd <pid> VM.flags | tr ' ' '\n' | grep -iE 'MaxDirectMemory|Xmx|Metaspace'
jcmd <pid> GC.heap_info
默认值要记准:未显式设置时,直接内存上限默认等于堆上限(按 JDK 17 jdk.internal.misc.VM.maxDirectMemory() 行为核对)。两个后果:
- 堆外超限表现为"先打满再尝试回收",不是干净的拒绝;
- 两笔预算被绑在一起,
-Xmx调大等于悄悄放大堆外额度。
显式设置的意义不是限得更小,而是把两笔预算解耦,让限额可解释、可告警、可进容量规划:
java -Xmx4g -XX:MaxDirectMemorySize=512m -jar app.jar
4. 回收依赖:Cleaner 与 Full GC 的关系
直接缓冲不会被超时回收,也不是引用计数语义——它挂在 Cleaner 上,入队依赖buffer 对象变成不可达。由此有三种误判:
| 现象 | 真实原因 | 该做什么 |
|---|---|---|
堆外涨、堆不动,System.gc() 后回落一点 |
buffer 死在新生代,young GC 即可释放 | 不干预,属回收节奏 |
System.gc() 后仍不回落 |
buffer 被长期持有(静态池、缓存、积压队列) | 查持有者,不是催 GC |
Direct buffer memory OOM |
限额打满、回收跟不上分配 | 限额 + 池化,非调大限额 |
不建议手动 System.gc():完整回收周期的停顿、ExplicitGCInvokesConcurrent 下"以为回收了其实只是并发标记"的错觉、把回收时机从 JVM 节奏改成业务节奏。要控的是分配速率与持有结构。
Netty 里 ByteBuf 是引用计数语义,漏掉的不是"等 GC"而是 release() 没配对:判据是 ResourceLeakDetector 级别与日志里的 LEAK。
5. MappedByteBuffer:页缓存算谁的
map() 后数据页由页缓存持有,进程只建立了地址映射:它在 smaps 里落在 file-backed 段;RSS 会算进去,但不代表这是"应用内存";属可回收页,压力下优先回收——除非是未落盘的脏页;显式释放没有公开 API,要用 Unsafe.invokeCleaner 或 cleaner 反射,跨平台不一致(Windows 上映射未解除时删文件会失败)。
| 判断项 | 答案 | 工程含义 |
|---|---|---|
受 -Xmx / MaxDirectMemorySize 约束? |
否,不记账 | 两项限额都挡不住它 |
| 会自动 unmap? | 不一定 | 长期持有时一直占地址空间 |
| 容器里算不算 limit? | 算,但多为可回收 | 只有脏页与匿名页才逼近 OOM |
治理映射文件靠控制数量与生命周期,不是调内存参数:要立刻释放,只能接受"映射到进程退出"或不可移植的 unmap。
6. NMT:唯一能按类别看原生内存的工具
java -XX:NativeMemoryTracking=summary -jar app.jar # 必须启动时开
jcmd <pid> VM.native_memory summary scale=MB # 总览,MB 口径
jcmd <pid> VM.native_memory baseline # 打基线
jcmd <pid> VM.native_memory summary.diff # 与基线比,看谁在涨
| 分类 | 装的是什么 | 治理手段 |
|---|---|---|
Java Heap |
堆已提交部分 | 调 -Xmx |
Class |
元空间、类元数据 | MaxMetaspaceSize、减少动态类 |
Thread |
线程栈与线程结构 | 池上限、-Xss |
Code |
JIT 产物、代码缓存 | 编译阈值、ReservedCodeCacheSize |
Internal / Other |
直接缓冲、内部表、malloc 碎片 | 直接内存限额、重启节奏 |
开启成本要算进去:summary 记分配点,detail 记每次分配的调用栈,都给分配路径加负担,幅度按 JDK 版本实测。纪律是只在怀疑堆外时开,用完就关。
summary.diff 才是真正用法:打基线 → 跑一段 → 看差值。某分类单调上升是泄漏证据,绝对值大而无增长只是"本来就大"。
7. pmap 与 smaps 的读法
pmap -x <pid> | sort -k4 -nr | head # 按 RSS 倒排
grep -E 'Rss|Pss|Anonymous' /proc/<pid>/smaps_rollup # 一次拿汇总
| 指标 | 含义 | 回答什么问题 |
|---|---|---|
| RSS | 驻留物理页,共享页重复计数 | 容器 OOM 的触发口径 |
| PSS | 共享页按共享进程数均摊 | "这个进程真实吃掉多少" |
| Anonymous | 堆、直接缓冲、线程栈 | 不可回收的那部分,最危险 |
| File-backed | 映射文件、jar、类数据 | 多为可回收,压力下被丢弃 |
匿名与文件映射必须分开看:匿名段持续涨才是"内存被占住";文件段涨而匿名稳定,是页缓存问题,不是治理问题。
8. 容器里的 OOMKilled 路径
容器被杀的判据是 cgroup 用量触及 limit,而它的账本与 -Xmx 不在同一层级:
| 环节 | 事实 | 后果 |
|---|---|---|
| 账本口径 | 进程全部匿名页 + 页缓存等集合 | 堆外、线程栈、映射都计入 |
| JVM 感知 | JDK 10+ 按 limit 推导默认堆上限 | 只读 limit,不看别的进程 |
| 回收顺序 | 页缓存先回收,匿名页不可回收 | 匿名段到 limit 就杀 |
| 杀掉方式 | 内核 OOM killer,SIGKILL |
无 Java 异常栈,只有 137 |
| 与堆 OOM | 有 OutOfMemoryError |
137 只能靠 RSS 曲线复盘 |
因此不能只写 -Xmx:limit 要覆盖"堆 + 元空间 + 线程栈 + 直接缓冲 + native + 余量",并在容器里显式设 MaxDirectMemorySize,否则默认值会跟着 cgroup 推导出的大堆上限一起放大。
9. 排查顺序
1 曲线:堆稳定而 RSS 单调涨 → 堆外
2 分账:堆 / 元空间 / 线程数×Xss / 直接缓冲,四项先减
3 计数:baseline → summary.diff,看哪个分类在涨
4 归属:smaps 分匿名与文件映射,file-backed 大 → 映射文件
5 结构:找持有者(静态池、队列、未 release 的 ByteBuf)
6 收敛:限额 → 池化 → 改写分配点
顺序的意义是避免先入为主:跳到第 5 步常改错对象;停在 3 只知"某分类在涨",不知谁在分配。
10. 用量估算:乘法比曲线更早
堆外用量几乎都是乘出来的,上线前就能算:
| 估算式 | 典型量级 | 说明 |
|---|---|---|
| 并发请求 × 每请求缓冲 | 千级并发 × 几十 KB ≈ 百 MB | 池化后按池容量算 |
| 连接数 × 每连接缓冲 | 千连接 × 几十 KB ≈ 百 MB | 内核 socket 缓冲不进 RSS |
| 线程数 × 栈预留 | 千线程 × 1 MB ≈ GB 级预留 | RSS 只算触碰页,远小于预留 |
| 映射数 × 文件大小 | 单文件 GB 级 | 占地址空间与页缓存 |
估算给的是规模判据:百 MB 级重池化与限额,GB 级重结构改写。
11. 排错表
| 现象 | 最可能原因 | 判定与处理 |
|---|---|---|
| RSS 涨、堆平、Full GC 不落 | buffer 被长期持有 | 看 Internal 是否单调涨;查静态池与队列 |
Direct buffer memory OOM |
分配快于回收,或限额过小 | 先确认限额已显式设置,再查是否每次新建大 buffer |
| 容器 137 且日志无异常 | 匿名段触及 cgroup limit | 分账定位匿名页主贡献者,重设 limit 或直接内存额度 |
| NMT 里找不到直接缓冲项 | 可能落在 Internal,与分配点不一一对应 |
换 detail 复核 |
12. 边界:什么时候不必做精细治理
| 场景 | 结论 | 理由 |
|---|---|---|
| 堆外稳定在几百 MB 以内 | 不做精细治理 | 分账误差比目标值还大 |
| 无 native 库、无 JNI | 省掉一半工具链 | 主要矛盾只剩直接缓冲与线程栈 |
| 单实例、重启即恢复 | 优先重启与告警 | 治理成本高于故障成本 |
| 压测阶段 | 先做乘法估算 | 曲线要跑很久才有结论 |
必须做的信号:limit 与堆上限接近、有 JNI 或本地缓存、有 GB 级映射、出现过 137。
13. 自测
- 堆曲线平坦而 RSS 单调上升时,你的第一张表先拆哪四项?
- 未显式设置
MaxDirectMemorySize时默认值是什么,这个默认值带来什么风险? System.gc()之后直接缓冲没回落,最可能是哪类持有问题?MappedByteBuffer的页在容器里算内存吗,为什么它通常不是 OOMKilled 主因?smaps里匿名段与 file-backed 段各说明什么?- 什么规模的堆外用量下,精细治理本身就不划算?