记一次Java内存泄漏的追踪

发生在工作中的一次Java内存泄漏,主要是查找过程相对艰辛,耗时两天。

背景

虽然对Java很熟悉了,但是以前大部分的经验都是在移动端,虽说语法、概念、思想都是一样的,但是Java写服务端,你懂得,必须Spring大法。

去年使用Spring Boot框架搭建了一套微服务系统。对Spring有了更加深入的了解和认知,整个使用过程还是很舒服的,虽说比起Golang来要臃肿很多,但是一行代码调用搞定以前十几行的内容,加上Java8之后的语法糖甜到掉牙。习惯了之后,体验真的是很不错。上线之后一切正常,心里十分满足。

随着需求逐渐稳定,线上版本更新频次逐渐降低。然后问题就不期而至了。随着程序在线上运行时间逐渐变长,程序的内存逐渐增大,直到最后无内存可用,抛出异常

1
2
3
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "kafka-producer-network-thread | producer-1"
Exception in thread "mysql-cj-abandoned-connection-cleanup" java.lang.OutOfMemoryError: Java heap space
Exception in thread "HikariPool-1 connection closer" java.lang.OutOfMemoryError: Java heap space

分析

出问题后第一时间是在测试环境进行复现。发现程序内存随着时间在线性增长,使用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
2
3
4
5
private class QueryPreparer {
...
private final Map<List<ParameterMetadata<?>>, ParameterBinder> binderCache = new ConcurrentHashMap();
...
}

从命名上可以看到,这里是将此Map作为了一个缓存使用,但是很奇怪的是Key使用的是一个List,再查找与此字段相关的操作如下:

1
2
3
4
5
private ParameterBinder getBinder(List<ParameterMetadata<?>> expressions) {
return (ParameterBinder)this.binderCache.computeIfAbsent(expressions, (key) -> {
return ParameterBinderFactory.createCriteriaBinder(PartTreeJpaQuery.this.parameters, key);
});
}

代码很简单,就是查找Map中是否有相关缓存,没有的话就插入进去,那么盲猜就是这里出现了问题。这里就要复习一个比较基础的Java知识了,Map的key是怎么比较是否相等的呢?ConcurrentHashMap中源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public V get(Object key) {
int h = spread(key.hashCode());
ConcurrentHashMap.Node[] tab;
ConcurrentHashMap.Node e;
int n;
if ((tab = this.table) != null && (n = tab.length) > 0 && (e = tabAt(tab, n - 1 & h)) != null) {
int eh;
Object ek;
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || ek != null && key.equals(ek)) {
return e.val;
}
} else if (eh < 0) {
ConcurrentHashMap.Node p;
return (p = e.find(h, key)) != null ? p.val : null;
}

while((e = e.next) != null) {
if (e.hash == h && ((ek = e.key) == key || ek != null && key.equals(ek))) {
return e.val;
}
}
}

return null;
}

可以看到,整体会涉及到key的 hashCode 和 equals 两个基础方法,追踪代码可以发现,这里key的List使用的是ArrayList,接下来需要查看ArrayList的两个方法的实现,代码不多,就不贴了,结论很简单,就是这两个的实现是和容器内的内容有关系的,也就是ParameterMetadata中这两个方法的实现了,查看后发现ParameterMetadata 并没有特意重写这两个方法, 那么这就导致每次进行缓存比较的时候都不能命中,Map就会越积越大,最后导致内存溢出。

查看了Github上,发现已经有相关PR,并且被合并到了最新版本里,具体链接看这里DATAJPA-1647 - Removes broken caching of ParameterBinder.,所以到最后,只需要把Spring做一个升级就好了。↖(ω)↗