遇到了一个 glibc 导致的内存回收问题,查找原因和实验的的过程是比较有意思的,主要会涉及到下面这些:
-
Linux 中典型的大量 64M 内存区域问题
-
glibc 的内存分配器 ptmalloc2 的底层原理
-
如何写一个自定义的 malloc hook 动态链接库 so
-
glibc 的内存分配原理(Arena、Chunk 结构、bins 等)
-
malloc_trim 对内存真正回收的影响
-
gdb 的 heap 调试工具使用
-
jemalloc 库的介绍与应用
背景
前段时间有同学反馈一个 java RPC 项目在容器中启动完没多久就因为容器内存超过配额 1500M 被杀,我帮忙一起看了一下。
在本地 Linux 环境中跑了一下,JVM 启动完通过 top 看到的 RES 内存就已经超过了 1.5G,如下图所示。
首先想到查看内存的分布情况,使用 arthas 是一个不错的选择,输入 dashboard 查看当前的内存使用情况,如下所示。
可以看到发现进程占用的堆内存只有 300M 左右,非堆(non-heap)也很小,加起来才 500 M 左右,那内存被谁消耗了。这就要看看 JVM 内存的几个组成部分了。
JVM 的内存都耗在哪里
JVM 的内存大概分为下面这几个部分
-
堆(Heap):eden、metaspace、old 区域等
-
线程栈(Thread Stack):每个线程栈预留 1M 的线程栈大小
-
非堆(Non-heap):包括 code_cache、metaspace 等
-
堆外内存:unsafe.allocateMemory 和 DirectByteBuffer 申请的堆外内存
-
native (C/C++ 代码)申请的内存
-
还有 JVM 运行本身需要的内存,比如 GC 等。
接下来怀疑堆外内存和 native 内存可能存在泄露问题。堆外内存可以通过 开启 NMT(NativeMemoryTracking) 来跟踪,加上 -XX:NativeMemoryTracking=detail
再次启动程序,也发现内存占用值远小于 RES 内存占用值。
因为 NMT 不会追踪 native (C/C++ 代码)申请的内存,到这里基本已经怀疑是 native 代码导致的。我们项目中除了 rocksdb 用到了 native 的代码就只剩下 JVM 自己了。接下来继续排查。
Linux 熟悉的 64M 内存问题
使用 pmap -x 查看内存的分布,发现有大量的 64M 左右的内存区域,如下图所示。
这个现象太熟悉了,这不是 linux glibc 中经典的 64M 内存问题吗?
ptmalloc2 与 arena
Linux 中 malloc 的早期版本是由 Doug Lea 实现的,它有一个严重问题是内存分配只有一个分配区(arena),每次分配内存都要对分配区加锁,分配完释放锁,导致多线程下并发申请释放内存锁的竞争激烈。arena 单词的字面意思是「舞台;竞技场」,可能就是内存分配库表演的主战场的意思吧。
于是修修补补又一个版本,你不是多线程锁竞争厉害吗,那我多开几个 arena,锁竞争的情况自然会好转。
Wolfram Gloger 在 Doug Lea 的基础上改进使得 Glibc 的 malloc 可以支持多线程,这就是 ptmalloc2。在只有一个分配区的基础上,增加了非主分配区(non main arena),主分配区只有一个,非主分配可以有很多个,具体个数后面会说。
当调用 malloc 分配内存的时候,会先查看当前线程私有变量中是否已经存在一个分配区 arena。如果存在,则尝试会对这个 arena 加锁
-
如果加锁成功,则会使用这个分配区分配内存
-
如果加锁失败,说明有其它线程正在使用,则遍历 arena 列表寻找没有加锁的 arena 区域,如果找到则用这个 arena 区域分配内存。
主分配区可以使用 brk 和 mmap 两种方式申请虚拟内存,非主分配区只能 mmap。glibc 每次申请的虚拟内存区块大小是 64MB
,glibc 再根据应用需要切割为小块零售。
这就是 linux 进程内存分布中典型的 64M 问题,那有多少个这样的区域呢?在 64 位系统下,这个值等于 8 * number of cores
,如果是 4 核,则最多有 32 个 64M 大小的内存区域。
难道是因为 arena 数量太多了导致的?
设置 MALLOC_ARENA_MAX=1 有用吗?
加上这个环境变量启动 java 进程,确实 64M 的