遇见了一个glibc造成的显存回收问题,查找缘由和实验的的过程是比较有意思的,主要会涉及到下边这种:
背景
前段时间有朋友反馈一个javaRPC项目在容器中启动完没多久就由于容器显存超过配额1500M被杀,我帮忙一起看了一下。
在本地Linux环境中跑了一下,JVM启动完通过top听到的RES显存就早已超过了1.5G,如右图所示。
首先想到查看显存的分布情况,使用arthas是一个不错的选择,输入dashboard查看当前的显存使用情况,如下所示。
可以看见发觉进程占用的堆显存只有300M左右,非堆(non-heap)也很小,加上去才500M左右,那显存被谁消耗了。这就要瞧瞧JVM显存的几个组成部份了。
JVM的显存都耗在那里
JVM的显存大约分为下边这几个部份
接出来怀疑堆外显存和native显存可能存在泄漏问题。堆外显存可以通过开启NMT(NativeMemoryTracking)来跟踪,加上-XX:NativeMemoryTracking=detail再度启动程序,也发觉显存占用值远大于RES显存占用值。
由于NMT不会追踪native(C/C++代码)申请的显存,到这儿基本早已怀疑是native代码造成的。我们项目中不仅rocksdb用到了native的代码就只剩下JVM自己了。接出来继续排查。
Linux熟悉的64M显存问题
使用pmap-x查看显存的分布,发觉有大量的64M左右的显存区域,如右图所示。
这个现象太熟悉了,这不是linuxglibc中精典的64M显存问题吗?
ptmalloc2与arena
Linux中malloc的初期版本是由DougLea实现的,它有一个严重问题是显存分配只有一个分配区(arena),每次分配显存都要对分配区加锁,分配完释放锁,致使多线程下并发申请释放显存锁的竞争激烈。arena词组的字面意思是「舞台;竞技场」,可能就是显存分配库演出的主战场的意思吧。
于是修修复补又一个版本,你不是多线程锁竞争厉害吗,那我多开几个arena,锁竞争的情况自然会好转。
WolframGloger在DougLea的基础上改进促使Glibc的malloc可以支持多线程,这就是ptmalloc2。在只有一个分配区的基础上,降低了非主分配区(nonmainarena),主分配区只有一个,非主分配可以有好多个,具体个数前面会说。
当调用malloc分配显存的时侯,会先查看当前线程私有变量中是否早已存在一个分配区arena。假如存在,则尝试会对这个arena加锁
主分配区可以使用brk和mmap两种形式申请虚拟显存,非主分配区只能mmap。glibc每次申请的虚拟显存区块大小是64MB,glibc再依照应用须要切割为小块零售。
这就是linux进程显存分布中典型的64M问题,那有多少个这样的区域呢?在64位系统下,这个值等于8*numberofcores,倘若是4核,则最多有32个64M大小的显存区域。
莫非是由于arena数目太多了造成的?
设置MALLOC_ARENA_MAX=1有用吗?
加上这个环境变量启动java进程,确实64M的显存区域就不见了,而且集中到了一个大的接近700M的显存区域中,如右图所示。
到这儿,显存占用高的问题并没有解决,接出来继续折腾。
是谁在分配释放显存
接出来,写一个自定义的malloc函数hook。hook实际上就是借助LD_PRELOAD环境变量替换glibc中的函数实现,在malloc、free、realloc、calloc这几个函数调用前先复印日志然后再调用实际的方式。以malloc函数的hook为例,部份代码如下所示。
// 获取线程 id 而不是 pidstatic pid_t gettid() { return syscall(__NR_gettid);}static void *(*real_realloc)(void *ptr, size_t size) = 0;void *malloc(size_t size) { void *p; if (!real_malloc) { real_malloc = dlsym(RTLD_NEXT, "malloc"); if (!real_malloc) return NULL; } p = real_malloc(size); printLog("[0xx] malloc(%u)= 0xx ", GETRET(), size, p); return p;}复制代码
设置LD_PRELOAD启动JVM
LD_PRELOAD=/app/my_malloc.so java -Xms -Xmx -jar ....复制代码
在JVM启动的过程中同时开启jstack复印线程堆栈,当jvm进程完全启动之后,查看malloc的输出日志和jstack的日志。
这儿输出了一个几十M的malloc日志,内容如下所示。日志的第一列是线程id。
使用awk处理上的日志linux命令tar,统计线程处理的次数。
cat malloc.log | awk '{print $1}' | less| sort | uniq -c | sort -rn | less 284881 16342 135 16341 57 16349 16 16346 10 16345 9 16351 9 16350 6 16343 5 16348 4 16347 1 16352 1 16344复制代码
可以看见线程16342分配释放显存最为凶恶,那这个线程在做哪些呢?在jstack的输出日志中搜索16342(0x3fd6)线程,可以看见好多次都在处理jar包的解压。
java处理zip使用的是java.util.zip.Inflater类,调用它的end方式会释放native的显存。听到这儿我以为是end方式没有调用造成的,这些的确是有可能的,java.util.zip.InflaterInputStream类的close方式在一些场景下是不会调用Inflater.end方式,如下所示。
高兴的有点早了。实际上并非这么,即使下层调用没有调用Inflater.end,Inflater类的finalize方式也调用了end方式,我强行GC试一下。
jcmd `pidof java` GC.run复制代码
通过GC日志确认确实触发了FullGC,但显存并没有降出来。通过valgrind等工具查看显存泄漏linux线程栈大小,也没有哪些发觉。
假如说JVM本身的实现没有显存外泄,那就是glibc自己的问题了,调用free把显存还给了glibc,glibc并没有最终释放,这个显存二道贩子自己把显存截胡了。
glibc的显存分配原理
这是一个很复杂的话题,假若这一块完全不熟悉,建议你先瞧瞧下边这几个资料。
总体来看,须要理解下边这几个概念:
显存分配区Arena
显存分配区Arena的概念在上面介绍过,也比较简单。为了更直观的了解heap的内部结构,可以使用gdb的heap扩充包,比较常见的有
这种也是打CTF堆相关的题目可以使用的工具,接出来使用的是Pwngdb工具来介绍。输入arenainfo可以查看Arena的列表,如下所示。
在这个反例中,有1个主分配区Arena和15个非主分配区Arena。
显存chunk的结构
chunk的概念也比较好理解红帽子linux,chunk的字面意思是「大块」,是面向用户而言的,用户申请分配的显存用chunk表示。
可能这样说还是不好理解,下边一个实际的事例来说明。
#include #include #include int main(void) { void *p; p = malloc(1024); printf("%p", p); p = malloc(1024); printf("%p", p); p = malloc(1024); printf("%p", p); getchar(); return (EXIT_SUCCESS);}复制代码
这段代码分配了三次1k大小的显存,显存地址是:
./malloc_test0x6020100x6024200x602830复制代码
pmap输出的结果如下所示。
可以见到第一次分配的显存区域地址0x602010在这块显存区域的基址(0x602000)偏斜量16(0x10)的地方。
再来看第二次分配的显存区域地址0x602420与0x602010的差值是1,040=1024+16(0x10)
第三次分配的显存以这种推是一样的,每次都空了0x10个字节。这中间空下来的0x10是哪些呢?
使用gdb查看一下就很清楚了,查看这三个显存地址向前0x10字节开始的32字节区域。
可以看见实际上储存的是0x0411,
0x0411 = 1024(0x0400) + 0x10(block size) + 0x01复制代码
其中1024很显著,是用户申请的显存区域大小,0x11是哪些?由于显存分配就会对齐,实际上最低3位对显存大小没有哪些意义,最低3位被借用来表示特殊涵义。一个使用中的chunk结构如右图所示。
最低三位的涵义如下:
这个事例中最低三位是b001,A=0表示这个chunk不属于主分配区,M=0,表示是从heap区域分配的,P=1表示前一个chunk在使用中。
从glibc源码中可以看的更清楚一些。
#define PREV_INUSE 0x1/* extract inuse bit of previous chunk */#define prev_inuse(p) ((p)->size & PREV_INUSE)#define IS_MMAPPED 0x2/* check for mmap()'ed chunk */#define chunk_is_mmapped(p) ((p)->size & IS_MMAPPED)#define NON_MAIN_ARENA 0x4/* check for chunk from non-main arena */#define chunk_non_main_arena(p) ((p)->size & NON_MAIN_ARENA)#define SIZE_BITS (PREV_INUSE|IS_MMAPPED|NON_MAIN_ARENA)/* Get size, ignoring use bits */#define chunksize(p) ((p)->size & ~(SIZE_BITS))复制代码
后面介绍的是allocatdchunk的结构,被free之后的空闲chunk的结构不太一样,还有一个称为topchunk的结构,这儿不再展开。
chunk的回收站bins
bin的字面意思是「垃圾箱」。显存在应用调用free释放之后chunk不一定会立即归还给系统,而是就被glibc这个二道贩子截胡。这也是为了效率的审视,当用户上次恳求分配显存时linux线程栈大小,ptmalloc2会先尝试从空闲chunk显存池中找到一个合适的显存区域返回给应用,这样就防止了频繁的brk、mmap系统调用。
为了更高效的管理显存分配和回收,ptmalloc2中使用了一个字段,维护了128个bins。
这种bin的介绍如下。
具体到本例中,在Pwngdb中可以查看每位arena的bins信息。如右图所示。
fastbin
通常情况下,程序在运行过程中会频繁和分配一些小的显存,假如那些小显存被频繁的合并和切割,效率会比较低下,因而ptmalloc在不仅里面的bin组成部份,还有一个特别重要的结构fastbin,专门拿来管理小的显存堆块。
64位系统中,不小于128字节的显存堆块被释放之后,首先会被放在fastbin中,fastbin中的chunk的P标记一直为1,fastbin的堆块会被当作使用中,因而不会被合并。
在分配大于128字节的显存时,ptmalloc会首先在fastbin中查找对应的空闲块,倘若没有才去其它bins中查找。
换个角度来看,fastbin可以看做是smallbin的一道缓存。
显存碎片与回收
接出来我们来做一个实验,瞧瞧显存碎片怎样影响glibc的显存回收,代码如下所示。
#include #include #include #define K (1024)#define MAXNUM 500000int main() { char *ptrs[MAXNUM]; int i; // malloc large block memory for (i = 0; i < MAXNUM; ++i) { ptrs[i] = (char *)malloc(1 * K); memset(ptrs[i], 0, 1 * K); } //never free,only 1B memory leak, what it will impact to the system? char *tmp1 = (char *)malloc(1); memset(tmp1, 0, 1); printf("%s", "malloc done"); getchar(); printf("%s", "start free memory"); for(i = 0; i < MAXNUM; ++i) { free(ptrs[i]); } printf("%s", "free done"); getchar(); return 0;}复制代码
程序中先malloc了一块500M的显存,之后再malloc了1B的显存(实际上比1B要大一点,不过不影响说明),接出来free掉那500M的显存。
在free之前的显存占用如下所示。
在调用free之后,使用top查看RES的结果如下。
可以看见实际上glibc并没有把显存归还给系统。而是放在了它自己的unsortedbin中,使用gdb的arenainfo工具可以看得很清楚。
0x1efe9200用十补码表示是520,000,000,正是我们刚才释放的500M左右的显存。
假如我把代码中的第二次malloc注释掉,glibc是可以立即释放显存的。
这个实验早已比较能证明显存碎片对glibc显存消耗的影响了。
glibc与malloc_trim
glibc中提供了malloc_trim函数,文档内容在这儿:
/linux/man-p…
从文档来看,应当只是归还堆顶上全部的空余显存给系统,没有办法归还堆顶显存中的空洞。并且实际上并非这么,在本例中,调用malloc_trim真正归还了500M以上的显存给系统。
gdb --batch --pid `pidof java` --ex 'call malloc_trim()'复制代码
看glibc的源码,malloc_trim的底层实现早已做了更改,是遍历所有的arena,之后对每位arena遍历所有的bin,执行madvise系统调用告知MADV_DONTNEED,通知内核这块可以回收了。
通过Systemtap脚本可以同步确认这一点。
probe begin { log("begin to probe")}probe kernel.function("SYSC_madvise") { if (ppid() == target()) { printf("in %s: %s", probefunc(), $$vars) print_backtrace(); }}复制代码
执行malloc_trim时,有大量的madvise系统调用,如右图所示。
这儿的behavior=0x4表示是MADV_DONTNEED,len_in表示厚度,start表示显存开始地址。
malloc_trim对前一个小节中的显存碎片实验同样是生效的。
jemalloc登场
既然是由于glibc的显存分配策略引起的碎片化显存回收问题,致使看上去像是显存泄漏,那有没有更好一点的对碎片化显存的malloc库呢?业界常见的有google家的tcmalloc和facebook家的jemalloc。
这两个我都试过,jemalloc的疗效比较显著,使用LD_PRELOAD挂载jemalloc库。
LD_PRELOAD=/usr/local/lib/libjemalloc.so复制代码
重新启动Java程序,可以看见显存RES消耗减少到了1G左右
使用jemalloc比glibc小了500M左右,只比malloc_trim的900多M多了一点点。
至于为何jemalloc在这个场景那么厉害,又是一个复杂的话题,这儿先不展开,有时间可以详尽介绍一下jemalloc的实现原理。
经多次实验,malloc_trim有机率会造成JVMCrash,使用的时侯须要当心。
经过替换ptmalloc2为jemalloc,进程的显存RES占用明显升高,至于性能、稳定性还需进一步观察。