100字范文,内容丰富有趣,生活中的好帮手!
100字范文 > 通过长期测试定位java服务内存泄漏问题

通过长期测试定位java服务内存泄漏问题

时间:2024-06-30 17:53:28

相关推荐

通过长期测试定位java服务内存泄漏问题

目录

一、前言

二、实验说明

三、实验记录

3.1 保存接口-exe部署-pid-1484

3.2 查询接口-exe部署pid-18592

3.3 查询接口-jar部署pid-8

3.4 服务静置-exe部署-pid-6

3.5 查询接口-exe部署-禁用二级缓存-pid-2040

3.6 查询接口-exe部署-禁用二级缓存-限制qp为1M-pid-24164

一、前言

根据测试小姐姐的反馈,日志服务在长期测试中,貌似存在内存泄漏,遂展开排查工作。

二、实验说明

因为服务只暴露出2个业务场景,一个数据查询,一个数据保存。因此,将项目按业务场景单独部署并长期观察内存占用情况。以下所说的“exe部署”是指,服务按照生产环境的模式进行部署,即exe并注册为带有守护进程的win系统服务的方式进行部署。以下所说的“jar包部署”是指,服务以java服务方式进行部署,运行于jvm上。

三、实验记录

3.1 保存接口-exe部署-pid-1484

只保留数据保存功能的代码,并单独部署为服务。此外,我们为了模拟客户端环境,开发了1个客户端程序A,持续调用我们服务中的保存接口,频率为8次/秒。

持续观测10天,见图 1,内存提交大小曲线基本规律,有升有降,且升幅约等于降幅。此外,只有用户登陆登出才会产生保存接口的调用,在实际生产环境中,这个接口的负载远低于8次/秒。因此,可以认为保存接口涉及到的代码,不存在内存泄漏。

图1 实验一内存曲线

3.2 查询接口-exe部署pid-18592

只保留数据查询功能的代码,并单独部署为服务。为了尽可能还原生产环境中的数据体量(db体量在以每分钟500条的公差进行增长),实验1中客户端程序A(8次/秒约等于每分钟500条数据)基本可以还原生产环境的数据体量。

此外,我们又开发了模拟客户端的程序B,查询时间区间也会跟随生产环境的时间动态变化(生产环境每次的查询时间区间为每日零时到查询时刻),并持续调用我们服务中的查询接口,频率为1次/10秒。

持续观测9天,见图 2,内存提交大小曲线涨幅为3.1MB。此外,内存曲线不规律,存在阶跃响应,某些时刻涨幅较大,而回落很小。可以得出结论,以exe方式部署服务,查询接口涉及到的代码,可能存在内存泄漏。

图 2 实验二内存曲线

3.3 查询接口-jar部署pid-8

为了确定影响因素是否为部署方式,本次实验选中jar包部署方式。只保留数据查询功能的代码,并单独部署为java服务。相比于步骤2,只是运行环境的不同,数据体量和查询条件均与实验2中对等。

持续观测8天,见图 3,内存提交大小曲线涨幅为11MB。内存提交大小曲线不规律,存在阶跃响应,某些时刻涨幅较大,而回落很小。可得出结论,以jar包方式部署服务,查询接口涉及到的代码,可能存在内存泄漏。

图 3 实验三内存曲线

由于java服务可以使用JvisualVm和jmap相关工具的优势,我们对堆栈信息进一步追踪,通过jvisualvm中的jmap -histo:live 8 命令,多次截取堆内存占用情况,可以观察到Hibernate相关实例占用堆空间,平均为0.1M。某次的截取如图 4所示:

图 4 实验三jmap命令统计堆内存占用信息

其次,鉴于项目架构中采用Hibernate实现了EntityManager,所以猜想可能是进程在db查询过程中,Hibernate产生了缓存并不断上涨所致的OOME,这对我们的实验5进行了启发。

3.4 服务静置-exe部署-pid-6

服务全功能部署并静置,持续观测9天,如图 5,内存提交大小曲线规律,无起伏变化,可认为服务静置状态,不存在内存泄漏。

图 5 实验四内存曲线

3.5 查询接口-exe部署-禁用二级缓存-pid-2040

为了验证实验3中Hibernate缓存是否导致内存泄漏,我们对EntityManager和Hibernate缓存策略又进行了学习。

