揭秘JVM内存问题:你所见到的内存泄漏,是否真实存在?

一、问题排查

1.1 问题发现

最近在某个应用做迭代时发现过几个小时前还能正常调用的接口突然调不通了,没有任何返回数据。

观察内存后发现:

内存使用率已经到了99%,能有反应才有鬼。

服务已经不能响应了

1.2 问题排查

1.2.1 SFTP

观察这个曲线,非常平滑,很明显属于 "内存泄漏" ,排查本次提交代码。

本次提交代码中使用了SFTP服务,因为是第一次整合SFTP,所以使用完毕后并没有关闭对应的连接资源

加上使用完毕后关闭SFTP连接资源:

csharp 复制代码
    try {
        if (is != null) {
            is.close();
        }
    } catch (IOException e) {
        log.error("关闭读取文件流失败", e);
    }
    try {
        if (fileOutputStream != null) {
            fileOutputStream.close();
        }
    } catch (IOException e) {
        log.error("关闭写入文件流失败", e);
​
    }
    channelSftp.disconnect();
    session.disconnect();

但是发现关闭SFTP文件后问题仍没有解决,内存仍在缓步增长。

1.2.2 分析Dump文件

发现不是SFTP的问题了,思路就来到了dump下内存快照,看一下是那个对象的内存一直在增长。

使用行云自带的dump工具尝试dump内存快照,但是由于种种原因失败了。

在本地运行后使用Java自带的内存分析工具:

发现本地也有和线上一样的情况,遂dump本地的内存

bash 复制代码
$ jmap -dump:file=heap.bin 40481
Dumping heap to /Users/hanguokai1/IdeaProjects/frc-trafftic-finplat-front/heap5.bin ...
Heap dump file created

然后使用JProfiler分析内存情况,查看占用内存最大的对象:

但是并没有发现有效对象,最大的一个对象才只有29mb左右,并不符合我们的预期。并且我们正常的堆内存应该有130mb左右,但是使用JProfiler分析只有29MB左右。

也就是说我们看到的现象是:

我们堆内存有大概130mb,但是用JProfiler实际看到的只有30mb,那么堆内存的剩下的100mb对象跑到哪里去了?

此处我们暂且不具体分析,继续按照主线寻找排查问题的思路。

既然dump文件无法获得有效信息,我们只能另寻他路。

1.2.3 观察生产环境情况

因为我们是在测试环境发现的这个问题,这个时候突然想到,生产上应该不会有这个问题,不然应用也不可能正常上线,于是查看生产环境的节点监控:

果然生产环境是没有问题的,那么首先现在生产环境与测试环境分支是不同的

先确定是否是分支问题,于是在测试环境部署master分支,观察内存情况。

果然,测试环境即使部署了master分支内存也会缓步增长

那也就说明,不是代码引起的问题,而是环境不同引起的问题

至此,离发现真正的问题也只差临门一脚了。

1.2.4 对比生产与测试环境

发现不是代码问题后,首先把这件事汇报给了leader,leader进一步反馈之前测试环境就有经常出现系统宕机的情况,但是生产环境没有问题。

于是确认是环境的问题

至此思路比较简单了,比较两者环境即可,但是看了一圈发现两者除了节点内存大小不一样 之外没有什么不同,那么内存有可能是影响因素吗,是的话为什么不同的内存会导致 "内存泄漏" 呢。

再次回看内存增长的图片,对比生产环境的内存情况,突然好像发现了问题所在,我们的问题真的是 "内存泄漏" 吗?

左边测试环境的内存好像只有增长,没有下降。

换句话说,我们的内存并没有发生GC,无论是YoungGC或者是Full GC,因为无论是什么GC类型都会带来内存的下降。

那么为什么测试环境不会触发GC呢

跟收集器有关吗,排查后发现生产环境与测试环境使用收集器一致,应该不是垃圾收集器的原因。

结合我们之前排查两者环境只有内存不一致,查看JVM启动参数:

