1. 用Visual VM 加载堆转储文件
先将转储文件从服务器下载下来,打开Visual VM,点击右上角的Load Snapshot,将这个转储文件加载到Visual VM中。
2. 用Visual VM 分析堆转储文件
1)首先看到是醒目的红色,这里标记了堆内存溢出的线程,我这里显示是Nacos的配置中心的一个更新本地配置的Updater线程导致堆内存溢出。
我本地debug发现,这个线程里边根本就不会创建太多对象。而且本地测试环境的配置文件数 、配置文件内容大小不会与生产环境相差太多,那么可以推测生产环境也不会创建太多的对象。因此可以进一步推测不是这个线程导致的内存溢出。实际情况可能是在此之前就已经内存不足了,只是在这个线程任务中GC已经不起作用了、实在是分配不了新对象,最终程序崩溃。
2)不能直接从线程上看出问题所在,就只能去看java对象的分布情况了。
从Class by Number of Instances
和Classes by Size of Instances
两个维度都可以看出byte[]
、char[]
、String
的对象个数及内存大小都是排在最前列的。
光看这几种对象其实没啥意义,因为它们是最常见的几种准标量,它们几乎在任何系统的任何时候都是数量最多、内存占用最大的,它们一般都是被其他复杂对象
给引用着,所以我们主要还是得关注这些复杂对象
。
除了这个常见的准标量外,可以直观看到EnterpriseProjectDO
对象占用内存最大。看到这个Java类型的对象很多,我第一反应就是我负责的那个定时任务可能在运行,那个定时任务会创建大量的EnterpriseProjectDO
对象。但后来又细想了下,白天那个定时任务不会运行,它的执行计划是每晚11点,后来在XxlJob的用户界面复查发现当时也没有其他人手动执行那个定时任务。
我只能扩大排查范围,后来在Dominators By Retain Size
维度看到了关键信息
点击Dominators By Retain Size
的view all
按钮显示出一下结果
从上图可以看出,用绿框标注出的对象是重点怀疑对象,这几类对象数量多、占用内存大,且有好几个还是GC Root。
- (1) 先看排第一的
ResultSetImpl
,这说明此时数据库查询向java程序返回了很大的数据报文。 - (2) 再看
ArrayList#115426
这个对象,其中的元素类型是EnterpriseMainAndCredential
,因此需要关注某个代码逻辑会用到这个类。
- (3) 然后看
HashMap#26865
这个对象,展开后发现,这个HashMap的key是Long
类型、value是ArrayList
(其元素类型是EnterpriseCredentialDO
),这明显是一个Java 8 对集合进行stream分组的结果。因此得重点排查对项目中对EnterpriseCredentialDO
的分组代码
- (4) 再然后看
ConcurrentHashMap#239
这个对象,展开这个map发现,它的key是Bean名、value是Bean对象,可以推测出这个对象是DefaultListableBeanFactory
中记录bean名到bean实例之间映射的字段singletonObjects
,因此这个对象对我们排查问题没啥参考意义。
- (5) 最后看
ArrayList$SubList#1
这个对象,展开这个对象后发现它是元素类型EnterpriseMainDO
的ArrayList
的子视图,这个java项目中有使用内存分页,所以需要注意项目中对EnterpriseMainDO
集合调用subList
进行分页的代码逻辑
3 结合分析结果,定位并解决问题
(1) 直接IDEA中搜索 EnterpriseMainAndCredential
被引用的地方,很快就定位了一个代码位置,这个类只有那一个处被使用。这个接口没有传租户id,会导致查询所有的EnterpriseProjectDO,在这个接口中这个实体对应的数据库表的数据量是最大的,达到了30多万,反序列化为Java实体时会创建大量对象。
(2) 另外由于过滤条件复杂,主数据没法在数据库层做过滤,需要将所有的EnterpriseMainDO
(数据量不大,总量才3000多条)查出,再做内存分页,所以在堆转储文件分析看到了元素类型是EnterpriseMainDO
的ArrayList$SubList
。
(3) 代码中对EnterpriseCredentialDO
和EnterpriseProjectDO
都进行了stream 分组,所以在堆转储文件分析看到了上面提的的HashMap#26865
对象。
(4) 内存溢出的原因也在于这里的stream 分组统计,代码中对这两对象集合分组后,只取了分组后的size,并未使用集合里的对象具体信息。EnterpriseProjectDO
的数据量特别大,且代码逻辑又只需要统计的count数,所以这个统计应该放在数据库上(利用group by
统计)。
(5) 最后,我改写了代码,用sql在数据库上做数据统计,大大减少了数据库的返回数据包文大小,java程序客户端也就不需要创建那么多的java数据库实体类,不会出现GC失败、内存溢出的问题了。