Mushroom Notes Mushroom Notes
🍄首页
  • JavaSE

    • 基础篇
    • 数据结构
    • IO流
    • Stream流
    • 函数式接口
    • JUC
    • 反射
    • 网络编程
    • 设计模式
  • JavaEE

    • Servlet
    • JDBC
    • 会话技术
    • 过滤器监听器
    • 三层架构
  • JDK

    • 总览
  • JVM

    • 总览
  • 常用mate
  • CSS
  • JavaScript
  • rds 数据库

    • MySQL
    • MySQL 进阶
    • MySQL 库表规范
  • nosql 数据库

    • Redis
    • Redis 进阶
    • Redis 底层
    • MongoDB
  • Spring生态

    • Spring
    • Spring MVC
    • Spring boot
    • Spring Validation
  • Spring Cloud生态

    • Spring Cloud
    • 服务治理
    • 远程调用
    • 网关路由
    • 服务保护
    • 分布式事务
    • 消息中间件
  • 数据库

    • Mybatis
    • Mybatis Plus
    • Elasticsearch
    • Redisson
  • 通信

    • Netty
📚技术
  • 方案专题
  • 算法专题
  • BUG专题
  • 安装专题
  • 网安专题
  • 面试专题
  • 常用网站
  • 后端常用
  • 前端常用
  • 分类
  • 标签
  • 归档

kinoko

一位兴趣使然的热心码农
🍄首页
  • JavaSE

    • 基础篇
    • 数据结构
    • IO流
    • Stream流
    • 函数式接口
    • JUC
    • 反射
    • 网络编程
    • 设计模式
  • JavaEE

    • Servlet
    • JDBC
    • 会话技术
    • 过滤器监听器
    • 三层架构
  • JDK

    • 总览
  • JVM

    • 总览
  • 常用mate
  • CSS
  • JavaScript
  • rds 数据库

    • MySQL
    • MySQL 进阶
    • MySQL 库表规范
  • nosql 数据库

    • Redis
    • Redis 进阶
    • Redis 底层
    • MongoDB
  • Spring生态

    • Spring
    • Spring MVC
    • Spring boot
    • Spring Validation
  • Spring Cloud生态

    • Spring Cloud
    • 服务治理
    • 远程调用
    • 网关路由
    • 服务保护
    • 分布式事务
    • 消息中间件
  • 数据库

    • Mybatis
    • Mybatis Plus
    • Elasticsearch
    • Redisson
  • 通信

    • Netty
📚技术
  • 方案专题
  • 算法专题
  • BUG专题
  • 安装专题
  • 网安专题
  • 面试专题
  • 常用网站
  • 后端常用
  • 前端常用
  • 分类
  • 标签
  • 归档
  • 方案专题

  • 算法专题

  • BUG专题

    • 《关于一个方法命名搞崩业务链的那件事》
    • 《一场hashcode和equals导致的内存泄漏》
      • 背景
      • 问题排查
        • 使用工具监控内存
        • 首次寻找嫌疑对象
        • 使用MAT分析hprof文件
        • 分析泄漏原因
        • 验证原因
      • 总结
  • 安装专题

  • 网安专题

  • 面试专题

  • 专题
  • BUG专题
kinoko
2024-09-13
目录

《一场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;
    }
}
1
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 // 快照存放位置
1
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<>();
1
2

我先解释一下这个变量的作用,业务是这样的:

  1. 用户登录
  2. 查询用户权限,封装成AuthHolder对象
  3. 放入AUTH_CACHE以便后续用户鉴权使用

我们来看下AuthHolder对象装了什么东西

public class AuthHolder {
    // 可看标签类型
    private List<Long> canSeeTagType;
    // 可编辑标签类型
    private List<Long> canDoTagType;
    // ...其他权限的id

}
1
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

}
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
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出来的,所以没有区别。

分析:

导致内存泄漏的有几个关键点:

  1. 缓存AUTH_CACHE所使用的Key是Object
  2. 放入缓存的key是uesr对象,这里不知道是不是想写user.getId()写漏了
  3. User没有使用lombok的@Data,而且没有重写hashcode和equals

由于使用的是user作为key,而且又刚好没重写hashcode和equals,也就是用的Object类的,比较的是地址值,然后登录每次查询数据库得到的user对象的地址值又不同,在hashmap中占有的槽自然也不一样,也就越积越多,最后导致OOM。上面这几点,哪怕有一点没达成都不会造成内存泄漏,可偏偏就是中了,连锁反应。

我们用下面这张图更直观的理解内存泄漏原因:

# 验证原因

设置JVM参数:

-Xms256m // 初始堆内存
-Xmx256m // 最大堆内存
-XX:+HeapDumpOnOutOfMemoryError // 开启内存快照
-XX:HeapDumpPath=E:\Project\jvm\dump // 快照存放位置
1
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);
}
1
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);
    }
}
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
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

#OOM#内存泄漏
上次更新: 2024/09/16 23:30:48
《关于一个方法命名搞崩业务链的那件事》
Git安装

← 《关于一个方法命名搞崩业务链的那件事》 Git安装→

最近更新
01
JVM 底层
09-13
02
JVM 理论
09-13
03
JVM 应用
09-13
更多文章>
Theme by Vdoing | Copyright © 2022-2024 kinoko | MIT License | 粤ICP备2024165634号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式