关于jvm:一次Java内存占用高的排查案例解释了我对内存问题的所有疑问

4次阅读

共计 6520 个字符,预计需要花费 17 分钟才能阅读完成。

原创:扣钉日记(微信公众号 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 个别有十分多的内存段,重点查看哪些内存段呢?
有两种思路,如下:

  1. 查看那些占用内存较大的内存段,如下:

    pmap -x 1 | sort -nrk3 | less 

    能够发现咱们过程有十分多的 64M 的内存块,而我同时看了看其它 java 服务,发现 64M 内存块则少得多。

  2. 查看一段时间后新增了哪些内存段,或哪些变大了,如下:
    在不同的工夫点屡次保留 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

阐明:

  1. Linux 将过程内存虚构为伪文件 /proc/$pid/mem,通过它即可查看过程内存中的数据。
  2. tail 用于偏移到指定内存段的起始地址,即 pmap 的第一列,head 用于读取指定大小,即 pmap 的第二列。
  3. 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.so
java -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 内存占用次要蕴含如下局部:

  1. Java 堆,-Xmx 选项限度的就是 Java 堆的大小,可通过 jcmd 命令观测。
  2. Java 非堆,蕴含 Metaspace、Code Cache、间接内存(DirectByteBuffer、MappedByteBuffer)、Thread、GC,它可通过 arthas memory 命令或 NMT 原生内存追踪观测。
  3. native 分配内存,即间接调用 malloc 调配的,如 JNI 调用、磁盘与网络 io 操作等,可通过 pmap 命令、malloc_stats 函数观测,或应用 tcmalloc 检测泄露点。
  4. 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

正文完
 0