Pitest内存泄露分析 (工具使用IDEA、Jprofiler)

目录

一、环境

二、概述

2.1变异测试整体流程

2.2内存溢出原因

主进程(设计问题)

子进程(CoverageMinion)

三、过程分析

3.1调试环境搭建

主/子进程远程调试

Jprofiler远程调试

3.2子进程内存分析

内存泄漏确定

占主要堆空间的对象

占主要空间对象补充。。

分析MockClassLoader被引用情况

监控ClassLoader

子线程未结束

子线程被加入shutdownHooks

MbeanServerFactory

3.3主进程内存分析

四、小结


 

 

一、环境

Pitest版本: 1.6.4

Github fork: https://github.com/YKRY35/pitest

Jprofiler工具: 11.1.5

 

Pitest以maven插件的方式运行。

公司使用中,单元测试使用的powermock框架,过程中发生OOM,机器内存32G。

 

二、概述

2.1变异测试整体流程

 

可以看到,主进程为了替换字节码,需要javaagent,所以会起子进程来跑覆盖率和变异。

覆盖率阶段,所有的任务都在单个子进程跑,而变异阶段,会拆分任务,到不同的子进程去跑。所以存在内存泄漏和设计问题的情况下,覆盖率阶段相比变异阶段更容易Out Of Memory。

这里分析和解决内存问题,是针对主进程的覆盖率阶段和子进程1 CoverageMinion子进程。

 

 

2.2内存溢出原因

先下结论。

  • 主进程(设计问题)

主进程是设计问题,导致内存过大直到溢出。

注意这个数据结构:

https://github.com/YKRY35/pitest/blob/master/pitest-entry/src/main/java/org/pitest/coverage/execute/Receive.java#L24

 

private final Map<Long, BlockLocation>    probeToBlock  = new ConcurrentHashMap<>();

 

这个数据结构是子进程内存溢出的根源。

两个原因导致这个数据结构过大:

1. MockClassLoader

PowerMock为了环境隔离,自定义了类加载器,并且重写loadClass方法,于是不遵守父委派模型,所以同一个类在不同单侧脚本中会被重复加载。

即:

 

可以看到同一个UserService类,因为类加载器不同,且不遵守父委派,所以它的Class对象会反复被加载,经过transformer被插入探针。

子进程的探针是要汇报到主进程作记录的,因此probeToBlock这个数据结构会特别大。

同一个类会被反复注入探针,反复次数取决于跑到这个类的单测数。

在单测1跑完,跑单测2时,Class<UserService> @1这里头的探针就没有用了,可以释放。

 

2. 无用探针注入

Pitest在支持powermock时,考虑到powermock使用javassist来加载最初的class文件,可能是希望在powermock修改class之前先拿到最初的class文件,因此pitest修改了javassist的字节码。

见:

https://github.com/YKRY35/pitest/blob/master/pitest/src/main/java/org/pitest/mutationtest/mocksupport/JavassistInputStreamInterceptorAdapater.java#L64

 

@Override

  public void visitMethodInsn(final int opcode, final String owner,

    final String name, final String desc, boolean itf) {

  if ((opcode == Opcodes.INVOKEINTERFACE)

      && owner.equals("javassist/ClassPath") && name.equals("openClassfile")) {

    this.mv.visitMethodInsn(Opcodes.INVOKESTATIC, this.interceptorClass, name,

        "(Ljava/lang/Object;Ljava/lang/String;)Ljava/io/InputStream;", false);

  } else {

    this.mv.visitMethodInsn(opcode, owner, name, desc, itf);

  }

  

}

它将javassist的ClassPath/openClassfile方法调用全部替换为自己框架内的JavassistCoverageInterceptor/openClssfile,所以在mock之前pitest就能拿到没被修改过的字节码文件了。

但是!!!注意:

https://github.com/YKRY35/pitest/blob/master/pitest/src/main/java/org/pitest/coverage/execute/JavassistCoverageInterceptor.java#L102

 

决定是否对这个类进程注入的关键函数:

private static boolean isInstrumentedClass(final String name) {

    return true;

  }

return的true,因此所有加载进来的类全部被插入探针。像java/lang/Object,第三方框架类啊什么的,全部插了探针,再结合MockClassLoader会反复加载类,最后探针数成倍增长。

内存达到8G时,已经插入3000万个探针了。

这算是一个bug,估计是作者想先return个true测试一下,但是后面功能实现完了,前面这里忘记改了。

 

 

  • 子进程(CoverageMinion)

子进程算是设计问题引发的内存泄漏了。

  • pitest内存泄漏问题本质

先谈本质

  1. 子进程用主线程跑所有的单测,所有单测是共享一个jvm虚拟机的
  2. PowerMock为了性能,没有完全做到环境隔离,有的虚拟机类例如包名是java./javax.的类,PowerMock又不会自己加载,而是deferTo,交给高级别的例如AppClassLoader去加载。
  3. 单测脚本或者是单测脚本用到的第三方依赖向虚拟机类中静态变量保存了数据。

