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

标签: dev | 发表时间:2017-07-22 08:00 | 作者:
出处:http://itindex.net/relian

概述

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

可是就在上周突然有机器出现了Full GC,本来G1设计出来就是希望Full GC不在出现,出现Full GC一般是不正常,GC日志如下:


从上面日志不难发现是因为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这个类来的,而关键的几个方法和属性如下:


在Class里有个关键的属性叫做reflectionData,这里主要存的是每次从jvm里获取到的一些类属性,比如方法,字段等,大概长这样


这个属性主要是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方法:


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

Method调用

有了Method之后,那就可以调用其invoke方法了,那先看看Method的几个关键信息


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

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

MethodAccessor的实现

MethodAccessor本身就是一个接口


其主要有三种实现

  • DelegatingMethodAccessorImpl
  • NativeMethodAccessorImpl
  • GeneratedMethodAccessorXXX

其中 DelegatingMethodAccessorImpl是最终注入给Method的 methodAccessor的,也就是某个Method的所有的invoke方法都会调用到这个 DelegatingMethodAccessorImpl.invoke,正如其名一样的,是做代理的,也就是真正的实现可以是下面的两种


如果是 NativeMethodAccessorImpl,那顾名思义,该实现主要是native实现的,而 GeneratedMethodAccessorXXX是为每个需要反射调用的Method动态生成的类,后的XXX是一个数字,不断递增的

并且所有的方法反射都是先走 NativeMethodAccessorImpl,默认调了15次之后,才生成一个 GeneratedMethodAccessorXXX类,生成好之后就会走这个生成的类的invoke方法了

那如何从 NativeMethodAccessorImpl过度到 GeneratedMethodAccessorXXX呢,来看看 NativeMethodAccessorImpl的invoke方法


其中我上面说的是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究竟长什么样呢,大概这样了


其实就是直接调用目标对象的具体方法了,和正常的方法调用没什么区别

GeneratedMethodAccessorXXX的类加载器

那加载 GeneratedMethodAccessorXXX的类加载器是什么呢,在生成好了字节码之后会调用下面的方法做类定义


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

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

并发导致垃圾类创建

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

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

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

运行时Dump类字节码

我们可以利用SA的接口从coredump里或者live进程里将对应的类dump下来,为了dump下来我们特定的类,首先我们写一个Filter类


使用SA的jar( $JAVA_HOME/lib/sa-jdi.jar)编译好类之后,然后我们在编译好的类目录下调用下面的命令进行dump


这样我们就可以将所有的 GeneratedMethodAccessor给dump下来了,这个时候我们再通过 javap -verbose GeneratedMethodAccessor9随便看一个类的字节码


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

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

dump出这些字节码之后,我们对这些所有的类的字节码做一个统计,就找出了所有的反射调用方法,然后发现某些model类(package都是相同的)居然产生了20多万个类,这意味着有非常多的这些model类做反射


有了这个线索之后就去看代码究竟哪里会有调用这些model方法的反射逻辑,但是可惜没有找到,但是这种model对象极有可能在某种情况下出现,那就是rpc反序列化的时候,最终询问业务方是使用的Xfire的服务,而凭借我多年框架开发积累的经验,确定Xfire就是通过反射的方式来反序列化对象的,具体代码如下( org.codehaus.xfire.aegis.type.basic.BeanType.writeProperty):

而javabean的 PropertyDescriptor里的 get/set方法,其实本身就是SoftReference包装的


看到这里或许大家都明白了吧,前面也已经说了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才得以释放

===这次广告真不能忘了===

蚂蚁中间件团队是服务于整个蚂蚁金服集团(包括支付宝钱包、网商银行、蚂蚁小贷、芝麻信用、蚂蚁聚宝、蚂蚁国际、口碑等)的核心技术团队,致力于打造支撑每秒亿级金融交易规模的基础中间件平台,我们为高速发展的业务提供金融级的高可用、高性能和可扩展的基础服务,也为围绕蚂蚁的业务生态的合作伙伴提供技术赋能和产品化输出,我们的目标是打造最专业的,最可靠的世界级的金融分布式高可用基础服务。 期待您的加入,一起在蚂蚁这个极具挑战和丰富多彩的业务舞台上玩技术!


想来蚂蚁中间件的可以发简历到我邮箱jiapeng.li#(替换成什么你懂的)alipay.com 

本人其他JVM相关文章