ruby 复制代码
-server -Xmx6g -Xms6g -Xmn2560m -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m -Xss256k -XX:+UseG1GC -XX:LargePageSizeInBytes=128m -XX:+UseFastAccessorMethods

果然,测试环境与生产环境启动参数一致...启动参数中设置的堆内存大小都是6G,但是生产环境节点有8G内存,而测试环境只有2G内存

于是将测试环境JVM内存调整至500mb:

diff 复制代码
-server -Xmx500m -Xms500m

果然,调整完成后:

发现现在应用已经可以正常GC了。

ini 复制代码
23-10-09.15:52:47.251 [schedule-worker-pool-1-thread-2] INFO  SyncDBDataToCrowJob     - 已使用堆内存: 112 mb
23-10-09.15:52:47.251 [schedule-worker-pool-1-thread-2] INFO  SyncDBDataToCrowJob     - 最大堆内存: 200 mb
23-10-09.15:52:47.251 [schedule-worker-pool-1-thread-2] INFO  SyncDBDataToCrowJob     - 已提交堆内存: 200 mb
23-10-09.15:52:47.251 [schedule-worker-pool-1-thread-2] INFO  SyncDBDataToCrowJob     - 总的GC次数: 9
23-10-09.15:52:47.251 [schedule-worker-pool-1-thread-2] INFO  SyncDBDataToCrowJob     - Full GC次数: 0
​
​
23-10-09.15:55:17.683 [schedule-worker-pool-1-thread-4] INFO  SyncDBDataToCrowJob     - 已使用堆内存: 136 mb
23-10-09.15:55:17.683 [schedule-worker-pool-1-thread-4] INFO  SyncDBDataToCrowJob     - 最大堆内存: 200 mb
23-10-09.15:55:17.683 [schedule-worker-pool-1-thread-4] INFO  SyncDBDataToCrowJob     - 已提交堆内存: 200 mb
23-10-09.15:55:17.683 [schedule-worker-pool-1-thread-4] INFO  SyncDBDataToCrowJob     - 总的GC次数: 9
23-10-09.15:55:17.683 [schedule-worker-pool-1-thread-4] INFO  SyncDBDataToCrowJob     - Full GC次数: 0 
​
​
23-10-09.15:57:01.932 [schedule-worker-pool-1-thread-5] INFO  SyncDBDataToCrowJob     - 已使用堆内存: 47 mb
23-10-09.15:57:01.932 [schedule-worker-pool-1-thread-5] INFO  SyncDBDataToCrowJob     - 最大堆内存: 200 mb
23-10-09.15:57:01.932 [schedule-worker-pool-1-thread-5] INFO  SyncDBDataToCrowJob     - 已提交堆内存: 200 mb
23-10-09.15:57:01.932 [schedule-worker-pool-1-thread-5] INFO  SyncDBDataToCrowJob     - 总的GC次数: 10
23-10-09.15:57:01.932 [schedule-worker-pool-1-thread-5] INFO  SyncDBDataToCrowJob     - Full GC次数: 0

二、问题原因

按上文,我们目前待解决的问题有两个 ,一是为什么JVM不会发生GC,二是为什么我们的dump文件大小与堆内存大小不一致

2.1 为什么JVM不会发生GC

其实仔细思考一下这个道理很简单。

首先,来回顾一下G1垃圾收集器发生Young GC的时机:

  1. 当某个小块中的对象占用的空间达到一定的阈值时,会触发Young GC。这个阈值可以通过JVM参数进行调整。
  2. 当年轻代中的空闲小块不足以分配新对象时,会触发Young GC。
  3. 当应用程序的存活对象占据的空间超过了年轻代的一定比例时,会触发Young GC。这个比例可以通过JVM参数进行调整。

简述一下就是需要年轻代中的对象需要达到某个阈值,我们此处简单假设该阈值为年轻代总容量的80%。

那么我们的年轻代的总容量是多少呢?

