性能文章>从一起GC血案谈到反射原理>

从一起GC血案谈到反射原理原创

5年前
20796537

前言

首先回答一下提问者的问题。这主要是由于存在大量反射而产生的临时类加载器和 ASM 临时生成的类,这些类会被保留在 Metaspace,一旦 Metaspace 即将满的时候,就会触发 FullGc,已达到回收不再被使用的类对象的目的。具体问题请参考接下来的内容,更好的了解反射的实现原理。

正文

概述

公司之前有个大内存系统(70G以上)一直使用CMS GC,不过因为该系统对时间很敏感,偶尔会因为gclocker导致remark特别长(虽然加了-XX:+CMSScavReengeBeforeRemark参数,但是gclocker会导致remark前的YGC被delay),无法忍受这么长的暂停就只好迁移到了G1,经过一系列的调优之后算比较稳定了,这套参数便推到了全部机器上

可是就在上周突然有机器出现了Full GC,本来G1设计出来就是希望Full GC不在出现,出现Full GC一般是不正常,GC日志如下:
从一起GC血案谈到反射原理数据图表-heapdump性能社区

从上面日志不难发现是因为Perm触发的Full GC,并且Full GC之后Perm就降下去了,不过需要提一下的是JDK7下正常的G1 GC是不会做类卸载的,只有Full GC的时候才会卸载,但JDK8下是提供了相关参数的可以在G1 GC某些阶段做类卸载

于是要业务方先做了coredump,保存好现场再重启系统,然后再针对coredump做了heap dump,不过heapdump有40G这么大,可以通过jmap -permstat <executable java> core.xxx来看看究竟perm里有什么东西

这篇文章相对来说比较长,涉及到的知识点比较多,如果实在忍不住看下去,可以跳到最后看下我对这个问题的描述再反过来看这篇文章或许让你有更清晰的认识

Perm里究竟塞了什么

既然是Perm满了,那我们得看Perm里究竟放了什么,我们知道Perm里主要存的是类的原始数据,比如我们加载了一个类,那这个类的信息会在Perm里分配内存来存储它的一些数据结构,所以大部分情况下,Perm的使用量和加载的类个数是关系很大的,当然Perm里在低版本的时候还会存一些其他的数据,比如String(String.intern()的情况)。

另外经验告诉我们如果真的是Perm溢出,那有地方动态构建一个类加载器加载一个类的可能性会很大,通过上面的jmap命令,我们可以统计下sun.reflect.DelegatingClassLoader的个数居然达到了415737个

那基本可以锁定是反射类加载器导致Perm溢出的原因了,那究竟为什么会有这么多反射类加载器呢,反射类加载器又是什么,接下来先简单说下反射的原理

反射的原理

反射大家用起来很方便,由于性能其实也比较不错了,因此用得挺广的,我们通常这么用反射

Method method = XXX.class.getDeclaredMethod(xx,xx);method.invoke(target,params)

不过这里我不准备用大量的代码来描述其原理,而是讲几个关键的东西,然后将他们串起来

获取Method

要调用首先要获取Method,而获取Method的逻辑是通过Class这个类来的,而关键的几个方法和属性如下:
从一起GC血案谈到反射原理数据图表-heapdump性能社区

在Class里有个关键的属性叫做reflectionData,这里主要存的是每次从jvm里获取到的一些类属性,比如方法,字段等,大概长这样
从一起GC血案谈到反射原理数据图表-heapdump性能社区

这个属性主要是SoftReference的,也就是在某些内存比较苛刻的情况下是可能被回收的,不过正常情况下可以通过-XX:SoftRefLRUPolicyMSPerMB这个参数来控制回收的时机,一旦时机到了,只要GC发生就会将其回收,那回收之后意味着再有需求的时候要重新创建一个这样的对象,同时也需要从JVM里重新拿一份数据,那这个数据结构关联的Method,Field字段等都是重新生成的对象。如果是重新生成的对象那可能有什么麻烦?讲到后面就明白了

getDeclaredMethod方法其实很简单,就是从privateGetDeclaredMethods返回的方法列表里复制一个Method对象返回。而这个复制的过程是通过searchMethods实现的

如果reflectionData这个属性的declaredMethods非空,那privateGetDeclaredMethods就直接返回其就可以了,否则就从JVM里去捞一把出来,并赋值给reflectionData的字段,这样下次再调用privateGetDeclaredMethods时候就可以用缓存数据了,不用每次调到JVM里去获取数据,因为reflectionData是Softreference,所以存在取不到值的风险,一旦取不到就又去JVM里捞了

