Skip to content

Java 应用的内存持续性增长,但是监控显示堆内存没有什么变化,可能的原因有哪些? ​

  • 标签:后端、场景题

🏛 业务架构师 ​

堆内存没涨但进程内存持续增长——问题出在堆外(off-heap),这是 JVM 进程 = 堆 + 元空间 + 线程栈 + 堆外内存 + JIT 代码缓存 的整体视角题。我按嫌疑大小排查。

常见原因(按概率)

  1. 堆外内存泄漏(DirectByteBuffer):NIO 框架(Netty、RPC 框架)大量使用直接内存,业务 bug 或框架参数不当导致不释放;特征:进程 RSS 涨、堆完全正常
  2. Metaspace 增长:CGLIB/反射动态生成类、Groovy 脚本、频繁动态代理;类加载器泄漏(热部署场景)。看 jstat -gcutil 的 M 列
  3. 线程数失控:每个线程默认 1MB 栈,线程泄漏(未复用的 new Thread、连接未关)几百个线程就是几百 MB;jstack 数线程
  4. 本地库(JNI)泄漏:压缩/解压库(Snappy、Zlib)、加密库、native 客户端
  5. glibc malloc 碎片:长期运行进程的经典问题(内存释放但未归还 OS),容器环境尤其常见

排查工具链

bash
jcmd <pid> VM.native_memory summary   # 开NMT: -XX:NativeMemoryTracking=detail
pmap -x <pid> | sort -k3 -n -r        # 看哪类内存段大
jstack <pid> | grep '"' | wc -l       # 线程数

NMT(Native Memory Tracking)是定位堆外的第一工具,但开启有 5~10% 性能损耗,平时 summary 级别常开、排查时切 detail。

业务视角的收尾:这类泄漏最终以容器 OOMKilled收场(cgroup 杀进程,重启接盘),比 JVM OOM 更隐蔽。我的预防规约:容器内存 limit = 堆 + Metaspace 上限 + 堆外预留 + 余量(堆外至少留 25~30%),很多人把 limit 设成等于 -Xmx,等于没给堆外留活路——这是容器化 JVM 最高频的配置事故。

🏗 系统架构师 ​

堆没涨但进程 RSS 持续增长,说明内存在堆外,我按堆外内存的六大去向排查。

1. 定位证据:NMT 与进程对比 JVM 加 -XX:NativeMemoryTracking=summary,jcmd <pid> VM.native_memory summary 看各区域(Class、Thread、Code、GC、Internal、Other)的量,与 top 的 RES 对比差值——差值大的地方就是泄漏方向。NMT 本身有 5%~10% 开销,生产可按需开关。

2. 常见根因(按概率):

  • 直接内存(DirectByteBuffer):Netty 池化堆外内存、NIO 使用后未显式 release()/Cleaner 生效不及时。排查:jcmd VM.native_memory 的 Internal 项 + Netty 的 io.netty.metrics;用 pmap/jemalloc profiling 看大块 anon 内存
  • 线程过多:每线程 1MB 栈,线程泄漏(未复用的 new Thread)累积几百个就是 GB 级,jstack 数线程
  • Metaspace/类加载器泄漏:热部署、Groovy 动态脚本、反射代理类不停生成,jstat -gcmetacapacity 或 NMT Class 项确认
  • JNI/本地库:加密库、压缩库(zstd/snappy)、rocksdb 的 native 分配,JVM 看不见,只能 pmap + native profiler
  • GC 未归还内存:G1 在 MaxHeapFreeRatio 限制下可能不把内存还给 OS,看似"涨"实则正常,jcmd GC.run 可验证
  • 容器误区:容器场景看错了 cgroup 的 RSS 共享页(fs cache 计入),先确认监控口径

3. 防复发:堆外内存配额(-XX:MaxDirectMemorySize、Netty 池上限)+ RSS 告警 + 压测基线,发布前对比内存曲线。

💻 全栈开发专家 ​

堆没涨但进程 RSS 持续涨,说明增长在堆外。我按嫌疑大小排查。

常见原因

  1. 堆外内存/NIO 泄漏:Netty 的 DirectByteBuffer、unsafe.allocateMemory。Netty 开泄漏检测:-Dio.netty.leakDetection.level=PARANOID,日志里会报 LEAK: ByteBuf.release() was not called。
  2. 线程过多:每线程默认 1MB 栈,2000 个自定义线程 = 2G。jstack <pid> | grep '"' | wc -l 数线程数,配上 cat /proc/<pid>/status | grep Threads。
  3. Metaspace 增长:动态代理(CGLIB)、Groovy 脚本、反射频繁生成类导致类加载器泄漏。jstat -gcmetacapacity 或 JFR 看 classloader 数量。
  4. glibc malloc 碎片:长期运行的服务 64 位机器上 arena 碎片可达数 G,RSS 远超 malloc 统计。解法:MALLOC_ARENA_MAX=2 或切 jemalloc/tcmalloc。
  5. JNI / 客户端 native 内存:RocksDB、Kafka 客户端压缩、APM Agent。

排查命令

bash
# Native Memory Tracking:重启加 -XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory summary diff   # 看哪块 native 在涨
pmap -x <pid> | sort -k3 -n | tail        # 看大内存段地址归属

我的经历:一次网关 RSS 每天 +200MB,NMT 正常、线程正常,最后 pmap 定位是 gzip 解压的 native 缓冲,换 netty 压缩配置 + 定期重启窗口解决。先 NMT diff、再 pmap,二分定位最快。