记一次服务Full GC背后的内存泄漏问题,真是匪夷所思( 二 )


记一次服务Full GC背后的内存泄漏问题,真是匪夷所思文章插图
6. 项目中哪里使用了ClassPathList?分析到这里 , 似乎离真相越来越近了 。 到底这个ClassPathList在项目中哪里使用到了?通过前面的分析知道了ClassPathList的整体引用关系链:AppClassLoader -> ClassPool类的defaultPool字段 -> ClassPoolTail类的source字段 -> ClassPathList类的pathList
6.1 ClassPathList源码
记一次服务Full GC背后的内存泄漏问题,真是匪夷所思文章插图
可以看到 , ClassPathList有两个属性 , 一个是next , 结合之前MAT的分析 , ClassPathList的确就是一个链表的结构 。 随着时间的增长 , ClassPathList不断新增 , 链表也随之变得越来越大 , 最后内存占用逐渐上升 。 另一个path字段属于ClassPath类型 , ClassPath是个接口 , 查看它的实现类 , 发现一个似曾相识的名称ClassClassPath , 之前分析对象统计信息时 , 还有一个类的对象数量是和ClassPathList一样的 , 正是这个ClassClassPath 。 每新增一个ClassPathList , 都会伴随着新增对应的ClassPath对象 , 这也解释了为什么两者数量是一致的了 。
6.2 ClassClassPath源码
记一次服务Full GC背后的内存泄漏问题,真是匪夷所思文章插图
通过注释知道 , 这个ClassClassPath的作用大概就是 , 利用一个叫ClassPool的对象 , 可以调用其insertClassPath方法来新增一个ClassClassPath对象 , insertClassPath方法内部通过头插法将ClassClassPath添加到ClassPathList链表 , 从而形成一个search-path , 然后通过这个search-path能够获取到某一个Class类的信息 。
记一次服务Full GC背后的内存泄漏问题,真是匪夷所思文章插图
于是尝试着搜了一下 , 看看项目中有没有调用到insertClassPath方法的地方 。 意外发现一个类 ,
记一次服务Full GC背后的内存泄漏问题,真是匪夷所思文章插图
这不就是我们项目用来打印方法入参、执行耗时、上报metrics的@AutoLog的实现类吗 。
记一次服务Full GC背后的内存泄漏问题,真是匪夷所思文章插图
可以看到getParams方法中调用了insertClassPath , 注解@AutoLog的printParams默认为true , 也就是每次调用都需要打印方法入参 , 每次打印前都要调用getParams先获取参数名称 。 因此每次都会insertClassPath , 从而导致ClassPathList链表越来越大 。
至此 , 内存泄漏的元凶已经找到 。 解决方法也就简单了 。 因为目标只是想得到方法的参数名称 , 通过JoinPoint其实能直接获取到 , 因此可以改成JoinPoint获取的方式 。
7. 压测为了进行对比 , 分别在修改前后各进行一次压测 。 压测JVM参数大致与线上一致 , 为了尽快看到效果 , 只是调小了heap的大小 。 -Xms200m -Xmx200m
7.1 修改前
记一次服务Full GC背后的内存泄漏问题,真是匪夷所思文章插图
ClassPathList数量不断增长
记一次服务Full GC背后的内存泄漏问题,真是匪夷所思文章插图
年老代每次能回收的垃圾越来越少 , 每次回收过后的剩余空间也越来越小 。 最终整个年老代被撑满
记一次服务Full GC背后的内存泄漏问题,真是匪夷所思文章插图
虽然还没触发OOM , 但是CPU负载飙高 , 从基本都在处于频繁的FULLGC状态
7.2 修改后
记一次服务Full GC背后的内存泄漏问题,真是匪夷所思文章插图
ClassPathList已经被消灭掉了
记一次服务Full GC背后的内存泄漏问题,真是匪夷所思文章插图
FullGC也趋于规律化了 。 每次回收的垃圾大致都相同
记一次服务Full GC背后的内存泄漏问题,真是匪夷所思文章插图
8. 后记

  1. 很多时候只依靠简单的对象统计信息 , 不足以定位问题 , 需要使用完整HeapDump , 通过MAT进一步分析 。 如果只需分析年老代对象 , 可以使用OQL过滤 。 可用如下方式可获得老生代地址:
第一种方式是在启动参数增加 -XX:+PrintHeapAtGC , 每次GC都打印地址
第二种方式是使用vjmap的命令 , 在-old, -sur, -address 中 , 都会打印出该区间的地址
第三种方式 , 使用vjmap的address命令 , 快速打印各代地址 , 不会造成过长时间停顿 。
详情参考:
  1. 亲选服务使用JDK8 ,默认的GC收集算法;-XX:+UseParallelOldGC 。 使用Parallel Scavenge(年轻代)+Parallel Old(老年代)的组合进行GC 。 这是一套注重吞吐量的收集器 。 吞吐量=程序运行时间/(程序运行时间+GC时间) 。 假设运行时间99秒、GC时间1秒 。 吞吐量=99% 。 但是对于有一定并发量的在线应用来说 , GC时停顿1秒是挺大影响的 。 因此后续可根据情况调整使用CMS