微服务常见内存泄漏实战分析
一、背景
最近发现线上环境偶尔会出现Pod重启的情况,虽然重启大法(K8S会自动维护Pod数,Pod出了问题会自动重启,感兴趣的话可以去了解K8S的机制)很有效,但是终归不是根本的解决办法,我们需要了解为什么会重启,然后解决它,不单单是技术人的觉悟,更重要的还是money(狗头微笑),万一哪天重要的服务跪了影响到用户,非得被老板扒层皮。
在这个背景下,我们需要针对Pod进行监控,然而,事情并没有那么简单。在成功上线后,线上频繁告警。这个刚松了口气的我突然紧张了起来,看了一眼钉钉告警消息,虽然不是Pod重启,但是却发现有好几个服务出现了堆内存占用率超过90%的情况,这下就很酸爽了……
在这里,把一些常见的内存泄漏情况分析给大家,欢迎指教。
二、首先弄清楚内存泄漏和内存溢出
简单地说,内存泄漏(Memory leak)就是在内存中有很多对象没有被回收,一直占着内存;而内存溢出就是我们常说的OOM了。
内存泄漏最终可能会导致内存溢出,因此我们需要针对上面说的内存占用率超过90%的服务进行分析,找到问题才能解决问题,防止OOM。
三、怎么排查呢
首先当然是看配置的堆内存大小了,这些服务有的配了512M,有的配置了1024M,虽然可以直接修改Pod分配的内存大小以及java应用的启动参数来实现内存扩容,但是难保有一天继续出现内存泄漏或者内存溢出而重启的问题。
因此,我们第二步就是老实本分地把Dump文件拿下来分析一下。
四、工具
(1)首先准备好工具,这里我本着免费的原则,找了俩个,一个是jdk自带的jvisualvm.exe,还有就是MAT了。
(2)拿到dump文件后开始用工具分析,这里我比较推荐MAT,个人使用起来觉得jvisualvm不如MAT好用,MAT能快速给你定位到大对象,还有一些其他的功能,可以俩个一起用,先jvisualvm(JDK自带,不用额外下载),没有结果就用MAT。
五、真正的分析从这里开始
在下面,会列举4种遇到的导致内存泄漏的场景,通过图+部分源码的方式展示,另外2种很容易找到问题,简单略过。 PS:部分参考链接需翻墙。
(一)mongodb
正确来讲是mongodb驱动。 首先打开MAT,将hprof文件载入,可以看到Biggest Objects的饼图,我们直接看最大的那个:
选中最大的那个,右键选择List objects。打开后可以看到:
打开后如此图,我们点击Retained Heap进行降序,然后逐步展开这个对象树。
这里解释一下Shallow Heap和Retained Heap的区别,Shallow Heap是浅层堆,主要是对象本身,也就是对象头+成员变量(不包含值);而Retained Heap是保留堆,指的是一个对象被释放后,能释放的对象的所有引用的堆大小。因此,Retained Heap能更实际地反映堆占用大小,也是我们重点关注的地方。
我们可以直接看到这个类是mongodb的,于是到idea中搜一下这个类,发现这个类是mongodb的驱动包内的,如下:
接下来就是看这个类是干啥的,为什么会是biggest object?
单从名字(2次幂缓存池)看就是mongodb连接池相关的类,我们要全局了解就看源码,但是为了快速解决问题,我们可以直接google搜,也可以直接上github和stackoverflow上找答案(度娘不值得)。
下面是大神的解释:
简单说就是PowerOfTwoBufferPool大对象存在是必要的,一个是为了避免GC进行大对象的回收,GC对大对象的回收成本大、耗时长;二是为了预留足够的内存用于应用大批量来自mongodb的数据查询。
此刻,我就在想,这个时间是2019年,但是过去好几年,有没可能其实已经被优化了呢,于是在这个链接上面发现了蛛丝马迹。
这个上面是在2022年讨论的,主要说的是开启了压缩后,mongo的java驱动导致了内存泄漏,然后在4.5.1版本修复了这个bug。咋一看好像没有特别多的东西,但是既然有修复,那应该是有所优化的。 然后我在网上这个文章上面看到可以调整参数,这个惊喜到我了,调整参数是一个很不错的办法,还不用重启。但保险起见,我还是去看了对应工程的pom.xml,发现用的spring-boot-starter-data-mongodb版本太老。
它甚至没有引入mongodb-driver-async这个异步包,该异步包在spring-data-mongodb中是可选的,引入的话就能够支持到异步。 (注:mongodb-driver有4个主要的包,core是基础的核心包,而java-driver已经不维护了,转向driver-sync以及driver-async)
因此,我将mongodb-driver-async这个异步包引入,且将spring-boot-starter-data-mongodb更新到比较新的版本,然后发布上去。 原本经常这个服务90%以上的告警在后面的半个月时间内都没有再出现,查看该pod的堆内存占用率也降到了75%以下,偶尔会超过,但没有再超过80%。
至此,问题得到解决。
(二)mysql
跟上面的mongodb相似,这里说的mysql也是驱动相关的,但是为什么呢?直接看图
在这里我们可以看到在mysql驱动包下面有这个AbandonedConnectionCleanupThread的类,这个类作用是什么呢? 请看源码:
我们知道,幻象引用(虚引用)的作用之一就是跟踪GC情况的,幻象引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有幻象引用,就会在回收对象的内存之前,把这个幻象引用加入到与之关联的引用队列中。
那么问题来了,为什么堆内存中会占用这些对象呢?
这里我给出github上一个大佬的俩个分析链接
第一个是针对上面AbandonedConnectionCleanupThread的回收说明,第二个是和Hikari相关的参数说明,这俩个都很值得一看,尽管第一个没有得出结论,但是这个思路非常清晰,值得读者借鉴。
我说一下看完这些之后的观点,首先mysql连接池中的连接对象是可复用的,但是复用的对象假设没有在年轻代中回收,就会进入老年代,我们只到老年代的GC需要等到堆内存占用达到一定程度才会进行Full GC,此时可能堆内存占用已经达到我们上所说的90%的告警比率了。
通常Full GC的触发时机有4个:
(1)年轻代全部对象大小 > 老年代剩余空间
(2)从年轻代存活超过15次后进入老年代的对象大小 > 老年代剩余空间
(3)Survivor区中不足以容纳年轻代中存活下来的对象,此时这部分的对象大小 > 老年代剩余空间
(4)如果是CMS收集器,老年代剩余空间 < 参数比例
由于我们没有采用CMS收集器,可以考虑的是上面三种,在上面三种情况下,无论哪一种都可能会造成上面AbandonedConnectionCleanupThread中留下了很多准备销毁的mysql连接对象。因为正常情况下,我们希望在15次minorGC内,连接对象可以被回收,即使一个连接对象会被不断复用,但一定时间范围内minor GC发生的次数并不会很多,只要连接对象被复用且15次minor GC内完成,就可以确保不会被堆积到老年代,尽管还是会存在,但我们有办法可以减少这种情况的发生。例如调整Hikari的maxLifetime,将该参数值降低,但需要保持在一个可控的范围内,不能超过mysql的连接最大存活时间,否则会出现mysql将连接断开,而Hikari还维持的情况(这个在网上有很多解释,可以自行搜索)。但是也不能设太小,因为在一个热点服务的工程中,mysql连接的复用是非常重要的,可以降低连接的创建成本,这个需要根据对应服务工程的mysql调用频率进行调整。当然,调高Hikari的最大连接数和最小连接数等其他参数也会有一定的辅助作用。
接下来说一下为什么调整这些参数,假设不调整,也不在乎这个90%阈值的堆内存告警。当某一天服务堆内存占用达到90%以上,然后突然有一大波流量持续地进来,这个时候可能已经OOM了,在没有使用k8s管理的情况下,服务已经不可用。即便在没有OOM的情况下,进行了full GC,在full GC期间,这波请求可能被阻塞住,从而影响用户的使用。因此,针对一段时间范围内堆内存占用过高的情况,我们可以相应地进行优化,以提前降低甚至规避风险。
(三)OkHttpClient
为什么这个会造成堆内存占用过高呢?直接说结论:使用不当。
首先,看这个图(该图是演示jvisualvm.exe的使用)
在这个图中,我们看到HashMap的Node竟然占到了61%,我们直接点进去看实例,如下图:
选择其中一个Node,观察右下角,OKio赫然在列,立马就可以想到一定是用了OKHttp进行远程调用导致的,但是为什么会出现这么多呢? 直接翻看工程代码,发现在封装的HttpUtil中,每次调用,都会new一个OkHttp,这个可就惊呆我了。 直接看源码:
看到了吗,这就是为什么在dump文件分析结果中会出现这么多SSLContextImpl的原因。
找到问题就等于解决了一半,每次调用都new有问题,那我们就应该考虑是不是提取出来,直接做成单例模式(懒汉、饿汉、枚举等都可以实现单例模式,也有可以参考的工具类,可以自行搜索),这样就不会出现这个问题了,至此,问题解决。
在这里,我们需要时刻铭记单例模式的好处~~~
(四)Spring-boot-starter-actuator
这个包如果没有研究的同学应该很少见,但是这个包其实是有用的,一般用于做监控,当然,也有直接用prometheus做监控的,底层是基于JMX,感兴趣的话可以自己了解。
这个包为什么会造成内存泄漏甚至可能造成OOM呢? 请直接看图:
一看到这个long[]占用达到38%,我血压突然升高,心想,是哪个*&*写的代码,long数组居然会占用这么高,如果被我发现是谁,我非打屎他不可。
老规矩,双击进去看看实例,瞅瞅是哪些类造成的。
看到这里,这个Histogram不是直方图的意思吗?心想当前分析的这个工程服务并不是做数据统计的,就是普通的业务工程,为什么会有这个东东?
于是,把这个Histogram去搜了一下,发现是在一个org.HdrHistogram的包中,这个包是从哪里来的呢?我又把这个org.HdrHistogram复制到pom.xml中去搜
居然是Spring相关的包用到的,心想大事不妙,这个可是Spring提供的包,不应该会出现这么奇怪的问题才对。
但是问题还是要找要分析才行,于是点击2.1.11[compile]双击进去,看到一个LatencyUtils,和LatencyStats很接近。经过一番搜索,终于,找到,原来是spring-boot-starter-actuator包引用了micrometer-core,然后引用了Histogram和LantencyStats相关的包。
至此,问题解决了一半。那么这个actuator的监控包为什么会导致这么多的long数组产生呢。其他工程也引用了这个,但为什么只有其中几个服务工程会出现这个问题?
带着问题我在github上找了许久,终于找到了一个讨论。
我翻译一下,就是调用的端点因为参数的不同,导致维护了很多端点的统计数据。
于是,答案已经呼之欲出,但是我们还得去工程代码里面看一看,瞧一瞧。
经过查找,发现有这个问题的几个工程里面都用到了**@GetMapping**,在参数不同的情况下,在actuator中会识别为不同的端点,从而维护了大量的统计数据,造成了堆内存的大量占用。
由于改接口的成本很大,而且spring-boot-stater-actuator包并没有做实际的使用,因此,通过在公共包中将这个包进行exclusion即可。至此,问题圆满解决,后面通过分析dump文件,也印证了猜想。
值得一提的是,还有一些常见的内存泄漏,例如ThreadLocal的不规范使用,静态List或者Map的不规范使用等,该CodeReview的时候请不要节省这个时间。
转载自:https://juejin.cn/post/7240342069997731895