在G1收集器中,默认情况下,年轻代的大小大约占总堆内存的5%。这个比例可以通过JVM参数进行调整,具体来说,可以使用以下参数:

  • -XX:G1NewSizePercent:用于调整年轻代大小占总堆内存的百分比,默认值为5%。
  • -XX:G1MaxNewSizePercent:用于调整年轻代大小占总堆内存的最大百分比,默认值为60%。

在系统运行中,JVM 会不停的给年轻代增加更多的 Region,但是最多新生代的占比不会超过 60%。

那也就意味着我们年轻代最大内存为总的堆内存的60%:

6G*0.6=3.2G

发生GC所需要的阈值为

3.2G*0.8=2.56G

我们之前提到,测试环境的节点内存才只有2G

2.56G>2G

换句话说,就算把内存全部打满了也不会达到Young GC的阈值,这也是为什么生产环境服务是正常的(其内存为8G,大于2.56),而测试环境却总容易因为内存资源不总而宕机。

2.2 为什么在设置了JVM堆内存大于宿主机内存后,服务正可以正常启动?

这里其实问题还可以继续深入,正常来说,JVM是可以获取到我们的宿主机内存。

按照常理,当JVM启动时应该要检查设置的堆内存大小是否大于了宿主机内存,如果大于的话应该直接报错退出,而不应该正常启动。

那么为什么JVM是可以允许设置堆内存大于宿主机内存呢?

这时候我突然想到之前在Windows玩游戏的时候,因为内存不够导致游戏崩溃,然后添加了虚拟内存解决了问题。

那么JVM允许设置堆内存大于宿主机内存是不是考虑了虚拟内存的存在呢?

所谓的虚拟内存是指:

物理内存是计算机的实际内存大小,由RAM芯片组成。虚拟内存则是虚拟出来的、使用磁盘代替内存。虚拟内存的出现,让机器内存不够的情况得到部分解决。当程序运行起来由操作系统做具体虚拟内存到物理内存的替换和加载(相应的页与段的虚拟内存管理)。这里的虚拟内存即所谓的swap。

当用户提交程序,然后产生进程在机器上运行。机器会判断当前物理内存是否还有空闲允许进程调入内存运行,如果有则直接调入内存进行;如果没有,则会根据优先级选择一个进程挂起,把该进程交换到swap中等待,然后把新的进程调入到内存中运行。根据这种换入和换出,实现了内存的循环利用,让用户感觉不到内存的限制。从这也可以看出swap扮演了一个非常重要的角色,就是暂存被换出的进程。

问一下ChatGpt:

果然,和我们的猜想是一致的,JVM允许设置比宿主机内存大的原因是考虑了虚拟内存的情况。

而这么做的目的也有说明:

如果应用程序的内存需求在某些时间段内超过了宿主机的物理内存,但在其他时间段内又没有超过,那么设置堆内存大小大于宿主机内存可以为应用程序提供更大的内存空间,以应对峰值需求。这样可以避免频繁地调整堆内存大小,提高了应用程序的灵活性和可调节性。

当如果我们的应用程序在某个时间段内可预见的会超出我们宿主机内存而绝大多数场景下又会回归到正常情况,这个时候设置

这个时候又忽然想到我们上一篇文章[线上FullGC问题排查实践------手把手教你排查线上问题]中的内容。在我们系统中遇到的问题,JVM中某个对象会随着时间逐步增长直至占满堆内存,导致频繁触发Full GC,引起CPU100%,但是大部分情况下系统堆内存都是处于正常情况。

例如该时间点内存峰值会达到10G,而日常情况下运行只有3G左右,假如我们直接升级服务器资源至16G:

是可以解决应用正常运行的问题,但是此时日常运行的状态下又会造成新的问题:

资源使用率过低,造成资源浪费。

因此我们可以将宿主机的虚拟内存设置为12G,再加上4G的物理内存,已经能满足我们的使用需求。正常情况下,JVM使用物理内存提升响应速率,极端情况下使用虚拟内存避免系统OOM。

2.3 为什么分析Dump文件时堆内存大小与分析应用中大小不一致

其实这个这个问题在一个月前已经有同事来问了,也是根据之前的文章找到的我:

描述的大概情况也是这样:

在监控上看到的堆内存大约有3G,但是通过JProfiler打开分析也是只有120M左右

当时查阅了一些资料后给了他一些反馈,但是由于自己也不太确定所以跟他说的也是可能是这种情况。

所以正好这次遇到了这个问题,就打算认真分析一下。

先梳理一下现象:

我们正常的堆内存应该有130mb左右,但是使用JProfiler分析只有29MB左右。

这跟我们预期是不符的,我们上一篇文章中使用JProfiler分析时,堆内存大小与分析出的内存大小是一致的,但是为什么这里dump文件不一致了呢?这个时候想到这个同事说他用另一款软件分析是能看到对应大小的堆内存的,因此有可能是软件问题。所以先问他要了一下软件名称,下载后打开dump文件:

其中143MB与我们的堆内存差不多一致,而retained size 27MB与我们在JProfiler中看到的内存也几乎一致。

那么这个这个27MB与143MB差距在哪呢?

可以看到数据前面有一个说明:

Strong reachable among them

也就是说这27MB是其中被强引用的对象,我们看一下这个说明文档

大概意思是说:Java的垃圾收集器(GC)在可达性的前提下工作,删除不再可达的对象。

那其实到这里也大概明白了

我们在JProfiler中看到的数据是只有被强引用的对象,也就是说是真正有意义的对象。我们知道,Java中很多无用的对象是朝生夕死的,而这些不再被引用的对象对我们分析堆内存的占用情况来说是没有价值的,因此JProfiler在启动时会自动对我们的堆内存进行Full GC:

看到的也就是剩下的27M强引用的对象。

关掉自动Full GC后:

回归正题总结一下:

这个现象也是符合我们的预期的,我们上文提到我们本次遇到的问题是堆内存被持续打满是因为没有发生GC,而GC要回收的对象就是这些不可达对象。同样的,当我们下次发现在监控中如果堆内存中对象比较大,而使用各种内存分析软件看到的对象比较小,这个时候就可以考虑是不是dump文件中存在很多不可达对象导致的一些差额。 而我们上一篇文章中提到的Full GC就没有这个问题,也正是因为他的问题就是频繁的Full GC不可能有不可达对象了。

三、总结

3.1 有极低频率偶发会出现JVM超内存的情况时,可以考虑使用虚拟内存

当我们的应用在极低频率下不可避免的会出现内存不足的情况时,可以考虑使用虚拟内存应对此情况。根据虚拟内存的定义,只有在内存不足时才会使用虚拟内存,因此也不会发生日常情况下程序运行速度变慢的问题。

3.2 出现堆内存使用率很高但使用分析软件分析没有结果时,可以考虑不可达对象

当应用出现上述的场景时,可以考虑堆内存中是否有大量的不可达对象,造成分析结果不一致。当确定是这种情况时,可以在应用程序GC后再执行Dump,避免发生这种情况,影响对问题的判断。

相关推荐
希忘auto13 分钟前
详解MySQL安装
java·mysql
冰淇淋烤布蕾25 分钟前
EasyExcel使用
java·开发语言·excel
拾荒的小海螺31 分钟前
JAVA:探索 EasyExcel 的技术指南
java·开发语言
Jakarta EE1 小时前
正确使用primefaces的process和update
java·primefaces·jakarta ee
马剑威(威哥爱编程)1 小时前
哇喔!20种单例模式的实现与变异总结
java·开发语言·单例模式
java—大象1 小时前
基于java+springboot+layui的流浪动物交流信息平台设计实现
java·开发语言·spring boot·layui·课程设计
杨哥带你写代码2 小时前
网上商城系统:Spring Boot框架的实现
java·spring boot·后端
camellias_2 小时前
SpringBoot(二十一)SpringBoot自定义CURL请求类
java·spring boot·后端
布川ku子2 小时前
[2024最新] java八股文实用版(附带原理)---Mysql篇
java·mysql·面试
向阳12182 小时前
JVM 进阶:深入理解与高级调优
java·jvm