• 由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);
    
    • 1
    • 2

    首先通过 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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    这段代码的意思是,每次调用 invoke 方法实际上都是调用的 invoke0 本地方法,但是调用本地方法会有一点性能损耗,于是 JVM 做了一个优化,当 numInvocations 的值大于 inflationThreshold 时,生成一个动态类,然后每次调用 invoke 方法就直接调用动态类对象的 invoke 方法,而不再调用本地方法,也就是说同一个方法的反射调用次数超过 15 次(默认值)后,就动态生成一个反射相关的类,后面直接调用生成类对象的 invoke 方法即可。这就是反射的 inflation 机制。调用流程图如下

    在这里插入图片描述

    四、进一步排查

    知道反射调用 inflation 原理后,发现它是正常的代码优化,并不会导致类爆炸,进一步观察发现项目的 JVM 参数中有一个和软引用相关的变量被设置成了 0,也就是 -XX:SoftRefLRUPolicyMSPerMB=0,对于软引用对象,只要堆内存不够了,该对象就会被回收来释放空间。不过这只是理论,软引用对象最终能够被回收需要满足一定的条件,条件公式如下:

    clock - timestamp <= freespace * SoftRefLRUPolicyMSPerMB
    
    • 1

    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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    从缓存中拿到的 method 对象并不直接使用,而是复制一份后再返回给调用者,复制的时候,会将缓存 method 对象中的 methodAccessor 属性(也就是图三中 DelegatingMethodAccessorImpl 对象)一同复制给新的 method 对象。因此,对于下面的代码

    Method method1 = User.class.getMethod("setUserId", Integer.class);
    Method method2 = User.class.getMethod("setUserId", Integer.class);
    
    • 1
    • 2

    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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80

    八、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() {
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53

    从反编译后的源码看出,这就是一个普通的方法调用,所以 inflation 机制可以避免调用本地方法,从而提高代码性能。

    九、参考资料

  • 相关阅读:
    添加阿里云maven镜像
    C#文件拷贝工具
    数位DP
    一、K8s中的一些重要概念和常用指令
    iperf带宽探测工具
    Blazor Hybrid 实战体验:那些你可能没预料到的坑没预料到的坑
    什么是SRM系统,如何规范公司内部采购流程
    企业ERP管理系统功能分析
    go语言---锁
    搭载经纬恒润12V BMS的路特斯ELETRE开始量产交付
  • 原文地址:https://blog.csdn.net/xiaoduanayu/article/details/126112897