JVM FULL GC 生产问题 II-如何定位内存泄露?

2025-05-29 0 58

JVM FULL GC 生产问题 II-如何定位内存泄露?

情景回顾

我们在上一篇 JVM FULL GC 生产问题笔记 中提出了如何更好的实现一个多线程消费的实现方式。

没有看过的小伙伴建议看一下。

本来以为一切都可以结束的,不过又发生了一点点意外,这里记录一下,避免自己和小伙伴们踩坑。

生产-消费者模式

简介

上一节中我们尝试了多种多线程方案,总会有各种各样奇怪的问题。

于是最后决定使用生产-消费者模式去实现。

实现如下:

这里使用 AtomicLong 做了一个简单的计数。

userMapper.handle2(Arrays.asList(user)); 这个方法是同事以前的方法,当然做了很多简化。

就没有修改,入参是一个列表。这里为了兼容,使用 Arrays.asList() 简单封装了一下。

  1. importcom.github.houbb.thread.demo.dal.entity.User;
  2. importcom.github.houbb.thread.demo.dal.mapper.UserMapper;
  3. importcom.github.houbb.thread.demo.service.UserService;
  4. importjava.util.Arrays;
  5. importjava.util.List;
  6. importjava.util.concurrent.*;
  7. importjava.util.concurrent.atomic.AtomicLong;
  8. /**
  9. *分页查询
  10. *@authorbinbin.hou
  11. *@since1.0.0
  12. */
  13. publicclassUserServicePageQueueimplementsUserService{
  14. //分页大小
  15. privatefinalintpageSize=10000;
  16. privatestaticfinalintTHREAD_NUM=20;
  17. privatefinalExecutorexecutor=Executors.newFixedThreadPool(THREAD_NUM);
  18. privatefinalArrayBlockingQueue<User>queue=newArrayBlockingQueue<>(2*pageSize,true);
  19. //模拟注入
  20. privateUserMapperuserMapper=newUserMapper();
  21. /**
  22. *计算总数
  23. */
  24. privateAtomicLongcounter=newAtomicLong(0);
  25. //消费线程任务
  26. publicclassConsumerTaskimplementsRunnable{
  27. @Override
  28. publicvoidrun(){
  29. while(true){
  30. try{
  31. //会阻塞直到获取到元素
  32. Useruser=queue.take();
  33. userMapper.handle2(Arrays.asList(user));
  34. longcount=counter.incrementAndGet();
  35. }catch(InterruptedExceptione){
  36. e.printStackTrace();
  37. }
  38. }
  39. }
  40. }
  41. //初始化消费者进程
  42. //启动五个进程去处理
  43. privatevoidstartConsumer(){
  44. for(inti=0;i<THREAD_NUM;i++){
  45. ConsumerTasktask=newConsumerTask();
  46. executor.execute(task);
  47. }
  48. }
  49. /**
  50. *处理所有的用户
  51. */
  52. publicvoidhandleAllUser(){
  53. //启动消费者
  54. startConsumer();
  55. //充值计数器
  56. counter=newAtomicLong(0);
  57. //分页查询
  58. inttotal=userMapper.count();
  59. inttotalPage=total/pageSize;
  60. for(inti=1;i<=totalPage;i++){
  61. //等待消费者处理已有的信息
  62. awaitQueue(pageSize);
  63. System.out.println(UserMapper.currentTime()+"第"+i+"页查询开始");
  64. List<User>userList=userMapper.selectList(i,pageSize);
  65. //直接往队列里面扔
  66. queue.addAll(userList);
  67. System.out.println(UserMapper.currentTime()+"第"+i+"页查询全部完成");
  68. }
  69. }
  70. /**
  71. *等待,直到queue的小于等于limit,才进行生产处理
  72. *
  73. *首先判断队列的大小,可以调整为0的时候,才查询。
  74. *不过因为查询也比较耗时,所以可以调整为小于pageSize的时候就可以准备查询
  75. *从而保障消费者不会等待太久
  76. *@paramlimit限制
  77. */
  78. privatevoidawaitQueue(intlimit){
  79. while(true){
  80. //获取阻塞队列的大小
  81. intsize=queue.size();
  82. if(size>=limit){
  83. try{
  84. //根据实际的情况进行调整
  85. Thread.sleep(1000);
  86. }catch(InterruptedExceptione){
  87. e.printStackTrace();
  88. }
  89. }else{
  90. break;
  91. }
  92. }
  93. }
  94. }

测试验证

当然这个方法在集成环境跑没有任何的问题。

于是就开始直接上生产验证,结果开始很快,然后就可以变慢了。

一看 GC 日志,梅开二度,FULL GC

可恶,圣斗士竟然会被同一招打败 2 次吗?

JVM FULL GC 生产问题 II-如何定位内存泄露?

FULL GC 的产生

一般要发现 full gc,最直观的感受就是程序很慢。

这时候你就需要添加一下 GC 日志打印,看一下是否有 full gc 即可。

