JVM 内存泄露排查

805 阅读6分钟

本文已参与「新人创作礼」活动, 一起开启掘金创作之路。

一、发现问题

这段时间持续收到项目预发环境的告警,提示内存占用过高,最高将近 90%。在预发环境下,内存持续走高的问题,已经持续了一段时间。尤其在周末无人发布版本,即没有重启应用程序的情况下,内存会持续堆积,并伴随频繁触发 GC 的情况。

image.png

image.png

上图中,比较明显的看到 1.14 - 1.17 期间,Eden Space、Survivor Space、Tenured Gen 三代内存占用持续走高,GC 的频率异常频繁,怀疑存在以下问题:

  • 内存中加载的数据量过于庞大,如一次从数据库取出过多数据。
  • 集合类中有对对象的引用,使用完后未清空,使得 jvm 不能回收。
  • 代码中存在死循环或循环产生过多重复的对象实体。
  • 使用的第三方软件中的 BUG
  • 启动参数内存值设定的过小。
  • 服务器 / pod 本身配置问题。

二、问题追踪

1. 疑似点 - buff/cache 缓存

最初直接使用 free -h 命令查看服务内存的使用情况,发现其中 buff/cache 这块会占用大量空间,迅速从 7.8 -> 9.7 -> 10.1 G,因为猜测这个就是内存持续走高的原因。

buff/cache 是 linux 系统为了提升响应速度,用来将硬盘中数据缓存入内存。

经过一段时间(10 - 16 点)的观察,内存只是暂时被占用,该部分内存在应用程序需要时,可提供可用的内存空间,监控下应用程序内存仍在走高,但 buff/cache 并未有过大波动。而且后来发现在 pod 中,free -h 获取的是宿主机的内存指标,所以基本上排除了这个疑似项的原因。

2. 疑似点 - JVM 启动参数配置过小,导致频繁 GC

通过 ps aux 命令查看进程,确认我们要找的 java 进程,当然在当前预发的 pod 中我们只有一个 java 进程,ID 为 1

$ ps aux | grep java
admin   1 55.4  2.8 4609772 452828 ?      Ssl  18:28   1:49 java -jar xxx-1.0.0-SNAPSHOT.jar
admin  301  0.0  0.0 112716  2324 pts/0    S+   18:31   0:00 grep --color=auto java

通过 jmap 命令,查看下这个进程的 jvm 配置,堆栈信息、分代信息等

$ jmap -heap 1

using thread-local object allocation.
Mark Sweep Compact GC

Heap Configuration:
   MinHeapFreeRatio         = 40
   MaxHeapFreeRatio         = 70
   MaxHeapSize              = 805306368 (768.0MB)
   NewSize                  = 16777216 (16.0MB)
   MaxNewSize               = 268435456 (256.0MB)
   OldSize                  = 33554432 (32.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
New Generation (Eden + 1 Survivor Space):
   capacity = 89063424 (84.9375MB)
   used     = 4388256 (4.184967041015625MB)
   free     = 84675168 (80.75253295898438MB)
   4.927113514072848% used
Eden Space:
   capacity = 79167488 (75.5MB)
   used     = 1598464 (1.5244140625MB)
   free     = 77569024 (73.9755859375MB)
   2.019091473509934% used
From Space:
   capacity = 9895936 (9.4375MB)
   used     = 2789792 (2.660552978515625MB)
   free     = 7106144 (6.776947021484375MB)
   28.191289838576157% used
To Space:
   capacity = 9895936 (9.4375MB)
   used     = 0 (0.0MB)
   free     = 9895936 (9.4375MB)
   0.0% used
tenured generation:
   capacity = 197283840 (188.14453125MB)
   used     = 156359928 (149.11644744873047MB)
   free     = 40923912 (39.02808380126953MB)
   79.25632834397383% used

51785 interned Strings occupying 5678744 bytes.

通过上面的参数配置,结合一些最佳实践文章,所配置的参数基本符合 jvm 需求,没发现特别特殊的参数异常或过小的情况。这块比较关注的几个点:

  • 各代大小,过小会导致频繁的 GC 情况
  • 堆大小,过小容易引起内存溢出情况
  • 老年代与新生代直接的比例,有待验证

通过对上面两个非代码层面的问题排查,基本上可以排查,以上两个原因不是内存持续增高的 主要 原因,还是要集中目光在代码层面,内存溢出的嫌疑很大。

三、定位问题

因为预发 jvm 启动中,没有加入 GC 相关的日志参数,所以这块没有结合到具体 GC 日志。而是通过监控下 GC 之后,三代的内存确实有大幅回收,但仍是呈持续走高的情况,断定肯定存在对象被引用一直未被释放或来不及释放的情况出现。

由于在预发服务器 jvm 不好连接调试,所以就是用主动 dump 内存快照的方式来定位问题,其实查阅文档过程中,发现 java 应用启动时有一个参数可以在内存溢出时候自动输出快照。但是 dump 持续时间很长,会导致应用程序的 OS 的卡顿,仔细想想这种方式也确实不适合线上来用,还是老老实实的去手动 dump 吧。这块除了 dump 之外,还可以直接通过查看堆实例对象分布来进行初步排查。

$ jmap -histo:live 1 | head -n 20

num     #instances         #bytes  class name
----------------------------------------------
1:        179270       17393680  [C
2:         12491        7040816  [B
3:        201608        6451456  java.util.concurrent.ConcurrentHashMap$Node
4:        101056        4850688  org.aspectj.weaver.reflect.ShadowMatchImpl
5:         49516        4357408  java.lang.reflect.Method
6:        177978        4271472  java.lang.String
7:        101056        3233792  org.aspectj.weaver.patterns.ExposedState
8:         31956        2624896  [Ljava.lang.Object;
9:         23088        2563232  java.lang.Class
10:           994        2136768  [Ljava.util.concurrent.ConcurrentHashMap$Node;
11:         17034        1795496  [I
12:         46570        1490240  java.util.HashMap$Node
13:         13443        1270632  [Ljava.util.HashMap$Node;
14:         30711        1228440  java.util.LinkedHashMap$Entry
15:         14387         805672  java.util.LinkedHashMap
16:         49234         787744  java.lang.Object
17:           280         738040  [J

上表中的一些很大的对象都没有直观的看出与哪个对象相关,所以还是采用 dump 的方式去快照。在 pod 中执行,执行 jmap -dump 命令。

$ jmap -dump:format=b,file=jmap-dump-20220117-01.hporf 1

OK ~,当前我们已经拿到了快照的内容,我们通过 MAT 内存分析工具来展开快照内容。很快定位到了几个问题:

  • 定时巡检任务中有一个较大的类,该对象被多次重复创建且进行了 Json 转换,巡检过程大约会持续 10 分钟左右,该对象造成很大的内存占用。
  • fastjson 的包,一直都会持续占用内存,且有持续慢增长的趋势,下面图中,占用了 10M 左右的空间。

image.png

因为上面的 dump 都是手动进行触发的,所以有可能不是内存占用最高峰或者触发 GC 之后的情况,所以多打几个包来进行对比,可以先手动 GC 之后,再进行 dump

五、效果

根据监控下 GC 频率和多代内存占用来看,应该是解决了问题。

image.png

image.png