欢迎各位关注个人微信公众号,主要围绕JVM写一系列的原理性,性能调优的文章

相关 [gc 反射 原理] 推荐:

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

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

JVM垃圾回收(GC)原理

- kill - yiihsia[互联网后端技术]_yiihsia[互联网后端技术]
引用计数(Reference Counting). 原理是此对象有一个引用,即增加一个计数,删除一个引用则减少一个计数. 垃圾回收时,只用收集计数为0的对象. 此算法最致命的是无法处理循环引用的问题. 标记-清除(Mark-Sweep). 第一阶段从引用根节点开始标记所有被引用的对象,第二阶段遍历整个堆,把未标记的对象清除.

Java GC的工作原理详解

- - Java - 编程语言 - ITeye博客
JVM学习笔记之JVM内存管理和JVM垃圾回收的概念,JVM内存结构由堆、栈、本地方法栈、方法区等部分组成,另外JVM分别对新生代和旧生代采用不同的垃圾回收机制. 首先来看一下JVM内存结构,它是由堆、栈、本地方法栈、方法区等部分组成,结构图如下所示. JVM学习笔记 JVM内存管理和JVM垃圾回收.

一次CMS GC问题排查过程(理解原理+读懂GC日志)

- - ITeye博客
这个是之前处理过的一个线上问题,处理过程断断续续,经历了两周多的时间,中间各种尝试,总结如下. 1、问题的场景和处理过程;2、GC的一些理论东西;3、看懂GC的日志. 问题场景:线上机器在半夜会推送一个700M左右的数据,这个时候有个数据置换的过程,也就是说有700M*2的数据在heap区域中,线上系统超时比较多,导致了很严重(严重程度就不说了)的问题.

JVM初探- 内存分配、GC原理与垃圾收集器

- - IT瘾-geek
JVM初探- 内存分配、GC原理与垃圾收集器. JVM内存的分配与回收大致可分为如下4个步骤: 何时分配 -> 怎样分配 -> 何时回收 -> 怎样回收. new时分配外, 我们着重介绍后面的3个步骤:. 怎样分配- JVM内存分配策略. 对象内存主要分配在新生代 Eden区, 如果启用了本地线程分配缓冲, 则 优先在TLAB上分配, 少数情况能会直接分配在老年代, 或被拆分成标量类型在栈上分配(JIT优化).

Java GC 调优

- - Darktea
关于 Java GC 已经有很多好的文档了, 比如这些:. 但是这里还是想再重点整理一下 Java GC 日志的格式, 可以作为实战时的备忘录.. 同时也会再整理一下各种概念. 一, JDK 6 提供的各种垃圾收集器. 先整理一下各种垃圾收集器.. 新生代收集器: Serial, ParNew, Parallel Scavenge (MaxGCPauseMillis vs.

[译]GC专家系列3-GC调优

- - SegmentFault 最新的文章
原文链接: http://www.cubrid.org/blog/dev-platform/how-to-tune-java-garbage-collection/. 本篇是”GC专家系列“的第三篇. 在第一篇 理解Java垃圾回收中我们学习了几种不同的GC算法的处理过程,GC的工作方式,新生代与老年代的区别.

GC 日志分析

- - 码蜂笔记
不同的JVM及其选项会输出不同的日志. 生成下面日志使用的选项: -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:d:/GClogs/tomcat6-gc.log. 最前面的数字 4.231 和 4.445 代表虚拟机启动以来的秒数.

初级分代GC

- - C++博客-首页原创精华区
通常情况下GC分为两种,分别是:扫描GC(Tracing GC)和引用计数GC(Reference counting GC). 其中扫描GC是比较常用的GC实现方法,其原理是:把正在使用的对象找出来,然后把未被使用的对象释放. 而引用计数GC则是对每个对象都添加一个计数器,引用增加一个计数器就加一,引用减少一个计数器就减一,当计数器减至零时,把对象回收释放.

一个GC频繁的Case

- loudly - BlueDavy之技术Blog
前两天碰到一个很诡异的GC频繁的现象,走了不少弯路,N种方法查找后才终于查明原因了,在这篇blog中记录下,以便以后碰到这类问题时能更快的解决. 前两天一位同学找到我,说有个应用在启动后就一直Full GC,拿到GC log先看了下,确实是非常的诡异,截取的部分log如下:. 这个日志中诡异的地方在于每次Full GC的时候旧生代都还有很多的空间,于是去看来下启动参数,此时的启动参数如下:.