记一次Java内存泄漏的追踪
发生在工作中的一次Java内存泄漏,主要是查找过程相对艰辛,耗时两天。
背景
虽然对Java很熟悉了,但是以前大部分的经验都是在移动端,虽说语法、概念、思想都是一样的,但是Java写服务端,你懂得,必须Spring
大法。
去年使用Spring Boot框架搭建了一套微服务系统。对Spring有了更加深入的了解和认知,整个使用过程还是很舒服的,虽说比起Golang来要臃肿很多,但是一行代码调用搞定以前十几行的内容,加上Java8之后的语法糖甜到掉牙。习惯了之后,体验真的是很不错。上线之后一切正常,心里十分满足。
随着需求逐渐稳定,线上版本更新频次逐渐降低。然后问题就不期而至了。随着程序在线上运行时间逐渐变长,程序的内存逐渐增大,直到最后无内存可用,抛出异常
1 | Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "kafka-producer-network-thread | producer-1" |
分析
出问题后第一时间是在测试环境进行复现。发现程序内存随着时间在线性增长,使用jmap
进行dump内存到文件
1 | jmap -dump:format=b,file=app-112648.dump 112648 |
使用MAT对dump文件进行分析,打开后界面如下:
首先通过Leak Suspects
来进行内存泄漏分析,等待大概几十秒后,得到如下信息:
点击查看详细信息,展示如下:
从上到下即为从泄漏源到root的路径,中间因为有分支,看上去不是很清晰,可以通过右键选择Merge Shortest Paths to GC Roots
--> exclude all phanton/weak/soft etc references
进行简化显示,得到如如下结果:
整个路径显示的非常清晰,其他几个泄漏路径也大同小异,与此原因相同。
可以看到数据最终指向是 Spring Data JPA 相关的代码。
Spring Data JPA
根据MAT的分析,找到最终泄漏源如下:
1 | private class QueryPreparer { |
从命名上可以看到,这里是将此Map作为了一个缓存使用,但是很奇怪的是Key使用的是一个List,再查找与此字段相关的操作如下:
1 | private ParameterBinder getBinder(List<ParameterMetadata<?>> expressions) { |
代码很简单,就是查找Map中是否有相关缓存,没有的话就插入进去,那么盲猜就是这里出现了问题。这里就要复习一个比较基础的Java知识了,Map的key是怎么比较是否相等的呢?ConcurrentHashMap中源码如下:
1 | public V get(Object key) { |
可以看到,整体会涉及到key的 hashCode 和 equals 两个基础方法,追踪代码可以发现,这里key的List使用的是ArrayList,接下来需要查看ArrayList的两个方法的实现,代码不多,就不贴了,结论很简单,就是这两个的实现是和容器内的内容有关系的,也就是ParameterMetadata
中这两个方法的实现了,查看后发现ParameterMetadata
并没有特意重写这两个方法, 那么这就导致每次进行缓存比较的时候都不能命中,Map就会越积越大,最后导致内存溢出。
查看了Github上,发现已经有相关PR,并且被合并到了最新版本里,具体链接看这里DATAJPA-1647 - Removes broken caching of ParameterBinder.,所以到最后,只需要把Spring做一个升级就好了。↖(ω)↗