这个最坑的地方就在于,性能问题是测试一般无法验证的,除非你进行压测。

压测还要同时满足两个条件:

(1)数据量足够大,或者说 QPS 足够高。持续压

(2)资源足够少,也就是还想马儿跑,还想马儿不吃草。

好巧不巧,我们同时赶上了两点。

那么问题又来了,如何定位为什么 FULL GC 呢?

内存泄露

程序变慢并不是一开始就慢,而是开始很快,然后变慢,接着就是不停的 FULL GC

这就和自然的想到是内存泄露

如何定位内存泄露呢?

你可以分成下面几步:

(1)看代码,是否有明显存在内存泄露的地方。然后修改验证。如果无法解决,则找出可能存在问题的地方,执行第二步。

(2)把 FULL GC 时的堆栈信息 dump 下来,分析到底是什么数据过大,然后结合 1 去解决。

接下来,让我们一起看一下这个过程的简化版本记录。

问题定位

看代码

最基本的生产者-消费者模式确认了即便,感觉没啥问题。

于是就要看一下消费者模式中调用其他人的方法问题。

方法的核心目的

(1)遍历入参列表,执行业务处理。

(2)把当前批次的处理结果写入到文件中。

方法实现

简化版本如下:

  1. /**
  2. *模拟用户处理
  3. *
  4. *@paramuserList用户列表
  5. */
  6. publicvoidhandle2(List<User>userList){
  7. StringtargetDir="D:\\\\data\\\\";
  8. //理论让每一个线程只读写属于自己的文件
  9. StringfileName=Thread.currentThread().getName()+".txt";
  10. StringfullFileName=targetDir+fileName;
  11. FileWriterfileWriter=null;
  12. BufferedWriterbufferedWriter=null;
  13. UseruserExample;
  14. try{
  15. fileWriter=newFileWriter(fullFileName);
  16. bufferedWriter=newBufferedWriter(fileWriter);
  17. StringBufferstringBuffer=null;
  18. for(Useruser:userList){
  19. stringBuffer=newStringBuffer();
  20. //业务逻辑
  21. userExample=newUser();
  22. userExample.setId(user.getId());
  23. //如果查询到的结果已存在,则跳过处理
  24. List<User>userCountList=queryUserList(userExample);
  25. if(userCountList!=null&&userCountList.size()>0){
  26. return;
  27. }
  28. //其他处理逻辑
  29. //记录最后的结果
  30. stringBuffer.append("用户")
  31. .append(user.getId())
  32. .append("同步结果完成");
  33. bufferedWriter.newLine();
  34. bufferedWriter.write(stringBuffer.toString());
  35. }
  36. //处理结果写入到文件中
  37. bufferedWriter.newLine();
  38. bufferedWriter.flush();
  39. bufferedWriter.close();
  40. fileWriter.close();
  41. }catch(Exceptionexception){
  42. exception.printStackTrace();
  43. }finally{
  44. try{
  45. if(null!=bufferedWriter){
  46. bufferedWriter.close();
  47. }
  48. if(null!=fileWriter){
  49. fileWriter.close();
  50. }
  51. }catch(Exceptione){
  52. }
  53. }
  54. }

这种代码怎么说呢,大概就是祖传代码吧,不晓得大家有没有见过,或者写过呢?

我们可以不看文件部分,核心部分实际上只有:

  1. UseruserExample;
  2. for(Useruser:userList){
  3. //业务逻辑
  4. userExample=newUser();
  5. userExample.setId(user.getId());
  6. //如果查询到的结果已存在,则跳过处理
  7. List<User>userCountList=queryUserList(userExample);
  8. if(userCountList!=null&&userCountList.size()>0){
  9. return;
  10. }
  11. //其他处理逻辑
  12. }

代码存在的问题

你觉得上面的代码有哪些问题?

什么地方可能存在内存泄露呢?

有应该如何改进呢?

看堆栈

如果你看代码已经确定了疑惑的地方,那么接下来就是去看一下堆栈,验证下自己的猜想。

堆栈的查看方式

jvm 堆栈查看的方式很多,我们这里以 jmap 命令为例。

(1)找到 java 进程的 pid

你可以执行 jps 或者 ps ux 等,选择一个你喜欢的。

我们 windows 本地测试了下(实际生产一般是 linux 系统):

  1. D:\\ProgramFiles\\Java\\jdk1.8.0_192\\bin>jps
  2. 11168Jps
  3. 3440RemoteMavenServer36
  4. 4512
  5. 11660Launcher
  6. 11964UserServicePageQueue

UserServicePageQueue 是我们执行的测试程序,所以 pid 是 11964

(2)执行 jmap 获取堆栈信息

命令:

  1. jmap-histo11964

