一、现象
在一个很稀松平常的上午,我收到公司业务监控系统的告警短信,提醒说我负责的 A 业务出现频繁Full GC,收到短信,我立马去监控系统页面查看 A 业务的 JVM GC 可视化信息,发现 metaspace 垃圾收集情况如下图
二、问题排查
metaspace 是用来存放类的元数据的,既然是 metaspace 频繁 GC,我想会不会是加载的类过多导致的,于是我使用 arthas classloader
命令,查看类的加载情况,结果如下:
发现 sun.reflect.DelegatingClassLoader 类加载器有几万个实例(图中数据是优化后的),并且每一个实例只加载了一个类,为了弄清楚这些被加载的类都是什么,我使用 arthas classloader -a
命令,发现 DelegatingClassLoader 类加载器加载的都是 sun.reflect.GeneratedMethodAccessor* 和 sun.reflect.GeneratedConstructorAccessor*(其中 * 表示数字)这些类,查阅相关的资料发现,这些类是在反射调用 inflaction 机制中生成的。
三、反射调用 inflation 机制
一段简单的反射调用代码如下:
Method method = User.class.getMethod("setUserId", Integer.class);
method.invoke(obj, args);
首先通过 Class 对象获取指定的方法对象,然后调用方法对象的 invoke 方法,深入 invoke 方法内部,我揭开了 inflation 机制的神秘面纱,发现 sun.reflect.NativeMethodAccessorImpl 类中有这么一段代码
public Object invoke(Object var1, Object[] var2) throws IllegalArgumentException, InvocationTargetException {
if (++this.numInvocations > ReflectionFactory.inflationThreshold() && !ReflectUtil.isVMAnonymousClass(this.method.getDeclaringClass())) {
MethodAccessorImpl var3 = (MethodAccessorImpl)(new MethodAccessorGenerator()).generateMethod(this.method.getDeclaringClass(), this.method.getName(), this.method.getParameterTypes(), this.method.getReturnType(), this.method.getExceptionTypes(), this.method.getModifiers());
this.parent.setDelegate(var3);
}
return invoke0(this.method, var1, var2);
}
private static native Object invoke0(Method var0, Object var1, Object[] var2);
这段代码的意思是,每次调用 invoke 方法实际上都是调用的 invoke0 本地方法,但是调用本地方法会有一点性能损耗,于是 JVM 做了一个优化,当 numInvocations 的值大于 inflationThreshold 时,生成一个动态类,然后每次调用 invoke 方法就直接调用动态类对象的 invoke 方法,而不再调用本地方法,也就是说同一个方法的反射调用次数超过 15 次(默认值)后,就动态生成一个反射相关的类,后面直接调用生成类对象的 invoke 方法即可。这就是反射的 inflation 机制。调用流程图如下
四、进一步排查
知道反射调用 inflation 原理后,发现它是正常的代码优化,并不会导致类爆炸,进一步观察发现项目的 JVM 参数中有一个和软引用相关的变量被设置成了 0,也就是 -XX:SoftRefLRUPolicyMSPerMB=0
,对于软引用对象,只要堆内存不够了,该对象就会被回收来释放空间。不过这只是理论,软引用对象最终能够被回收需要满足一定的条件,条件公式如下:
clock - timestamp <= freespace * SoftRefLRUPolicyMSPerMB
clock 表示上次 GC 的时间戳,timestamp 表示最近一次读取软引用对象的时间戳,这两者的差值表示该软引用对象多久没被使用了,差值越大,软引用对象价值越低,负数则表示软引用对象刚刚被使用。freespace 是空闲空间大小,SoftRefLRUPolicyMSPerMB 表示每 MB 的空闲内存空间可以允许软引用对象存活多久,这也就间接的解释了,为什么空间不够用,空闲空间越小,软引用对象就会被回收,因为它的存活时间会随着空闲空间的减小而递减。可以把 freespace * SoftRefLRUPolicyMSPerMB
理解为忍耐度,即对软引用对象的忍耐程度。所以如果上述公式成立,那么软引用对象就不会被回收,反之则需要回收该软引用对象。当 -XX:SoftRefLRUPolicyMSPerMB=0
时公式图解如下:
那么,反射的时候会用到软引用吗?深入代码 Method method = User.class.getMethod("setUserId", Integer.class)
内部发现,method 对象第一次是从 JVM 本地方法中获取,然后缓存在 reflectionData 软引用属性中,后面再想获取这个 method 对象的话,就可以直接从缓存中获取,相关代码在 Class 类中如下:
// 缓存属性是软引用
private volatile transient SoftReference<ReflectionData<T>> reflectionData;
private Method[] privateGetDeclaredMethods(boolean publicOnly) {
checkInitted();
Method[] res;
// 从缓存中拿
ReflectionData<T> rd = reflectionData();
if (rd != null) {
res = publicOnly ? rd.declaredPublicMethods : rd.declaredMethods;
if (res != null) return res;
}
// 缓存中没有,从JVM中拿
res = Reflection.filterMethods(this, getDeclaredMethods0(publicOnly));
if (rd != null) {
if (publicOnly) {
rd.declaredPublicMethods = res;
} else {
rd.declaredMethods = res;
}
}
return res;
}
private native Method[] getDeclaredMethods0(boolean publicOnly);
从缓存中拿到的 method 对象并不直接使用,而是复制一份后再返回给调用者,复制的时候,会将缓存 method 对象中的 methodAccessor 属性(也就是图三中 DelegatingMethodAccessorImpl 对象)一同复制给新的 method 对象。因此,对于下面的代码
Method method1 = User.class.getMethod("setUserId", Integer.class);
Method method2 = User.class.getMethod("setUserId", Integer.class);
method1 和 method2 都拷贝自同一个缓存对象,且持有同一个 methodAccessor 对象,也就意味着它们共享反射调用 inflation 机制的 numInvocations 计数。
知道了反射调用的 inflation 机制以及软引用的回收公式,那么类爆炸是怎么发生的呢?原来当设置 JVM 参数 -XX:SoftRefLRUPolicyMSPerMB=0
以后,有很大概率会在下一次 YGC 的时候回收堆内存中的软引用,那么对于 Method method = User.class.getMethod("setUserId", Integer.class);
代码,底层缓存中的 method 对象将会被回收,而重新从 JVM 中获取并放入缓存,然后复制一个新的对象返回。那么在反射调用的时候,这个 method 对象的 invoke 方法执行次数就会重新开始计算,等到累积 15 次以上的调用后,又会生成新的 GeneratedMethodAccessor* 动态类的对象,从而导致产生大量的动态类被加载到 metaspace。
五、结论
通过上面分析,metaspace 频繁 Full GC 的原因就呼之欲出了。由于程序中大量使用了反射调用,当请求频繁的时候,会触发反射调用的 inflation 机制,产生 GeneratedMethodAccessor* 动态类,又因为 JVM 参数 XX:SoftRefLRUPolicyMSPerMB=0
,导致缓存中的 method 软引用对象很快被回收,在后续反射调用达到一定次数的时候会再次产生 GeneratedMethodAccessor* 动态类,当 metaspace 加载的类所占空间达到阈值,会触发 Full GC 进行类卸载,如此反复。
六、解决方案
最后的解决方案就是将 SoftRefLRUPolicyMSPerMB 的值设置成 1000,让软引用对象能在堆中多存活一段时间,也就间接消除了 inflation 机制的类爆炸现象,后续通过监控观察,Full GC 的频次终于回归正常。
七、复现
经过调研发现在业务代码 Controller 层使用 @ResponseBody 和 @RequestBody 注解时,默认会使用 jackson 框架进行序列化和反序列化,在序列化和反序列化过程中,会用到反射调用,同时,一些第三方的 RPC 框架的远程调用过程中也会使用到反射,为了简单起见,我使用下面这段代码来复现 metaspace 频繁 Full GC 的现象。
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtField;
import javassist.CtMethod;
import javassist.CtNewMethod;
import javassist.bytecode.AccessFlag;
import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;
/**
* 使用下面这些JVM参数运行程序:
* -XX:MetaspaceSize=128M
* -XX:MaxMetaspaceSize=128M
* -XX:+TraceClassLoading
* -XX:+TraceClassUnloading
* -XX:+PrintGCDetails
* -Xms120m
* -Xmx120m
* -XX:+UseConcMarkSweepGC
* -XX:SoftRefLRUPolicyMSPerMB=0
*
* @author debo
* @date 2022-07-17
*/
public class MetaspaceFullGCTest {
public static void main(String[] args) throws Exception {
for (int i = 1; i <= 40000; i++) {
TimeUnit.MILLISECONDS.sleep(20);
Class clz = generateClass(i);
Object o = clz.newInstance();
for (int j = 1; j <= 500; j++) {
Method setUserId = clz.getMethod("setUserId" + i, int.class);
setUserId.invoke(o, 1);
if (j % 18 == 0) {
// 制造内存紧张
byte[] _28M = new byte[28 * 1024 * 1024];
_28M = null;
// clock - timestamp必大于0
_28M = new byte[28 * 1024 * 1024];
}
}
System.out.println("结束了:" + i);
}
}
/**
* 动态创建类
*
* @param idx
* @return
*/
private static Class generateClass(int idx) {
ClassPool pool = ClassPool.getDefault();
// 创建类
CtClass ct = pool.makeClass("tmp.User" + idx);
try {
// 获得一个类型为int,名称为userId的字段
CtField userId = new CtField(CtClass.intType, "userId" + idx, ct);
// 将字段设置为public
userId.setModifiers(AccessFlag.PUBLIC);
// 将字段设置到类上
ct.addField(userId);
// 添加方法
CtMethod setUserId = CtNewMethod.make("public void setUserId" + idx + "(int userId){this.userId" + idx + " = userId;}", ct);
ct.addMethod(setUserId);
// 将生成的.class文件保存到磁盘
ct.writeFile();
Class clz = ct.toClass();
// 防止内存溢出
ct.detach();
return clz;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
八、GeneratedMethodAccessor* 源码长啥样
反射 inflation 机制中生成的 GeneratedMethodAccesssor* 源码如何获取呢?有两种办法
-
可以用 arthas 来查看,运行 arthas 命令
jad sun.reflect.GeneratedMethodAccessor2000
来查看 GeneratedMethodAccessor2000 类的源码,不过这种方式只能一个类一个类地看。 -
也可以用这个工具 dumpclass,先将工具下载到本地,然后使用命令
java -jar dumpclass-0.0.2.jar -p 122250 *GeneratedMethodAccessor*
将全部的 GeneratedMethodAccessor* 类导出到本地,其中的 122250 是 java 进程的 pid。
针对复现章节中的程序,选一个 GeneratedMethodAccessor class 文件,使用 IDEA 打开,反编译后的源码如下:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package sun.reflect;
import java.lang.reflect.InvocationTargetException;
import tmp.User1;
public class GeneratedMethodAccessor1 extends MethodAccessorImpl {
public Object invoke(Object var1, Object[] var2) throws InvocationTargetException {
if (var1 == null) {
throw new NullPointerException();
} else {
User1 var10000;
int var10001;
try {
var10000 = (User1)var1;
if (var2.length != 1) {
throw new IllegalArgumentException();
}
Object var3 = var2[0];
if (var3 instanceof Byte) {
var10001 = (Byte)var3;
} else if (var3 instanceof Character) {
var10001 = (Character)var3;
} else if (var3 instanceof Short) {
var10001 = (Short)var3;
} else {
if (!(var3 instanceof Integer)) {
throw new IllegalArgumentException();
}
var10001 = (Integer)var3;
}
} catch (NullPointerException | ClassCastException var5) {
throw new IllegalArgumentException(var5.toString());
}
try {
var10000.setUserId1(var10001);
return null;
} catch (Throwable var4) {
throw new InvocationTargetException(var4);
}
}
}
public GeneratedMethodAccessor1() {
}
}
从反编译后的源码看出,这就是一个普通的方法调用,所以 inflation 机制可以避免调用本地方法,从而提高代码性能。