原创:扣钉日记(微信公众号ID:codelogs),欢送分享,非公众号转载保留此申明。
问题景象
7月25号,咱们一服务的内存占用较高,约13G,容器总内存16G,占用约85%,触发了内存报警(阈值85%),而咱们是按容器内存60%(9.6G)的比例配置的JVM堆内存。看了下其它服务,同样的堆内存配置,它们内存占用约70%~79%,此服务比其它服务内存占用稍大。
那为什么此服务内存占用稍大呢,它存在内存泄露吗?
排查步骤
1. 查看Java堆占用与gc状况
jcmd 1 GC.heap_info
jstat -gcutil 1 1000
可见堆应用状况失常。
2. 查看非堆占用状况
查看监控仪表盘,如下:
arthas的memory命令查看,如下:
可见非堆内存占用也失常。
3. 查看native内存
Linux过程的内存布局,如下:
linux过程启动时,有代码段、数据段、堆(Heap)、栈(Stack)及内存映射段,在运行过程中,应用程序调用malloc、mmap等C库函数来应用内存,C库函数外部则会视状况通过brk零碎调用扩大堆或应用mmap零碎调用创立新的内存映射段。
而通过pmap命令,就能够查看过程的内存布局,它的输入样例如下:
能够发现,过程申请的所有虚拟内存段,都在pmap中可能找到,相干字段解释如下:
- Address:示意此内存段的起始地址
- Kbytes:示意此内存段的大小(ps:这是虚拟内存)
- RSS:示意此内存段理论调配的物理内存,这是因为Linux是提早分配内存的,过程调用malloc时Linux只是调配了一段虚拟内存块,直到过程理论读写此内存块中局部时,Linux会通过缺页中断真正调配物理内存。
- Dirty:此内存段中被批改过的内存大小,应用mmap零碎调用申请虚拟内存时,能够关联到某个文件,也可不关联,当关联了文件的内存段被拜访时,会主动读取此文件的数据到内存中,若此段某一页内存数据后被更改,即为Dirty,而对于非文件映射的匿名内存段(anon),此列与RSS相等。
- Mode:内存段是否可读(r)可写(w)可执行(x)
- Mapping:内存段映射的文件,匿名内存段显示为anon,非匿名内存段显示文件名(加-p可显示全门路)。
因而,咱们能够找一些内存段,来看看这些内存段中都存储的什么数据,来确定是否有泄露。但jvm个别有十分多的内存段,重点查看哪些内存段呢?
有两种思路,如下:
查看那些占用内存较大的内存段,如下:
pmap -x 1 | sort -nrk3 | less
能够发现咱们过程有十分多的64M的内存块,而我同时看了看其它java服务,发现64M内存块则少得多。查看一段时间后新增了哪些内存段,或哪些变大了,如下:
在不同的工夫点屡次保留pmap命令的输入,而后通过文本比照工具查看两个工夫点内存段散布的差别。pmap -x 1 > pmap-`date +%F-%H-%M-%S`.log
icdiff pmap-2023-07-27-09-46-36.log pmap-2023-07-28-09-29-55.log | less -SR
能够看到,一段时间后,新调配了一些内存段,看看这些变动的内存段里存的是什么内容!
tail -c +$((0x00007face0000000+1)) /proc/1/mem|head -c $((11616*1024))|strings|less -S
阐明:
- Linux将过程内存虚构为伪文件/proc/$pid/mem,通过它即可查看过程内存中的数据。
- tail用于偏移到指定内存段的起始地址,即pmap的第一列,head用于读取指定大小,即pmap的第二列。
strings用于找出内存中的字符串数据,less用于查看strings输入的字符串。
通过查看各个可疑内存段,发现有不少相似咱们一自研音讯队列的响应格局数据,通过与音讯队列团队单干,找到了相干的音讯topic,并最终与相干研发确认了此topic音讯最近刚迁徙到此服务中。4. 查看发http申请代码
因为发送音讯是走http接口,故我在工程中搜寻调用http接口的相干代码,发现一处代码中创立的流对象没有敞开,而GZIPInputStream这个类刚好会间接调配到native内存。
其它办法
本次问题,通过查看内存中的数据找到了问题,还是有些碰运气的。这须要内存中刚好有一些十分有代表性的字符串,因为非字符串的二进制数据,根本无奈剖析。
如果查看内存数据无奈找到要害线索,还可尝试以下几个办法:
5. 开启JVM的NMT原生内存追踪性能
增加JVM参数-XX:NativeMemoryTracking=detail
开启,应用jcmd查看,如下:
jcmd 1 VM.native_memory
NMT只能察看到JVM治理的内存,像通过JNI机制间接调用malloc调配的内存,则感知不到。
6. 查看被glibc内存分配器缓存的内存
JVM等原生应用程序调用的malloc、free函数,理论是由根底C库libc提供的,而linux零碎则提供了brk、mmap、munmap这几个零碎调用来调配虚拟内存,所以libc的malloc、free函数理论是基于这些零碎调用实现的。
因为零碎调用有肯定的开销,为减小开销,libc实现了一个相似内存池的机制,在free函数调用时将内存块缓存起来不归还给linux,直到缓存内存量达到肯定条件才会理论执行偿还内存的零碎调用。
所以过程占用内存比实践上要大些,肯定水平上是失常的。
malloc_stats函数
通过如下命令,能够确认glibc库缓存的内存量,如下:
# 查看glibc内存分配情况,会输入到过程规范谬误中gdb -q -batch -ex 'call malloc_stats()' -p 1
如上,Total (incl. mmap)示意glibc调配的总体状况(蕴含mmap调配的局部),其中system bytes示意glibc从操作系统中申请的虚拟内存总大小,in use bytes示意JVM正在应用的内存总大小(即调用glibc的malloc函数后且没有free的内存)。
能够发现,glibc缓存了快500m的内存。
注:当我对jvm过程中执行malloc_stats后,我发现它显示的in use bytes要少得多,通过查看JVM代码,发现JVM在为Java Heap、Metaspace分配内存时,是间接通过mmap函数调配的,而这个函数是间接封装的mmap零碎调用,不走glibc内存分配器,故in use bytes会小很多。
malloc_trim函数
glibc实现了malloc_trim函数,通过brk或madvise零碎调用,偿还被glibc缓存的内存,如下:
# 回收glibc缓存的内存gdb -q -batch -ex 'call malloc_trim(0)' -p 1
能够发现,执行malloc_trim后,RSS缩小了约250m内存,可见内存占用高并不是因为glibc缓存了内存。
注:通过gdb调用C函数,会有肯定概率造成jvm过程解体,需谨慎执行。
7. 应用tcmalloc或jemalloc的内存泄露检测工具
glibc的默认内存分配器为ptmalloc2,但Linux提供了LD_PRELOAD机制,使得咱们能够更换为其它的内存分配器,如业内比拟成熟的tcmalloc或jemalloc。
这两个内存分配器除了实现了内存调配性能外,还提供了内存泄露检测的能力,它们通过hook过程的malloc、free函数调用,而后找到那些调用了malloc后始终没有free的中央,那么这些中央就可能是内存泄露点。
HEAPPROFILE=./heap.log HEAP_PROFILE_ALLOCATION_INTERVAL=104857600 LD_PRELOAD=./libtcmalloc_and_profiler.sojava -jar xxx ...pprof --pdf /path/to/java heap.log.xx.heap > test.pdf
tcmalloc下载地址:https://github.com/gperftools/gperftools
如上,能够发现内存泄露点来自Inflater对象的init和inflateBytes办法,而这些办法是通过JNI调用实现的,它会申请native内存,通过查看代码,发现GZIPInputStream的确会创立并应用Inflater对象,如下:
而它的close办法,会调用Inflater的end办法来偿还native内存,因为咱们没有调用close办法,故相关联的native内存无奈偿还。
能够发现,tcmalloc的泄露检测只能看到native栈,如想看到Java栈,可思考配合应用arthas的profile命令,如下:
# 获取调用inflateBytes时的调用栈profiler execute 'start,event=Java_java_util_zip_Inflater_inflateBytes,alluser'# 获取调用malloc时的调用栈profiler execute 'start,event=malloc,alluser'
如果代码不修复,内存会始终涨吗?
通过查看代码,发现Inflater实现了finalize办法,而finalize办法调用了end办法。
也就是说,若GC时Inflater对象被回收,相关联的原生内存是会被free的,所以内存会始终涨上来导致过程被oom kill吗?maybe,这取决于GC触发的阈值,即在GC触发前JVM中会保留的垃圾Inflater对象数量,保留得越多native内存占用越大。
但我发现一个乏味景象,我通过jcmd强行触发了一次Full GC,如下:
jcmd 1 GC.run
实践上native内存应该会free,但我通过top察看过程rss,发现根本没有变动,但我查看malloc_stats的输入,发现in use bytes的确少了许多,这阐明Full GC后,JVM的确偿还了Inflater对象关联的原生内存,但它们都被glibc缓存起来了,并没有归还给操作系统。
于是我再执行了一次malloc_trim,强制glibc偿还缓存的内存,发现过程的rss降了下来。
编码最佳实际
这个问题是因为InputStream流对象未敞开导致的,在Java中流对象(FileInputStream)、网络连接对象(Socket)个别都关联了原生资源,记得在finally中调用close办法偿还原生资源。
而GZIPInputstream、Inflater是JVM堆外内存泄露的常见问题点,review代码发现有应用这些类时,须要保持警惕。
JVM内存常见疑难
为什么我设置了-Xmx为10G,top中看到的rss却大于10G?
依据下面的介绍,JVM内存占用散布大略如下:
能够发现,JVM内存占用次要蕴含如下局部:
- Java堆,-Xmx选项限度的就是Java堆的大小,可通过jcmd命令观测。
- Java非堆,蕴含Metaspace、Code Cache、间接内存(DirectByteBuffer、MappedByteBuffer)、Thread、GC,它可通过arthas memory命令或NMT原生内存追踪观测。
- native分配内存,即间接调用malloc调配的,如JNI调用、磁盘与网络io操作等,可通过pmap命令、malloc_stats函数观测,或应用tcmalloc检测泄露点。
- glibc缓存的内存,即JVM调用free后,glibc库缓存下来未归还给操作系统的局部,可通过pmap命令、malloc_stats函数观测。
所以-Xmx的值,肯定要小于容器/物理机的内存限度,依据教训,个别设置为容器/物理机内存的65%左右较为平安,可思考应用比例的形式代替-Xms与-Xmx,如下:
-XX:MaxRAMPercentage=65.0 -XX:InitialRAMPercentage=65.0 -XX:MinRAMPercentage=65.0
top中VIRT与RES是什么区别?
- VIRT:过程申请的虚拟内存总大小。
- RES:过程在读写它申请的虚拟内存页面后,会触发Linux的内存缺页中断,进而导致Linux为该页调配理论内存,即RSS,在top中叫RES。
- SHR:过程间共享的内存,如libc.so这个C动静库,简直会被所有过程加载到各自的虚拟内存空间并应用,但Linux理论只调配了一份内存,各个过程只是通过内存页表关联到此内存而已,留神,RSS指标个别也蕴含SHR。
通过top、ps或pidstat可查问过程的缺页中断次数,如下:
top中能够通过f交互指令,将mMin、mMaj列显示进去。
minflt示意轻微缺页,即Linux调配了一个内存页给过程,而majflt示意次要缺页,即Linux除了要分配内存页外,还须要从磁盘中读取数据到内存页,个别是内存swap到了磁盘后再拜访,或应用了内存映射技术读取文件。
为什么top中JVM过程的VIRT列(虚拟内存)那么大?
能够看到,咱们一Java服务,申请了约30G的虚拟内存,比RES理论内存5.6G大很多。
这是因为glibc为了解决多线程内存申请时的锁竞争问题,创立了多个内存调配区Arena,而后每个Arena都有一把锁,特定的线程会hash到特定的Arena中去竞争锁并申请内存,从而缩小锁开销。
但在64位零碎里,每个Arena去零碎申请虚拟内存的单位是64M,而后按需拆分为小块调配给申请方,所以哪怕线程在此Arena中只申请了1K内存,glibc也会为此Arena申请64M。
64位零碎里glibc创立Arena数量的默认值为CPU外围数的8倍,而咱们容器运行在32核的机器,故glibc会创立32*8=256
个Arena,如果每个Arena起码申请64M虚拟内存的话,总共申请的虚拟内存为256*64M=16G
。
而后JVM是间接通过mmap申请的堆、MetaSpace等内存区域,不走glibc的内存分配器,这些加起来大概14G,与走glibc申请的16G虚拟内存加起来,总共申请虚拟内存30G!
当然,不用惊恐,这些只是虚拟内存而已,它们多一些并没有什么影响,毕竟64位过程的虚拟内存空间有2^48字节那么大!
为什么jvm启动后一段时间内内存占用越来越多,存在内存泄露吗?
如下,是咱们一服务重启后运行快2天的内存占用状况,能够发现内存始终从45%涨到了62%,8G的容器,上涨内存大小为1.36G!
但咱们这个服务其实没有内存泄露问题,因为JVM为堆申请的内存是虚拟内存,如4.8G,但在启动后JVM一开始可能理论只应用了3G内存,导致Linux理论只调配了3G。
而后在gc时,因为会复制存活对象到堆的闲暇局部,如果正好复制到了以前未应用过的区域,就又会触发Linux进行内存调配,故一段时间内内存占用会越来越多,直到堆的所有区域都被touch到。
而通过增加JVM参数-XX:+AlwaysPreTouch
,能够让JVM为堆申请虚拟内存后,立刻把堆全副touch一遍,使得堆区域全都被调配物理内存,而因为Java过程次要流动在堆内,故后续内存就不会有很大变动了,咱们另一服务增加了此参数,内存体现如下:
能够看到,内存上涨幅度不到2%,无此参数能够进步内存利用度,加此参数则会使利用运行得更稳固。
如咱们之前一服务一周内会有1到2次GC耗时超过2s,当我增加此参数后,再未呈现过此状况。这是因为当无此参数时,若GC拜访到了未读写区域,会触发Linux分配内存,大多数状况下此过程很快,但有极少数状况下会较慢,在GC日志中则体现为sys耗时较高。
参考文章
https://sploitfun.wordpress.com/2015/02/10/understanding-glib...
https://juejin.cn/post/7078624931826794503
https://juejin.cn/post/6903363887496691719