问题现象
前段时间,公司线上的服务器开始出现pod反复重启的现象,但是没有JVM内存泄漏的日志,也没有找到OOM的dump文件,只有一条容器被kill的日志。通过普罗米修斯监控大盘,发现是JVM内存突破了pod的内存限制。
问题分析
现象分析
- 硬件配置
我们的应用服务是使用k8s来部署的,应用是基于jdk8的,出现问题的服务pod分配的总数为3台,每个pod分配3G内存和1个高性能CPU,pod的内存required和limit分别为2G和3G。
- JVM参数
默认参数,未配置。
- JVM内存分析
年轻代正常;老年代占用较多,可能有大对象存在;老年代内存增长同pod增长趋势相同,且有按小时增长迹象,并会触发FullGC。
问题猜测
- 随着业务增长,pod内存可能确实不足
- JVM默认参数无法有效限制JVM的内存使用
- 某个小时任务存在问题,且使用了大对象
- 大对象触发FullGC后并没有有效释放内存
- 查询k8s官方Issues,是否存在pod与Java版本兼容bug
尝试解决
- 增加pod内存,使得
required=4G
limit=5G
- 增加JVM参数限制
-Xms=4G
-Xmx=4G
,堆外内存也要限制,(特别注意jdk8为元空间)-XX:MetaspaceSize=300m
-XX:MaxMetaspaceSize=300m
- 翻查代码,确实发现某个定时任务存在着大批量的List内存存储,且长时间不释放,但是短时间内没有较好的修改替代方案
- 调整年轻代大小
-Xmn
,使大对象在年轻代就被回收,而不进入老年代 - 升级jdk8的小版本,
提升jvm对容器限制的感知
(这块见参考文档3)
尝试结果
- JVM内存增长导致的重启次数变得减少一点,部分FullGC之后pod容器依然健在,但是时间久了依然会被kill重启
- 大对象没有被提前回收,依然进入了老年代,且FullGC明显增多
初步结论
- 业务增长pod内存确实需要适当增加
- JVM参数需要进行手动限制,但是年轻代大小可以不需要调整,反而可以降低FullGC次数
- 代码端想更好的优化方法,尽量让数据对象生命周期缩短,但是改动较难
- jdk8小版本有对容器限制感知的修复,有一定用处(这块见参考文档3)
问题解决
保持上面的解决方案和参数,我们的服务勉强保持了一段时间,只是相对拉长了容器被重启的时间,告警和重启依然存在,实在令人头疼,很明显还是存在JVM内存泄漏。
后来通过不断的排查监控大盘,我发现堆内内存经过FullGC后,可以做到到达临界线后不再增长,这说明了堆内参数大小和限制都起到了很好的作用;但是要知道,JVM还有一部分叫做堆外内存的东西,参数中限制了我们常知道的MetaSpace,在大盘中我却发现了non-heap memory部分总有比MetaSpace多出的300M,其增长趋势同pod内存一样一致,只是不是那么明显。
这引发了我进一步的思考,堆外内存是不是除了元数据区,还有一块未曾想到的区域?
经过翻查资料:
发现有一块叫做Direct Memory的区域,这块区域就是我们常提到的NIO为了减少内存拷贝而直接申请的部分,这部分在常见数据库读写客户端中大量存在。
通过排查代码,果不其然,代码除了保留List大对象之外,还大量使用了RedisTemplate对象,这个客户端底层连接基于netty实现,正是NIO那部分。
于是,加上-MaxDirectMemorySize
参数后,再次测试,JVM内存稳固被限制,果然没有问题了!而这个参数正是《深入理解JVM虚拟机》前几页所提到的——很多程序员会忽略的一个参数。
问题总结
总的来看,这次的问题DirectMemory是一个主要问题,代码的大对象处理是一个次要问题,其次才应该考虑对JVM的FullGC次数进行优化。
毕竟,JVM是一个复杂的大工程,它已经帮我们很好的完成了对内存的管理;出现了内存泄漏不可怕,要从现象和问题本身出发,要从JVM本身出发,万万不能被所谓的“八股文”和“权威”给束缚住,上来就考虑GC优化往往只会迷失方向。
那些被刻板经验给忽略的,往往就是真相,仅此而已。
参考文档
- https://blog.csdn.net/tterminator/article/details/54342666
- https://zhuanlan.zhihu.com/p/370241822
- https://blog.51cto.com/lookingdream/4046529
- https://juejin.cn/post/6844903894863052814
- https://www.processon.com/view/link/5b61ea2ae4b0555b39cfa842