效果如下:

  1. D:\\ProgramFiles\\Java\\jdk1.8.0_192\\bin>jmap-histo11964
  2. num#instances#bytesclassname
  3. ———————————————-
  4. 1:16103120851264[C
  5. 2:1579493790776java.lang.String
  6. 3:17093699696[B
  7. 4:34723688440[I
  8. 5:1393583344592com.github.houbb.thread.demo.dal.entity.User
  9. 6:1396142233824java.lang.Integer
  10. 7:12716508640java.io.FileDescriptor
  11. 8:12714406848java.io.FileOutputStream
  12. 9:7122284880java.lang.ref.Finalizer
  13. 10:12875206000java.lang.Object

当然下面还有很多,你可以使用 head 命令过滤。

当然,如果服务器不支持这个命令,你可以把堆栈信息输出到文件中:

  1. jmap-histo11964>>dump.txt

堆栈分析

我们可以很明显发现不合理的地方:

[C 这里指的是 chars,有 161031。

String 是字符串,有 157949。

当然还有 User 对象,有 139358。

我们每一次分页是 1W 个,queue 中最多是 19999 个,这么多对象显然不合理。

代码中的问题

chars 和 String 为什么这么多

代码给人的第一感受,就是和业务逻辑没啥关系的写文件了。

很多小伙伴肯定想到了可以使用 TWR 简化一下代码,不过这里存在两个问题:

(1)最后文件中能记录所有的执行结果吗?

(2)有没有更好的方式呢?

对于问题1,答案是不能。虽然我们为每一个线程创建一个文件,但是实际测试,发现文件会被覆盖。

实际上比起我们自己写文件,更应该使用 log 去记录结果,这样更加优雅。

于是,最后把代码简化如下:

  1. //日志
  2. UseruserExample;
  3. for(Useruser:userList){
  4. //业务逻辑
  5. userExample=newUser();
  6. userExample.setId(user.getId());
  7. //如果查询到的结果已存在,则跳过处理
  8. List<User>userCountList=queryUserList(userExample);
  9. if(userCountList!=null&&userCountList.size()>0){
  10. //日志
  11. return;
  12. }
  13. //其他处理逻辑
  14. //日志记录结果
  15. }

user 对象为什么这里多?

我们看一下核心业务代码:

  1. UseruserExample;
  2. for(Useruser:userList){
  3. //业务逻辑
  4. userExample=newUser();
  5. userExample.setId(user.getId());
  6. //如果查询到的结果已存在,则跳过处理
  7. List<User>userCountList=queryUserList(userExample);
  8. if(userCountList!=null&&userCountList.size()>0){
  9. return;
  10. }
  11. //其他处理逻辑
  12. }

这里在判断是否存在的时候构建了一个 mybatis 中常用的 User 查询条件,然后判断查询的列表大小。

这里有两个问题:

(1)判断是否存在,最好使用 count,而不是判断列表结果大小。

(2)User userExample 的作用域尽量小一点。

调整如下:

  1. for(Useruser:userList){
  2. //业务逻辑
  3. UseruserExample=newUser();
  4. userExample.setId(user.getId());
  5. //如果查询到的结果已存在,则跳过处理
  6. intcount=selectCount(userExample);
  7. if(count>0){
  8. return;
  9. }
  10. //其他业务逻辑
  11. }

调整之后的代码

这里的 System.out.println 实际使用时用 log 替代,这里只是为了演示。

  1. /**
  2. *模拟用户处理
  3. *
  4. *@paramuserList用户列表
  5. */
  6. publicvoidhandle3(List<User>userList){
  7. System.out.println("入参:"+userList);
  8. for(Useruser:userList){
  9. //业务逻辑
  10. UseruserExample=newUser();
  11. userExample.setId(user.getId());
  12. //如果查询到的结果已存在,则跳过处理
  13. intcount=selectCount(userExample);
  14. if(count>0){
  15. System.out.println("如果查询到的结果已存在,则跳过处理");
  16. continue;
  17. }
  18. //其他业务逻辑
  19. System.out.println("业务逻辑处理结果");
  20. }
  21. }

生产验证

全部改完之后,重新部署验证,一切顺利。

希望不会有第三篇。:)

小结

当然验证的过程中还发生过一点小插曲,比如开发没有权限看堆栈信息,执行命令时程序已经假死等等。

生产 full gc 是一个比较麻烦的问题,一个是难以复现,另一个是如果是偶发性的,又是实时链路,可能也不好执行 dump 命令。

所以写代码还是写的尽可能简单的好,不然会有各种问题。

能复用已有的工具、中间件尽量复用。

这样看来,我们自己写的生产-消费者模式也不太好,因为复用性不强,所以建议使用公司已有的 mq 工具,不过如何选择,还是看具体的业务场景。

架构,就是权衡。

希望本文对你有所帮助!

【原文地址】:https://www.toutiao.com/i6949465946352255525/

收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。

快网idc优惠网 建站教程 JVM FULL GC 生产问题 II-如何定位内存泄露? https://www.kuaiidc.com/112253.html

相关文章

发表评论
暂无评论