Netty 堆外内存没涨,RSS 却一直涨?记一次 glibc Arena 排查
问题概述
有一个基于 Netty 的高并发 Java 服务,内存指标出现了奇怪的偏差:
- Java 堆在
-Xmx限制内,用量正常; - Netty 统计的直接内存没超过业务阈值;
- JVM Native Memory Tracking(NMT)显示大量内存归到
Internal; - 但操作系统看到的 RSS 比 JVM 和 Netty 的统计值高出一大截。
调用链很快就指向了 Netty——分配路径很清楚,Unsafe.allocateMemory 进来的。但问题的关键不是”谁申请的”,而是:
Netty 觉得自己只用了 X GB,操作系统却看到 X+N GB。这 N GB 是哪来的?
最终排查发现,差值主要来自 glibc malloc 的 Arena 缓存和归还策略。大量并发线程通过 Netty 申请小于 Arena 粒度的直接内存,即使上层释放了,glibc 也可能把空闲页继续留在进程 Arena 里,不通过 munmap / brk 还给操作系统。
1. 先分清楚是哪一层
Java 进程内存异常,第一步先建立分层视角:
- Java Heap:对象堆,
-Xms/-Xmx管着。 - JVM Native Memory:Metaspace、Code Cache、GC 结构、线程栈、JIT 等。
- Direct Memory:NIO、Netty 或业务通过
Unsafe等方式分配的堆外内存。 - JNI / Native Library:本地库通过
malloc、mmap分配的内存。 - 分配器缓存与碎片:上层已经释放了,但分配器还没还回去。
- 文件映射和共享内存:也反映在进程地址空间 / RSS 里。
这个 case 里堆稳定、没有 Java heap space,直接转向堆外排查。
2. pmap 先看个大概
1
pmap -X <pid> > pmap.txt
结果里出现了大量 64MB 的匿名映射块,排列整齐。64MB 这个数字很眼熟——glibc Thread Arena 的默认大小就长这样。
不过光看 pmap 只能说”这些地址段占了内存”,不能直接回答”谁申请的”。还得结合 NMT、调用栈和系统调用继续验证。
3. NMT 缩小范围
JVM 启动时加:
1
-XX:NativeMemoryTracking=detail
运行中查:
1
jcmd <pid> VM.native_memory summary
这个 case 的 NMT 里 Internal 分类显著偏大,初步判断大量内存来自 Unsafe.allocateMemory。但 NMT 有几个局限得注意:
- 它跟踪的是 JVM 自身或经 JVM 记账的 Native 内存,不等于操作系统 RSS;
reserved、committed和 RSS 是三个不同的东西;- 分配器缓存、碎片和部分第三方 native 分配可能归因不准。
NMT 更适合”定界”,别拿它当最终结论。
4. Native 火焰图为什么没直接解决问题
常见思路是用 jemalloc 接管 malloc/free 生成火焰图:
1
LD_PRELOAD=/path/to/libjemalloc.so java ...
这对纯 C/C++ 调用栈很有效,但 native 栈跨越到 JIT 生成的 Java 代码后,经常只剩地址,还原不了完整 Java 栈帧。
也可以用 async-profiler 的 native 分配事件:
1
./profiler.sh -d <duration> -e malloc -f malloc.html <pid>
但这个 case 里受采样时机、符号信息和调用路径的影响,没拿到能定位问题的完整调用栈。
关于 jemalloc profiling 定位 native 内存泄漏的方法,可以参考上一篇文章。
5. 从 Netty 统计与 RSS 的差值入手
业务侧用 Netty 分配器指标统计直接内存:
1
PooledByteBufAllocator.DEFAULT.metric().usedDirectMemory();
监控显示 Netty 使用量在阈值内,但 pmap 汇总的匿名映射 RSS 明显更大。这说明至少存在两种统计口径:
- Netty 视角:自己管着、还在用的内存块;
- 操作系统视角:进程当前驻留在物理内存里的页面。
上层释放了内存,分配器可能继续缓存这些页面。Netty 的使用量会降,RSS 不一定同步降。
6. 在 Unsafe.allocateMemory 抓 Java 调用栈
在可控测试环境里,用带调试能力的 JDK 在 Unsafe.allocateMemory 路径上加了采样日志。至少记录:线程名、申请字节数、时间戳、返回地址、Java 调用栈,加个采样率或最小分配阈值避免日志风暴。
拿到了核心调用链:
1
2
3
4
5
6
7
8
9
10
11
12
jdk.internal.misc.Unsafe.allocateMemory
sun.misc.Unsafe.allocateMemory
io.netty.util.internal.PlatformDependent0.allocateDirectNoCleaner
io.netty.util.internal.PlatformDependent.allocateDirectNoCleaner
io.netty.buffer.PoolArena$DirectArena.allocateDirect
io.netty.buffer.PoolArena$DirectArena.newChunk
io.netty.buffer.PoolArena.allocateNormal
io.netty.buffer.PooledByteBufAllocator.newDirectBuffer
io.netty.buffer.AbstractByteBufAllocator.directBuffer
io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read
io.netty.channel.nio.NioEventLoop.run
java.lang.Thread.run
确认了堆外分配走的是 Netty 的 allocateDirectNoCleaner 路径,底层 Unsafe.allocateMemory。但这只解释了”谁在申请”,还没解释”释放了为什么 RSS 不降”。
7. strace 看 mmap 和 munmap 是否匹配
继续跟内存相关的系统调用:
1
strace -f -k -p <pid> -e mmap,munmap,brk
看统计的话:
1
strace -f -p <pid> -e mmap,munmap,brk -c
结果 mmap 和 munmap 的调用次数明显不对等:进程一直在申请新映射,但没有以同样的节奏释放。结合 pmap 里重复出现的大块匿名映射,链路已经比较清晰了:
- Netty 频繁申请直接内存;
Unsafe.allocateMemory最终进 glibcmalloc;- 高并发线程触发或使用多个 Arena;
- Netty 释放了部分内存,glibc 留着在 Arena 里复用;
- Netty 统计的”在用内存”降了,但操作系统 RSS 不降。
8. glibc Arena 为什么让 RSS 膨胀
glibc malloc 为了减少多线程分配锁竞争,维护了一个 Main Arena 和多个线程相关的 Arena。不同线程可以在不同 Arena 上操作,并行分配,吞吐量上去了。
代价呢:
- 每个 Arena 都可能保留空闲块;
- 空闲块只有满足特定条件时才会通过
malloc_trim、munmap等方式归还; - 跨 Arena 的空闲空间没法合并;
- 线程多、分配模式不规则时,缓存和碎片会让 RSS 远高于上层统计值。
所以”应用释放了内存”不等于”操作系统 RSS 马上降”:
1
2
3
4
RSS ≈ 仍在使用的内存
+ 分配器缓存
+ 内外部碎片
+ 其他驻留映射
Netty 指标和 RSS 之间那 N GB 的差值,就是 glibc Arena 里还没还给操作系统的页面。
9. 方案一:HotSpot Native Heap Trimming
OpenJDK 在较新版本里提供了周期性 Native Heap Trimming,定时调 glibc malloc_trim(3),把未使用的 native heap 页面还给操作系统。
相关 JBS issue:
- JDK-8293114: JVM support for Linux native heap trimming
- JDK-8315028: Backport for JDK 17
- JDK-8314107: Backport for JDK 21
周期性触发
1
2
3
4
java \
-XX:+UnlockExperimentalVMOptions \
-XX:TrimNativeHeapInterval=10000 \
-jar app.jar
部分新版本可能已经不需要实验选项了。先确认自己 JDK 是否支持:
1
java -XX:+PrintFlagsFinal -version | grep TrimNativeHeapInterval
别无脑设很短的间隔。malloc_trim 要扫描和回收空闲页,太频繁会带来 CPU 开销、锁竞争和延迟抖动。建议在压测环境里对比:
- RSS 回落幅度;
- trim 单次耗时;
- CPU 使用率;
- P99/P999 延迟;
- 后续重新分配导致的 page fault。
观察 trim 日志
1
java -Xlog:trimnative=debug:nativeheap.log ...
手动触发
1
jcmd <pid> System.trim_native_heap
手动触发很适合验证”高 RSS 是不是 glibc 可回收缓存造成的”——如果命令执行后 RSS 明显下降,就是很硬的证据了。
相关 issue:JDK-8268893: Add a JCmd to trim the glibc heap
10. 方案二:换成 jemalloc
另一种思路是通过 LD_PRELOAD 切分配器:
1
LD_PRELOAD=/path/to/libjemalloc.so java -jar app.jar
jemalloc 和 glibc 的 Arena、size class、decay、页面回收策略都不一样。在某些高并发 Java/Netty 场景里,确实能降低 RSS 峰值、改善回收可预测性。
但切换分配器不是免费午餐,得用真实负载验证:
- 吞吐和尾延迟有没有变化;
- RSS 峰值和稳态值有没有下降;
- page fault 有没有增加;
- native 分配 CPU 开销怎么样;
- 和用的 JNI 库兼不兼容;
- decay、tcache、Arena 数量是不是适合当前负载。
也可以试更激进的回收配置:
1
MALLOC_CONF="narenas:1,tcache:false,dirty_decay_ms:0,muzzy_decay_ms:0"
但这类配置会显著改变分配吞吐、缓存命中和锁竞争,千万别不测试就上生产。
11. Netty 参数也别忘了查
调用栈里看到用了 allocateDirectNoCleaner,还得确认 Netty 自己的直接内存限制:
1
-Dio.netty.maxDirectMemory=<bytes>
-XX:MaxDirectMemorySize 和 -Dio.netty.maxDirectMemory 的关系取决于 Netty 版本、平台能力和具体分配路径,尤其在 no-cleaner 路径下,不能假设 JVM 参数就能约束 Netty 的全部直接内存。
建议验证:
- 确认 Netty 版本和
PlatformDependent的参数解析逻辑; - 启动时打印实际生效的 direct memory limit;
- 构造可控分配测试,看超过阈值后的表现;
- 对照 Netty metric、NMT、
pmap、RSS; - 区分”上层在用”和”分配器已缓存”的内存。
顺便提一嘴:MALLOC_ARENA_MAX 可以限制 Arena 数量,但值太小会加剧分配锁竞争。当压测变量用还行,别当默认修复方案。
12. 一套可复用的排查流程
以后再碰到类似问题,按这个顺序走:
先建统一时间窗口
同时记录:容器 RSS、Heap used/committed、Netty direct memory、NMT summary、线程数、请求吞吐延迟、Full GC、page fault。数据不在同一时间窗口的话,差值分析很容易失真。
区分容量、使用量、驻留量
reserved:预留的虚拟地址空间;committed:已提交的内存;- allocator used:上层认为还在用的;
- RSS:实际驻留在物理内存里的。
别把不同口径的指标直接对比。
逐层归因
1
2
3
4
5
6
业务/框架指标
→ JVM NMT
→ Java/Native 分配调用栈
→ pmap/smaps 地址区间
→ mmap/munmap/brk 系统调用
→ 手动 trim 或替换分配器验证
用可逆实验验证
优先低风险、结果明确的实验:
- 手动执行一次
System.trim_native_heap; - 隔离环境降低并发线程数;
- 对比 glibc 和 jemalloc;
- 调整 Netty direct memory limit;
- 调整
MALLOC_ARENA_MAX; - 相同流量模型下对比 RSS 和尾延迟。
别靠一个工具的一次截图就宣布根因。
13. 总结
这次排查最值得带走的三个点:
- 框架内存指标不等于进程 RSS。 Netty 统计的是自己管理视角下的使用量,操作系统统计的是驻留页,中间还隔着 libc 分配器。
- 定位分配来源和解释 RSS 放大是两个问题。
Unsafe.allocateMemory调用栈证明内存来自 Netty,pmap、strace和 trim 实验才解释释放后 RSS 为什么不降。 - 修复得同时考虑内存和性能。 周期性
malloc_trim、jemalloc、MALLOC_ARENA_MAX、Netty 限制都可能有效,但也都可能引入 CPU、锁竞争、page fault 或尾延迟的代价。
当 Java 服务出现”堆不大、NMT 好像能解释一部分、RSS 却一直涨”时,把 glibc Arena 和分配器缓存放进排查清单。真正靠得住的结论,由上层调用栈、地址空间分布、系统调用和可逆实验一起组成。
相关阅读:使用 jemalloc 定位 Java Native Memory 泄漏——另一个 native 内存排查案例,根因在
GZIPOutputStream未关闭。一次 Flink Full GC 频繁问题排查:真正的根因竟是 CPU Throttling——GC 不一定起于 JVM,有时在操作系统层。
参考资料
- JDK-8293114: JVM support for Linux native heap trimming
- JDK-8315028: Backport for JDK 17
- JDK-8314107: Backport for JDK 21
- JDK-8268893: Add a JCmd to trim the glibc heap
- HotSpot developers mailing list: native heap trimming discussion
- Oracle Java Troubleshooting Guide: Native Memory Tracking
- glibc malloc internals overview