由SoftRefLRUPolicyMSPerMB=0引起的频繁Full GC问题排查实战

一、现象

在一个很稀松平常的上午,我收到公司业务监控系统的告警短信,提醒说我负责的 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 机制可以避免调用本地方法,从而提高代码性能。

九、参考资料

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值