这三个特性结合起来,导致了内存泄漏。

举个栗子:

 

如果UserService向ApplicationShutdownHooks的static变量保存了UserService的实例。

高等级ClassLoader加载的ApplicationShutdownHooks是不会被回收的,它的static变量成为Gc Root后,保持着对Class<UserService> @1实例的引用,而UserService这个实例的clasLoader又会引用MockClassLoader @1,MockClassLoader又会引用到所有它加载过的类,最后就会导致,链路的形成,即使这个单测结束,在用MockClassLoader @2时,MockClassLoader @1加载过的所有类都不会被释放(注意这里的类指的是Class<?>,而不是类的实例)。内存泄漏。

而我用到的那个被测项目,实际发现,每个MockClassLoader的深堆引用大多达到5M,200个TestUnit(一个单测文件会有多个测试单元),内存泄漏就达到1G了。

 

   这里发生泄漏的具体原因:

  • 原因1:log4j子线程没结束

Log4j的AsyncAppender会开子线程,来写数据库,或者写文件。在跑单测时,这个子线程在单测跑完没有结束。子线程它对应的类Java.lang.Thread虽然不是MockClassLoader加载的,但是子线程中用到的类存在MockClassLoader加载的。所以保持引用。

即AyncAppender$AsyncThread.run()在java stack里作为Gc Root了。

 

  • 原因2:log4j将子线程加入jvm的shutdownHooks

不仅如此,log4j还会把这个子线程addShutdownHook,估计是想在jvm结束前将内存缓冲写入硬盘。

addShutdownHook最终会存在java/lang/ApplicationShutdownHooks中

private static IdentityHashMap<Thread, Thread> hooks;

 前面说到这个不会被回收,引用存在。

 

  • 原因3:log4j保存了一些数据到MbeanServerFactory

这个原理和前面的类似,javax/management/MbeanServerFacotry的classLoader也是null,即bootstrapClassLoader。

只是这个引用存在的数据结构更深,更难发现一些。

 

  • 原因4:PowerMock会缓存一个类所有的方法

org/powermock/reflect/internal/WhiteboxImpl中,有个static变量allClassMethodsCache,类型是ConcurrentMap<Class, Method[]>。

    类名相同的类由不同加载器加载,则是不同的Class对象,在allClassMethodsCache中则是不同的key。同时,WhiteboxImpl这个类的加载器是AppClassloader,主进程结束前WhiteboxImpl及其static引用的对象就不会被回收了。

 

  • 原因5:ThreadLocal引用

Powermock会把一下Class<?>写入threadlocal,而threadlocal是保存在Thread.threadLocals数据结构中的。

    不过这个问题只会直接导致最后一次执行的单测脚本的MockClassLoader泄漏。

 

  • 原因n:还有别的很多。。。。

以上几个问题,通过反射,手工在每个单测脚本执行完后将那些涉及的数据结构恢复成执行前的,能够释放掉一部分MockClassLoader,但是这么手工释放,没有解决本质问题。

还有有一部分MockClassLoader被没被释放,说明还有别的引用。

 

三、过程分析

3.1调试环境搭建

主/子进程远程调试

先加个测试工程,包含单测的工程,和pitest工程在一个idea窗口下,这样主进程可以直接debug。

Run中添加maven。

 

点击debug可以调试到pitest的maven插件主进程。

Commit:

https://github.com/YKRY35/pitest/commit/2dc40dc9d11d133718edbd8f2e975084f54a5eb4

 

子进程,在WrappingProcess中加入远程调试参数:

if (DEBUG_FLAG) {

  cmd.add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=7999");

}

 

Commit:

https://github.com/YKRY35/pitest/commit/ac8c30cef27a36656cf8c8a42fa171d766eb86eb

 

添加远程调试:

 

如此一来,工程所有的代码都能调试到了。

 

Jprofiler远程调试

Jprofiler分析内存,堆对象,以及classloader加载情况,线程情况等。

1. 主进程jprofiler调试

    可以在主进程运行后,jprofiler attach上去,不过开头一部分信息会丢失。

    也可以在run的maven中加参数:

   

    对应的,jprofiler中Session – integration wizards – new remote integration ,配置好就行:

 

    端口别重了。

    这样,主进程就会等待jprofiler attach上去以后,才会继续执行代码了。

 

2. 子进程jprofiler调试

见:

Commit:

https://github.com/YKRY35/pitest/commit/ac8c30cef27a36656cf8c8a42fa171d766eb86eb

jprofiler操作和《主进程 jprofiler远程调试》一致。

 

3.2子进程内存分析

内存泄漏确定

 

典型的增长火焰图。

每次gc完,谷底的高度都比上次高。

当然,实际高的没有这么明显,可能每个单测跑完就泄漏5M,为了这个图表现更直观,我小P了一手。望看官见谅。

 

占主要堆空间的对象

Heap Walker 先Mark Heap,合适的时候Take snapshot,那么显示的对象就是在mark之后,堆里新生成的对象了。

  点击计算retained size,会出现最后一列,深堆引用。

 

