《一场hashcode和equals导致的内存泄漏》BUG
由于实际场景代码过于复杂,下面展示代码片段都是模拟场景还原,非实际场景代码
# 背景
项目是一个类OA系统,Springboot,单体架构,本身的用户量不多,主要是公司内部人员使用,维护了五年,中间经历过两次转型,存在许多陈旧代码。系统有个“老毛病”,会越用越卡,长时间运作后会崩溃,大概运行一个月左右就会非常卡,甚至崩溃。检查日志后发现报出OOM。
# 问题排查
根据用户反馈的系统特征,基本可以确定系统存在内存泄漏,可单单一个OOM报错,实在让人摸不着脑袋,一般来说,生产环境的java应用应该开启-XX:+HeapDumpOnOutOfMemoryError参数以进行OOM时的内存快照,哪怕没有开启,也应该在报出OOM后,使用jmap命令主动进行快照方便问题排查,可惜运维的同学眼疾手快,重启解决百分之99的问题,没有给我这个机会。现在领导要我解决,我总不能等下次OOM的时候再去分析快照文件吧... 于是我决定本地测试,看看能不能找到原因。
# 使用工具监控内存
首先连接上VisualVM工具监控内存情况,无请求状态下运行一段时间后,观察内存情况:

属于正常的内存波动,由于本身系统存在定时任务,会自动的创建对象并销毁,springboot自身也会自发的创建对象,而且GC后恢复的内存大小基本一致。
那么就是请求带来的内存泄漏,可系统接口这么多,我怎么知道是哪个接口泄漏了。
一般来说,业务创建的对象都会随着线程栈的弹出而被回收,没能回收的说明仍然存在强引用导致JVM无法回收,那么要说常见的强引用,我首先想到的就是伴随着类加载的static成员变量,亦或是IOC中的SpringBean,这类单例,亦或是线程共享的对象。于是我尝试从这里着手。
# 首次寻找嫌疑对象
我全局搜索static关键字,看看能不能找到嫌疑对象,开始我有怀疑过是不是ThreadLocal,像这种代码:
public class LoginInterceptor implements HandlerInterceptor {
private static final ThreadLocal<Object> THREAD_LOCAL = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 验证token,解析token获取用户信息等一系列行为...
// 存入用户id以便后续使用
THREAD_LOCAL.set("uid");
return true;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
但实际上,ThreadLocal的entry是弱引用,哪怕没有显性的去调用ThreadLocal.remove()也能在GC的时候被回收,只有当并发数足够大,导致对象创建速度大于GC速度时,才会引发OOM。而且项目里很好的在后置拦截器调用了ThreadLocal.remove()来释放。很遗憾,问题没这么好解决。
但实际这是个笨方法,搜关键字如同大海捞针,是极其不效率的。

然后我突发一个点子,把本地虚拟机的内存设置小一点,开启快照,正常去走一些常用的业务流程,缩短其发病周期,并且监控内存情况,如果内存已经出现异常则主动去快照,这样是不是来得更快?在没有快照文件的情况下,我确实像个无头苍蝇一样,只能瞎找,于是我把本地虚拟机设置成了这样,开始各种瞎操作:
-Xms256m // 初始堆内存
-Xmx256m // 最大堆内存
-XX:+HeapDumpOnOutOfMemoryError // 开启内存快照
-XX:HeapDumpPath=E:\Project\jvm\dump // 快照存放位置
2
3
4
可捣鼓了一下午,也没触发OOM,但内存的低峰值确实变高了,说明是有操作到内存泄漏的接口,虽然可能不是很明显,但我还是决定使用dump命令先进行一次内存快照,看看能不能从中摸到一些蛛丝马迹:

# 使用MAT分析hprof文件
使用MAT (opens new window)工具进行内存分析:

可以看到已经帮我们检测到了一个内存泄漏的嫌疑对象,点击Details查看详情:

从支配树中可以看到GC Root是一个hash表,并且告诉了我们这个hash表的变量名AUTH_CACHE,我们去项目中找这个变量。
# 分析泄漏原因
// 用户权限缓存
private static final Map<Object, AuthHolder> AUTH_CACHE = new HashMap<>();
2
我先解释一下这个变量的作用,业务是这样的:
- 用户登录
- 查询用户权限,封装成
AuthHolder对象 - 放入
AUTH_CACHE以便后续用户鉴权使用
我们来看下AuthHolder对象装了什么东西
public class AuthHolder {
// 可看标签类型
private List<Long> canSeeTagType;
// 可编辑标签类型
private List<Long> canDoTagType;
// ...其他权限的id
}
2
3
4
5
6
7
8
其实就是用户权限的一个打包,估计考虑是登录的时候装载用户所有的权限,方便后续使用吧,这样设计的风险和合理性先不在这讨论,先看看为什么会导致内存溢出。
下面是模拟的场景代码,实际会更复杂,仅保留了关键部分:
// 用户权限缓存
private static final Map<Object, AuthHolder> AUTH_CACHE = new HashMap<>();
@GetMapping("/login")
public void login() {
// 模拟从数据库中查询用户对象...
User user = new User();
user.setId(1L);
user.setName("张三");
// 从数据库中查询用户权限...
AuthHolder authHolder = new AuthHolder();
// 一共放入1mb
authHolder.setCanDoTagType(new byte[1024 * 512]);
authHolder.setCanSeeTagType(new byte[1024 * 512]);
// 放入缓存
AUTH_CACHE.put(user, authHolder);
}
public class User implements Serializable {
private Long id;
private String name;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
@Data
public class AuthHolder {
private byte[] canSeeTagType;
private byte[] canDoTagType;
// ...其他权限的id
}
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
逻辑就是上面说的,登录查询权限封装成权限包对象,放入缓存,这里为更明显的反应内存溢出我将AuthHolder中Long类型集合改为了字节数组,方便申请更大的内存。
我们先来分析一下这段代码,首先为什么我模拟直接用new User(),因为项目用的mybatis,实际通过mb查询出来的User对象也相当于是new出来的,所以没有区别。
分析:
导致内存泄漏的有几个关键点:
- 缓存
AUTH_CACHE所使用的Key是Object - 放入缓存的key是
uesr对象,这里不知道是不是想写user.getId()写漏了 User没有使用lombok的@Data,而且没有重写hashcode和equals
由于使用的是user作为key,而且又刚好没重写hashcode和equals,也就是用的Object类的,比较的是地址值,然后登录每次查询数据库得到的user对象的地址值又不同,在hashmap中占有的槽自然也不一样,也就越积越多,最后导致OOM。上面这几点,哪怕有一点没达成都不会造成内存泄漏,可偏偏就是中了,连锁反应。
我们用下面这张图更直观的理解内存泄漏原因:

# 验证原因
设置JVM参数:
-Xms256m // 初始堆内存
-Xmx256m // 最大堆内存
-XX:+HeapDumpOnOutOfMemoryError // 开启内存快照
-XX:HeapDumpPath=E:\Project\jvm\dump // 快照存放位置
2
3
4
登录一次放入1MB数据到缓存中
@GetMapping("/login")
public void login() {
// 模拟从数据库中查询用户对象...
User user = new User();
user.setId(1L);
user.setName("张三");
// 从数据库中查询用户权限...
AuthHolder authHolder = new AuthHolder();
// 一共放入1mb
authHolder.setCanDoTagType(new byte[1024 * 512]);
authHolder.setCanSeeTagType(new byte[1024 * 512]);
// 放入缓存
AUTH_CACHE.put(user, authHolder);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
使用jmeter发送请求观察内存情况

设置时间间隔,延长到达OOM的时间

测试结果:

可以看到后续经过几次Full GC都无法恢复到启动时的内存低峰值,这就是很标准的内存泄漏趋势图。
我们给User添加上hashCode和equals再次测试:
public class User implements Serializable {
private Long id;
private String name;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(id, user.id) && Objects.equals(name, user.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name);
}
}
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
27
28
29
30
31
32
33
34
调高请求频率,观察测试结果:

可以看到虽然GC频繁,但是确实解决了内存泄漏的问题,每次GC后的内存低峰值基本维持在30MB左右。对于频繁GC,又是另外要解决的问题了。
# 总结
生产环境java应用建议开启内存快照参数,真的很需要求求了
被动快照命令:
-XX:+HeapDumpOnOutOfMemoryError:发生OutOfMemoryError错误时,自动生成hprof内存快照文件。
-XX:HeapDumpPath=<path>:指定hprof文件的输出路径。
主动快照命令:
jmap -dump:live,format=b,file=文件路径和文件名 进程ID