下面是精简版,保留关键排查过程、数据、原理和结论,适合直接作为技术博客。
一、问题背景#
生产环境某 Java 服务运行一段时间后,容器内存使用率接近 95%,存在 OOMKilled 风险。
环境信息:
- JDK 21、Spring Boot
- Kubernetes,容器内存限制 8GiB
- JVM 参数:
-Xms4096m -Xmx4096m
- Debian,glibc 2.36
通过 Arthas 查看,Java Heap Used 只有约 2.2GiB,但进程 RSS 已经达到 7.5GiB。
显然,问题不能只从 Java Heap 入手。
二、排查过程#
1. JVM Native Memory 分析#
首先查看进程 RSS:
grep -E 'VmRSS|Threads' /proc/1/status
结果:
通过 NMT 查看 JVM 内存:
jcmd 1 VM.native_memory summary scale=MB
结果:
Total: reserved=6827MB, committed=5196MB
关键数据:
RSS 明显高于 NMT Committed。
需要注意,NMT 与 RSS 统计口径不同,不能直接将差值认定为内存泄漏。但这提示我们,需要进一步调查 JVM 之外的 Native 内存分配。
2. 分析 Linux 内存映射#
通过以下命令查看内存映射:
发现大量类似的匿名映射:
7f9ca4000000-7f9ca7ff9000 rw-p
7f9ca7ff9000-7f9ca8000000 ---p
这些映射具有明显的 64MiB 地址布局特征,与 glibc malloc 非主 Arena 的 heap 区域比较相似。
进一步统计部分候选匿名映射:
$5 == "rw---" && $7 == "anon" &&
$2 >= 1024 && $2 <= 65536 {
printf "Mappings: %d\n", count
printf "RSS: %.2f MiB\n", rss / 1024
生产与预发对比:
可以发现:
生产与预发 RSS 相差约 2.2GiB,而候选匿名映射 RSS 也相差约 2.2GiB。
这使 glibc malloc Arena 成为重点怀疑对象。
注意,这里的候选映射并不全部等于 Arena,统计结果只能作为辅助证据。
三、glibc Arena 是什么?#
glibc malloc 为了提高多线程内存分配性能,引入了多个 Arena。
不同线程可以通过不同 Arena 分配内存,减少锁竞争。
但代价是:
- 不同 Arena 的空闲内存无法直接共享。
free() 后的内存不一定立即归还操作系统。
- Arena 较多时,可能出现内存保留和碎片化,导致 RSS 偏高。
glibc 在 64 位系统上,默认 Arena 数量上限通常为在线 CPU 数量的 8 倍。
而 Kubernetes 容器中的 CPU Limit 不一定影响 glibc 获取的在线 CPU 数量,因此可能允许创建较多 Arena。
可以通过环境变量限制 Arena 数量:
该参数只限制 Arena 数量,不限制 Native 内存总量。
潜在收益是降低内存碎片和保留内存;代价是可能增加 malloc 锁竞争。
四、预发验证#
在预发环境增加 MALLOC_ARENA_MAX=4,重启 Pod 后持续观察近 5 天。
结果如下:
优化效果:
- RSS 降低约 415MiB(7.6%)
- 候选匿名映射 RSS 降低约 377MiB(36%)
- 运行近 5 天,RSS 基本保持稳定。
虽然优化前后的负载和线程数并不完全一致,但结果与限制 glibc Arena 后降低内存保留的预期相符。
五、总结#
本次排查的核心过程:
Heap 正常 → NMT 无法完整解释 RSS → 分析匿名内存映射 → 怀疑 glibc Arena → 预发调整参数验证。
最终发现:
- Java Heap 正常,不代表整个 Java 进程内存正常。
- NMT 无法完整覆盖所有 Native 内存分配。
- glibc malloc 的 Arena 机制可能导致内存释放后 RSS 仍然偏高。
- 通过
MALLOC_ARENA_MAX=4,预发环境获得了约 415MiB 的 RSS 降幅。
当前结论:glibc Arena 内存保留或碎片化很可能是 RSS 偏高的重要影响因素,但尚未直接证明是唯一根因。
后续计划在生产环境进行单 Pod 灰度,进一步验证内存收益及性能影响。