由一个GC Overhead线上问题谈谈Metaspace

由一个GC Overhead线上问题谈谈Metaspace,第1张

在对某个服务进行迁移时,我们观察到数据如下。不同颜色代表不同的机器,可以发现很多机器在不同的时间段内都出现了 GC overhead 达到了100%的情况。意味着这段时间内,该机器不能对外提供服务。这是一个很危险的情况,而且并不是偶然。

原因分析

我们找到其中一台机器在GC overhead到达100%时的GC日志,如下:

从日志中可以发现,这两次GC都full GC。在G1中正常情况下是只有 young GC 和 mixed GC , full GC 在 G1 GC 无法满足内存分配需求时就会切换到 serial old GC 来收集整个堆内存。严格意义上来讲, full GC 并不属于G1,而是G1无法满足需求时使用的兜底策略。另外,我们还可以从时间戳和执行时间上发现,这两次GC是连续的,并且花费的时间也很长,这就是 GC Overhead 会升到100%的原因。

第一次GC的原因是 Metadata GC Threshold ,这表示是由MetaSpace空间不足引起的,而经过第一次GC,Metaspace空间并没有减少,于是引起了第二次GC,第二次GC会尝试清除软引用,但是MetaSpace空间依然没有减少。看到这里,第一反应就是MetaSpace有问题。在JDK 1.8中,为了更灵活的管理内存,永久代被移除,取而代之的是Metaspace。

MetaSpace 不再算是JVM的内存,所以我们在计算内存占用时需要用 metaspace+ jvm 的内存。配置永久代的相关参数 PermSize 以及 MaxPermSize 也不会在生效了。检查了启动参数之后,当前的metaspace只有512MB。当在大量使用反射,动态代理,动态生成JSP功能时,会导致Metaspace空间不足,导致无法正常回收。

解决方案

了解原因之后,解决起来就很简单,在启动参数中将 maxMetaSpaceSize 设置为1024MB。改动后 GC Overhead 如下图所示,100%的情况不再出现了。

Metaspace 到底放了什么

既然是Metaspace满了,那我们得看Metaspace里究竟放了什么,我们知道Metaspace里主要存的是类的原始数据,比如我们加载了一个类,那这个类的信息会在Metaspace里分配内存来存储它的一些数据结构,所以大部分情况下,Metaspace的使用量和加载的类个数是关系很大的。上文就是类的数量非常多导致的Metaspace空间不足。

另外还有一种情况Metaspace溢出,是有地方动态构建一个类加载器,同时不断加载一个类,我们遇到过一个案例,通过jmap命令,统计下 sun.reflect.DelegatingClassLoader 的个数居然达到了几万个

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

在java中,反射大部分是这么写,假设有个class A

获取Method

要调用首先要获取Method,而获取Method的逻辑是通过Class这个类来的,而关键的几个方法和属性如下:

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

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

后面的博文会讲 SoftReferenc 不断加载导致的麻烦。

getDeclaredMethod 方法从主要看 searchMethods(privateGetDeclaredMethods).privateGetDeclaredMethods 返回的方法列表里复制一个Method对象返回。如果 reflectionData 这个属性的 declaredMethods 非空,那 privateGetDeclaredMethods 就直接返回其就可以了,否则就从JVM里load,并赋值给 reflectionData 的字段。

searchMethods主要调用

getReflectionFactory().copyMethod(res) ->langReflectAccess().copyMethod(arg) ->ReflectAccess.copyMethod->method.copy

由此可见, getDeclaredMethod 方法返回的Method对象其实都是一个新的对象,所以不宜调用的过多,如果调用频繁最好缓存起来。

Method调用

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

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

MethodAccessor的实现

MethodAccessor 本身就是一个 interface , 有三个实现。

DelegatingMethodAccessorImpl

NativeMethodAccessorImpl

GeneratedMethodAccessorXXX

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

Default 实现是 NativeMethodAccessorImpl ,而 GeneratedMethodAccessorXXX 是为每个需要反射调用的 Method 动态生成的类,后的XXX是一个数字,不断递增的

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

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

ReflectionFactory.inflationThreshold() default 是15次。我们可以通过 -Dsun.reflect.inflationThreshold 来指定,我们还可以通过 -Dsun.reflect.noInflation=true 来直接绕过上面的15次 NativeMethodAccessorImpl 调用,和

-Dsun.reflect.inflationThreshold=0 的效果一样的

而 GeneratedMethodAccessorXXX 都是通过 new MethodAccessorGenerator().generateMethod 来生成的,一旦创建好之后就设置到 DelegatingMethodAccessorImpl 里去了,这样下次 Method.invoke 就会调到这个新创建的 MethodAccessor 里了。

动态创建出来的类由 DelegatingClassLoader 来加载。请看 ClassDefiner , new DelegatingClassLoader 来加载新类。

每次new类加载器,是为了性能考虑,在某些情况下可以卸载这些生成的类,因为类的卸载是只有在类加载器可以被回收的情况下才会被回收的,如果用了原来的类加载器,那可能导致这些新创建的类一直无法被卸载。

NativeMethodAccessorImpl.invoke 其实都是不加锁的,如果并发很高的时候,可能同时有很多线程进入到创建 GeneratedMethodAccessorXXX 类的逻辑里,假如有1000个线程都进入到创建 GeneratedMethodAccessorXXX 的逻辑里,那意味着多创建了999个无用的类,这些类会一直占着内存,直到能回收MetaSpace的GC发生才会回收

sd-jdi.jar 来 dump.sd-jdi.jar 里自带的 sun.jvm.hotspot.tools.jcore.ClassDump 可以把类的class内容dump到文件里。

ClassDump里可以设置两个 System properties :

sun.jvm.hotspot.tools.jcore.filter Filter的类名

sun.jvm.hotspot.tools.jcore.outputDir 输出的目录

首先写一个filter类

然后编译成class文件

要使用这个首先需要把 sa-jdi.jar 加到java的 classpath 里。

进入 刚刚写的filter类的class文件的目录下。执行

把MyFilter改为你自己的类名,pid为目标 java进程的pid(可以使用jps查看)。然后就会在<folder>产生相应的class文件。

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

看36行就可以知道到你那个方法导致的Method 反射过多。

-XX:MaxNewSize=512m JVM堆区域新生代内存的最大可分配大小(PermSize不属于堆区), 生产环境建议设为800M-1024M

-XX:MetaspaceSize 表示的并非是元空间的大小,它的含义是:主要控制matesaceGC发生的初始阈值,也就是最小阈值。也就是说当使用的matespace空间到达了MetaspaceSize的时候,就会触发Metaspace的GC。

-XX:MetaspaceSize和-XX:MaxMetaspaceSize设置元空间初始大小以及最大可分配大小。

例子:设置初始大小是100M,最大可分配空间也是100M。-XX:MetaspaceSize=100m -XX:MaxMetaspaceSize=100m。

2.如果元空间内存不够用,就会报OOM。

3.默认情况下,对应一个64位的服务端JVM来说,其默认的-XX:MetaspaceSize值为21MB,这就是初始的高水位线,一旦元空间的大小触及这个高水位线,就会触发Full GC并会卸载没有用的类,然后高水位线的值将会被重置。

4.从第3点可以知道,如果初始化的高水位线设置过低,会频繁的触发Full GC,高水位线会被多次调整。所以为了避免频繁GC以及调整高水位线,建议将-XX:MetaspaceSize设置为较高的值,而-XX:MaxMetaspaceSize不进行设置。


欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/tougao/11257301.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2023-05-14
下一篇 2023-05-14

发表评论

登录后才能评论

评论列表(0条)

保存