受业务影响,后端有一些较为复杂的数据库查询操作,项目中使用到了EntityManager。

首先,EntityManager分为2种,容器托管的EntityManager对象和应用托管的EntityManager对象。项目中使用的为前者,通过注解@PersistenceContext注入的方式来获得,不需要手动地控制资源释放、连接、事务等的优势。因此,理论上不会存在未关闭db资源而导致内存泄漏的情况。

此外,Hibernate缓存分为2级,第一级缓存又被称为“Session的缓存”。鉴于使用了容器托管的EntityManager对象,在每次查询之后会自动关闭session,不会产生一级缓存滞留。因此,只需考虑二级缓存带来的影响。在quarkus官网中,找到了二级缓存的默认值2048MB,更进一步加深了猜测。此外,找到了是否开启二级缓存的配置,可以将quarkus.hibernate-orm.second-level-caching-enabled设为false,即不开启二级缓存。

最后,为了进一步验证,我们保留数据查询功能的代码,禁用了Hibernate二级缓存,并在每次查询前对缓存进行了清除,单独部署为服务。开发客户端模拟程序C辅助进行测试,数据体量和查询条件均与实验2中对等。

持续观测7天,内存提交大小曲线上涨3.2M。如图 6,内存提交大小曲线不规律,存在阶跃响应,某些时刻涨幅较大,而回落很小。此外,本次实验和实验二内存曲线基本一致,可以得出结论,Hibernate缓存不是造成查询接口内存泄漏的主要原因。

图 6 实验五内存曲线

3.6 查询接口-exe部署-禁用二级缓存-限制qp为1M-pid-24164

首先,通过JVisualVM观测了实验3进程8的运行情况,进行了1次堆dump,如图 7,将实例数按占用大小进行排序,对应前三的类名分别为:byte[]、java.lang.Object[]、java.lang.Stirng。

然后,进一步观察这些类下的实例数明细。发现java.lang.Stirng类下存在大量select count查询语句的实例,而且这些实例被NativeSQLQuerySpecification、NativeSQLQueryPlan和SQLCustomQuery所大量持有,如图 8。

接下来,在JVisualVM上手动进行GC,发现这些实例并没有被回收,如图 9,可以断定应该是内存泄漏的1个原因。

紧接着,也是受到了一篇博客的启发,找了一下NativeSQLQueryPlan的源码,如图 10,发现每次执行entityManager.createNativeQuery(sql),会把sql的执行计划缓存起来。再结合业务场景来看,由于是sql拼接了时间参数,导致每次执行计划不一样。这样一来,每执行一次查询,将会缓存一个实例,该实例将被缓存一直所持有,并且只有在 SessionFactory 关闭的时候缓存清除,实例才会被释放,从而触发GC。

那么,如何才能限制sql执行计划的数量呢?

第一个方案:首先,通过代码自查,发现整个查询接口,会涉及到2次db查询,一次select语句,一次select count语句。为什么发现的大量执行计划,都是关于select count语句的,而不是select语句的呢?准确得来说,我们只需要研究这2个语句的实现有何不同。

在select语句中,采用了占位符代替参数,将查询条件拼接,接着再以占位符的方式将参数注入的形式,而select count语句未采用此种策略,所以似乎已经找到了突破点。但是,花费了好几个小时,由于一直解决不了单引号参数注入的问题,导致该方案不通。

第二个方案:找到了限制sql查询计划缓存大小的配置。

最后,采用方案二,搭建同样数据体量和查询条件的环境,再次进行长期验证。

图 7 首次堆内存dump分析

图 8 首次堆内存dump后实例数分析

图 9 二次堆内存dump后实例数分析

图 10 QueryPlan源码

持续观测6天,见图 11,曲线已基本呈现出收敛的形势。在长期持续大概3小时的时候,内存提交大小曲线有1MB的涨幅,基本跟我们设定的sql执行计划缓存1M大小一致。之后曲线的每一次涨幅,都对应一次相同大小的降幅,已趋近收敛。

图 11 实验六内存曲线

[1]想要Python软件测试更上一层楼,从这几个方面入手! - 知乎

[2]使用entityManager.createNativeQuery(拼接sql)导致的内存泄漏

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。