Size只是这个对象自身占用的大小,每个对象都是固定的,没有参考价值。Retained size会计算这个对象所引用的所有对象(递归下去)的总大小。

按最后一列排序,java.lang.Class,java.lang.Object这种没有分析价值(或者是我目前水平不够,还无法利用这些信息)。可以看到第三方框架类MockClassLoader,占用了主要的内存,而且这个是在主动gc以后的(上方有个绿色的圈圈写着Run GC)。而且这个内存占用基本接近Memory的谷底内存。

基本可以断定,内存泄漏主要是因为MockClassLoader没有被释放。

 

占主要空间对象补充。。

如果上一个步骤,有相同多的第三方框架对象实例占用,可以分析一下它们之间的引用关系,来抓住主要矛盾。

例如,我最开始,分析的是javassist.bytecode.Utf8Info,但是在references中,选择merged incoming references,可以合并同类引用,并给出百分比。

 

 

可以推断出还是MockClassLoader的问题。

 

分析MockClassLoader被引用情况

双击MockClassLoader,进入References,选择Incoming references(incoming是谁引用了它,outgoing是它引用了谁)。

接下来,点击Show Paths To GC Root。来分析它的引用链。

官方说,大多数情况下,Single root可以解决大部分问题,但是,这里有的对象,例如ClassA, ClassA如果中间用了比如ClassA.class的情况,可能就会出现循环引用,自己引用自己的情况,这时就需要All roots,目标工程比较庞大,single root解决不了的,我就all root。

All root出来的路会很多,也不容易找到最终的那个GC root。

    这一步,分析MockClassLoader的被引用,没有得到太多有价值的信息。想到这个类加载器没被gc,是它加载过的类的实例还在堆里被引用所导致的。

    因此我觉定,分析MockClassLoader加载过的类的instance的引用情况。

 

监控ClassLoader

之后在监控面板,看到那些没有被回收的MockClassLoader,右键,show in heap walker。

    然后选择show loaded instances,查看它加载过的类。

    之后在Heap Walker中显示。

 

 

    接下来举几个分析GC Root栗子。

 

子线程未结束

       在Thread那里,可以看到线程越来越多,然后开启CPU recording,

 

    查看创建线程的方法栈。可以确定是log4j框架的AsyncAppender.

    然后在MockClassLoader加载过的类中(Show in Heap Walker),搜索这个类,点击这个类的GC Root,可以看到这个:

   

 

可以注意钩子形状的那个符号(非高亮行),看了这个就是看到了胜利。

进一步印证想法,这里导致MockClassLoader没释放的第一原因(后面可以看到还有第二、第三、第n原因)。

这个对象的某些方法还在方法栈里。

 

然后在每个单测结束后,搜索所有线程,interrupt结束多余线程。

出现下一个问题。

 

子线程被加入shutdownHooks

类似的,一步步分析(A的引用链路找到B,那就再去找B,依次下去),最后找到这个:

 

ApplicationShutdownHooks是BootstrapClassLoader加载的,它的static变量hooks里头若干层存了Thread实例,Thread的contextClassLoader是MockClassLoader,MockClassLoader的classes变量又保存了AsyncAppenderAdmin。

关键在于这个Thread实例。

在单测结束后,通过反射,拿到hooks变量,将其还原为单测前的状态,接下来出现了下一个引用问题。

 

MbeanServerFactory

       这个分析过程和上一个ShutdownHooks类似,没有截图。

 

        释放掉这个MbeanServerFactory static变量实例引用了,还释放了WhiteboxImpl。此时,我的那个项目,大约2/3的MockClassLoader被GC了。

        剩下1/3,没有GC,比较顽固,我分析过程中,出现了,A-B-C-A-B-C循环引用的情况,还没有去分析出来。

        虽然这样手动释放不能解决本质问题,但是对个人修炼(指作者本人)还是比较有帮助的。

        到这一步,我决定先搁置了,尝试去解决本质问题,再回头来分析这个顽固loader。

 

3.3主进程内存分析

    主进程内存分析简单,在Live Memory里看一下基本就能确定问题,不需要用到Heap Walker。

    在Live Memory中按照Instance Count排序,发现出现特别多的对象。注意这里的Size也是浅堆大小。

 

     看到BlockLocation达到3000万个了,直接就能推断出,是probeToBlock这个数据结构的问题。直接把断点打在org.pitest.coverage.execute.Receive这个文件相关位置,作为分析入口,一点点推理下去就能分析出来了。

 

 

四、小结

    中间,分析、学习的过程当然不会像上面,一步一步那么顺。经常会卡壳不知所措。但幸运的是,疲惫了一阵子,总算发现了其问题的本质。

    同时积累了经验。之后遇到类似问题,就知道首先要往哪里看,往哪边想了。

    写下这些,希望能够对刚接触这方面的同学有所帮助。写的不好的地方,也欢迎指正。

 

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值