2961 字
约 9 分钟
17
S05-A07 · 堆外内存治理:直接缓冲、映射文件与原生分配
2024-12-31
2026-11-09

堆曲线平坦、老年代稳定、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() 行为核对)。两个后果:

  1. 堆外超限表现为"先打满再尝试回收",不是干净的拒绝;
  2. 两笔预算被绑在一起,-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. 自测

  1. 堆曲线平坦而 RSS 单调上升时,你的第一张表先拆哪四项?
  2. 未显式设置 MaxDirectMemorySize 时默认值是什么,这个默认值带来什么风险?
  3. System.gc() 之后直接缓冲没回落,最可能是哪类持有问题?
  4. MappedByteBuffer 的页在容器里算内存吗,为什么它通常不是 OOMKilled 主因?
  5. smaps 里匿名段与 file-backed 段各说明什么?
  6. 什么规模的堆外用量下,精细治理本身就不划算?
S05-A07 · 堆外内存治理:直接缓冲、映射文件与原生分配
https://www.clxhxhhr.top/posts/6204/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。