searchMethods将从privateGetDeclaredMethods返回的方法列表里找到一个同名的匹配的方法,然后复制一个方法对象出来,这个复制的具体实现,其实就是Method.copy方法:
从一起GC血案谈到反射原理数据图表-heapdump性能社区

由此可见,我们每次通过调用getDeclaredMethod方法返回的Method对象其实都是一个新的对象,所以不宜多调哦,如果调用频繁最好缓存起来。不过这个新的方法对象都有个root属性指向reflectionData里缓存的某个方法,同时其methodAccessor也是用的缓存里的那个Method的methodAccessor。

Method调用

有了Method之后,那就可以调用其invoke方法了,那先看看Method的几个关键信息
从一起GC血案谈到反射原理数据图表-heapdump性能社区

root属性其实上面已经说了,主要指向缓存里的Method对象,也就是当前这个Method对象其实是根据root这个Method构建出来的,因此存在一个root Method派生出多个Method的情况。

methodAccessor这个很关键了,其实Method.invoke方法就是调用methodAccessor的invoke方法,methodAccessor这个属性如果root本身已经有了,那就直接用root的methodAccessor赋值过来,否则的话就创建一个

MethodAccessor的实现

MethodAccessor本身就是一个接口
从一起GC血案谈到反射原理数据图表-heapdump性能社区

其主要有三种实现

  • DelegatingMethodAccessorImpl
  • NativeMethodAccessorImpl
  • GeneratedMethodAccessorXXX

其中DelegatingMethodAccessorImpl是最终注入给Method的methodAccessor的,也就是某个Method的所有的invoke方法都会调用到这个DelegatingMethodAccessorImpl.invoke,正如其名一样的,是做代理的,也就是真正的实现可以是下面的两种
从一起GC血案谈到反射原理数据图表-heapdump性能社区
如果是NativeMethodAccessorImpl,那顾名思义,该实现主要是native实现的,而GeneratedMethodAccessorXXX是为每个需要反射调用的Method动态生成的类,后的XXX是一个数字,不断递增的
并且所有的方法反射都是先走NativeMethodAccessorImpl,默认调了15次之后,才生成一个GeneratedMethodAccessorXXX类,生成好之后就会走这个生成的类的invoke方法了
那如何从NativeMethodAccessorImpl过度到GeneratedMethodAccessorXXX呢,来看看NativeMethodAccessorImpl的invoke方法
从一起GC血案谈到反射原理数据图表-heapdump性能社区
其中我上面说的是15次就是ReflectionFactory.inflationThreshold()这个方法返回的,这个15当然也不是一尘不变的,我们可以通过-Dsun.reflect.inflationThreshold=xxx来指定,我们还可以通过-Dsun.reflect.noInflation=true来直接绕过上面的15次NativeMethodAccessorImpl调用,和-Dsun.reflect.inflationThreshold=0的效果一样的
而GeneratedMethodAccessorXXX都是通过new MethodAccessorGenerator().generateMethod来生成的,一旦创建好之后就设置到DelegatingMethodAccessorImpl里去了,这样下次Method.invoke就会调到这个新创建的MethodAccessor里了。

那生成的GeneratedMethodAccessorXXX究竟长什么样呢,大概这样了
从一起GC血案谈到反射原理数据图表-heapdump性能社区
其实就是直接调用目标对象的具体方法了,和正常的方法调用没什么区别

GeneratedMethodAccessorXXX的类加载器

那加载GeneratedMethodAccessorXXX的类加载器是什么呢,在生成好了字节码之后会调用下面的方法做类定义
从一起GC血案谈到反射原理数据图表-heapdump性能社区

所以GeneratedMethodAccessorXXX的类加载器其实是一个DelegatingClassLoader类加载器

之所以搞一个新的类加载器,是为了性能考虑,在某些情况下可以卸载这些生成的类,因为类的卸载是只有在类加载器可以被回收的情况下才会被回收的,如果用了原来的类加载器,那可能导致这些新创建的类一直无法被卸载,从其设计来看本身就不希望他们一直存在内存里的,在需要的时候有就行了,在内存紧俏的时候可以释放掉内存

并发导致垃圾类创建

看到这里不知道大家是否发现了一个问题,上面的NativeMethodAccessorImpl.invoke其实都是不加锁的,那意味着什么?如果并发很高的时候,是不是意味着可能同时有很多线程进入到创建GeneratedMethodAccessorXXX类的逻辑里,虽然说最终使用的其实只会有一个,但是这些开销是不是已然存在了,假如有1000个线程都进入到创建GeneratedMethodAccessorXXX的逻辑里,那意味着多创建了999个无用的类,这些类会一直占着内存,直到能回收Perm的GC发生才会回收

那究竟是什么方法在不断反射呢

有了上面对反射原理的了解之后,我们知道了在反射执行到一定次数之后,其实会动态构建一个类,在这个类里会直接调用目标对象的对应的方法,我们从heap dump里看到了有大量的DelegatingClassLoader类加载器加载了GeneratedMethodAccessorXXX类,那这些类到底是调用了什么方法呢,于是我们不得不做一件事,那就是将内存里的这些类都dump下来,然后对字节码做一个统计分析一下

运行时Dump类字节码

我们可以利用SA的接口从coredump里或者live进程里将对应的类dump下来,为了dump下来我们特定的类,首先我们写一个Filter类
从一起GC血案谈到反射原理数据图表-heapdump性能社区

使用SA的jar($JAVA_HOME/lib/sa-jdi.jar)编译好类之后,然后我们在编译好的类目录下调用下面的命令进行dump
从一起GC血案谈到反射原理数据图表-heapdump性能社区

这样我们就可以将所有的GeneratedMethodAccessor给dump下来了,这个时候我们再通过javap -verbose GeneratedMethodAccessor9随便看一个类的字节码
从一起GC血案谈到反射原理数据图表-heapdump性能社区

看到上面关键的bci为36的那行,这里的方法便是我们反射调用的方法了,比如上面的那个反射调用的方法就是org/codehaus/xfire/util/ParamReader.readCode

定位到具体的反射类及方法

dump出这些字节码之后,我们对这些所有的类的字节码做一个统计,就找出了所有的反射调用方法,然后发现某些model类(package都是相同的)居然产生了20多万个类,这意味着有非常多的这些model类做反射
从一起GC血案谈到反射原理数据图表-heapdump性能社区
有了这个线索之后就去看代码究竟哪里会有调用这些model方法的反射逻辑,但是可惜没有找到,但是这种model对象极有可能在某种情况下出现,那就是rpc反序列化的时候,最终询问业务方是使用的Xfire的服务,而凭借我多年框架开发积累的经验,确定Xfire就是通过反射的方式来反序列化对象的,具体代码如下(org.codehaus.xfire.aegis.type.basic.BeanType.writeProperty):
从一起GC血案谈到反射原理数据图表-heapdump性能社区
而javabean的PropertyDeor里的get/set方法,其实本身就是SoftReference包装的
从一起GC血案谈到反射原理数据图表-heapdump性能社区

看到这里或许大家都明白了吧,前面也已经说了SoftReference是可能被GC回收掉的,时间一到在下次GC里就会被回收,如果被回收了,那就要重新获取,然后相当于是调用的新的Method对象的invoke方法,那调用次数一多,就会产生新的动态构建的类,而这份类会一直存到直到可以回收Perm的GC

G1回收Perm

注意下业务系统使用的是JDK7的G1,而JDK7的G1对perm其实正常情况下是不会回收的,只有在Full GC的时候才会回收Perm,这就解释了经过了多次G1 GC之后,那些Softreference的对象会被回收,但是新产生的类其实并不会被回收,所以G1 GC越频繁,那意味着SoftReference的对象越容易被回收(虽然正常情况下是时间到了,但是如果gc不频繁,即使时间到了,也会留在内存里的),越容易被回收那就越容易产生新的类,直到Full GC发生

解决方案

  • 升级到jdk8,可以在G1 GC过程中对类做卸载
  • 换一个序列化协议,不走方法反射的,比如hessian
  • 调整SoftRefLRUPolicyMSPerMB这个参数变大,不过这个不能治本

总结

上面涉及的内容非常多,如果不多读几遍可能难以串起来,我这里将这个问题发生的情况大致描述一下:

这个系统在JDK7下使用G1,而这个版本的G1只有在Full GC的时候才会对Perm里的类做卸载,该系统因为大量的请求导致G1 GC发生很频繁,同时该系统还设置了-XX:SoftRefLRUPolicyMSPerMB=0,那意味着SoftReference的生命周期不会跨GC周期,能很快被回收掉,这个系统存在大量的RPC调用,走的Xfire协议,对返回结果做反序列化的时候是走的Method.invoke的逻辑,而相关的method因此被SoftReference引用,因此很容易被回收,一旦被回收,那就创建一个新的Method对象,再调用其invoke方法,在调用到一定次数(15次)之后,就构建一个新的字节码类,伴随着GC的进行,同一个方法的字节码类不断构建,直到将Perm充满触发一次Full GC才得以释放
点赞收藏
分类:标签:
你假笨

通过创新的技术肯定可以让这个世界变得更美好,让我们为技术而狂吧

请先登录,查看5条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

从 Linux 内核角度探秘 JDK MappedByteBuffer

从 Linux 内核角度探秘 JDK MappedByteBuffer

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

37
5