美文网首页收藏
排查实战之ClassLoader动态加载插件无法回收引用排查

排查实战之ClassLoader动态加载插件无法回收引用排查

作者: 凯凯雄雄 | 来源:发表于2021-12-28 16:17 被阅读0次

    最近在看jvm-sandbox的一些功能,参考着实现了动态加载Jar包插件的功能,但是实现的这个功能有一个比较严重的问题,就是类加载完毕之后,当你需要覆盖或者卸载时候,该类加载器的引用是无法被回收的。也就是说由这个类加载器加载之后,无法卸载,这个加载器一直存在。

    如果一旦新增或者覆盖的jar包过多,会导致类加载器一直堆积。严重点会发生泄漏的风险。

    基于以上场景开始了漫漫排查路。

    代码回顾

    1. 自定义的类加载器

    这个加载器的主要功能是负责路由,也是参考的jvm-sandbox
    主要目的是将加载器隔离:比如主加载器A,插件加载器为B

    同样一个接口A加载器肯定是有的,B加载器也有,如果各自加载那么同一个类也会出现不一致。所以为了保证全局唯一,有一些特定的类B中即便有的话也需要从A中去加载。这就是这个路由的意义。

    /**
     * 可路由的URLClassLoader
     *
     * @author luanjia@taobao.com
     */
    public class ManagerClassLoader extends URLClassLoader {
    
        private final Logger logger = LoggerFactory.getLogger(ManagerClassLoader.class);
        private final Routing[] routingArray;
    
        public ManagerClassLoader(final URL[] urls,
                                  final Routing... routingArray) {
            super(urls);
            this.routingArray = routingArray;
        }
    
        public ManagerClassLoader(final URL[] urls,
                                  final ClassLoader parent,
                                  final Routing... routingArray) {
            super(urls, parent);
            this.routingArray = routingArray;
        }
    
        @Override
        public URL getResource(String name) {
            URL url = findResource(name);
            if (null != url) {
                return url;
            }
            url = super.getResource(name);
            return url;
        }
    
        @Override
        public Enumeration<URL> getResources(String name) throws IOException {
            Enumeration<URL> urls = findResources(name);
            if (null != urls) {
                return urls;
            }
            urls = super.getResources(name);
            return urls;
        }
    
        @Override
        protected Class<?> loadClass(final String javaClassName, final boolean resolve) throws ClassNotFoundException {
            // 优先查询类加载路由表,如果命中路由规则,则优先从路由表中的ClassLoader完成类加载
            if (ArrayUtils.isNotEmpty(routingArray)) {
                for (final Routing routing : routingArray) {
                    if (!routing.isHit(javaClassName)) {
                        continue;
                    }
                    final ClassLoader routingClassLoader = routing.classLoader;
                    try {
                        System.out.println("被转发的类名称:" + javaClassName);
                        return routingClassLoader.loadClass(javaClassName);
                    } catch (Exception cause) {
                        // 如果在当前routingClassLoader中找不到应该优先加载的类(应该不可能,但不排除有就是故意命名成同名类)
                        // 此时应该忽略异常,继续往下加载
                        // ignore...
                    }
                }
            }
    
            // 先走一次已加载类的缓存,如果没有命中,则继续往下加载
            final Class<?> loadedClass = findLoadedClass(javaClassName);
            if (loadedClass != null) {
                return loadedClass;
            }
    
            try {
                Class<?> aClass = findClass(javaClassName);
                if (resolve) {
                    resolveClass(aClass);
                }
                return aClass;
            } catch (Exception cause) {
                System.out.println("================================" + javaClassName);
                return super.loadClass(javaClassName, resolve);
            }
        }
    
        /**
         * 类加载路由匹配器
         */
        public static class Routing {
    
            private final Collection<String/*REGEX*/> regexExpresses = new ArrayList<String>();
            private ClassLoader classLoader;
    
            /**
             * 构造类加载路由匹配器
             *
             * @param classLoader       目标ClassLoader
             * @param regexExpressArray 匹配规则表达式数组
             */
            public Routing(final ClassLoader classLoader, final String... regexExpressArray) {
                if (ArrayUtils.isNotEmpty(regexExpressArray)) {
                    regexExpresses.addAll(Arrays.asList(regexExpressArray));
                }
                this.classLoader = classLoader;
            }
    
            /**
             * 当前参与匹配的Java类名是否命中路由匹配规则
             * 命中匹配规则的类加载,将会从此ClassLoader中完成对应的加载行为
             *
             * @param javaClassName 参与匹配的Java类名
             * @return true:命中;false:不命中;
             */
            private boolean isHit(final String javaClassName) {
                for (final String regexExpress : regexExpresses) {
                    try {
                        if (javaClassName.matches(regexExpress)) {
                            return true;
                        }
                    } catch (Throwable cause) {
                        cause.printStackTrace();
    //                    logger.warn("routing {} failed, regex-express={}.", javaClassName, regexExpress, cause);
                    }
                }
                return false;
            }
    
        }
    
    
        @Override
        protected void finalize() throws Throwable {
            // 一旦这个类被回收的话,会被回调。
            System.out.println("ManagerClassLoader 终于被回收了!");
            super.finalize();
        }
    }
    

    2. 构建测试

    这个测试比较简单:

    • 构建一个Map来管理加载的类
    • 每次加载一个ClassLoader的时候,先清空上一个。

    为了简单方便,管理器永远只有一个加载器。但是为了查看效果,你可以重复一直加载。

    • 控制台输入1 的时候会手动加载一个jar包中的类。2 卸载jar包中的类和加载器. 3 触发GC看是否会被回收掉。
    
    /**
     * @author liukaixiong
     * @Email liukx@elab-plus.com
     * @date 2021/12/27 - 17:27
     */
    public class ClassLoaderTest {
    
        public static void main(String[] args) throws Exception {
            File file = new File("E:\\study\\sandbox\\sandbox-module\\manager-plugins\\cat-plugin-1.3.3-jar-with-dependencies.jar");
    //        URL urls = new URL("file:C:/Users/liukx/AppData/Local/Temp/manager_plugin124980413499729388.jar");
            Map<String, AnnotationConfigApplicationContext> cacheMap = new HashMap<>();
    
            Scanner input = new Scanner(System.in);
            while (true) {
                System.out.println("请输入执行 [1 : 加载 , 3 : 卸载]");
                int next = input.nextInt();
                System.out.println("接收到的指令:" + next);
    
                if (1 == next) {
                    // 先清除上一个加载器
                    clearClassLoader(cacheMap);
                    // 加载一个新的类加载器
                    AnnotationConfigApplicationContext applicationContext = newManager(file);
                    cacheMap.put("A", applicationContext);
                } else if (2 == next) {
                    clearClassLoader(cacheMap);
                } else if (3 == next) {
                    System.gc();
                    System.out.println("触发了一次GC操作!");
                }
            }
        }
        
        // 先清空上一个加载器。
        private static void clearClassLoader(Map<String, AnnotationConfigApplicationContext> cacheMap) throws IOException {
            AnnotationConfigApplicationContext context = cacheMap.remove("A");
            Optional.ofNullable(context).ifPresent((c) -> {
                ManagerClassLoader classLoader = (ManagerClassLoader) c.getClassLoader();
                try {
                    Objects.requireNonNull(classLoader).close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                System.out.println("清除缓存");
            });
        }
        
        // 实际中的自定义管理器
        private static AnnotationConfigApplicationContext newManager(File file) {
            List<String> includeClass = new ArrayList<>();
            includeClass.add("^com\\.sandbox\\.manager\\.api\\..*");
            includeClass.add("^com\\.alibaba\\.jvm\\.sandbox\\.api\\..*");
           //  includeClass.add("^com\\.lkx\\..*"); //todo 原来如此
    //        // includeClass.add("^org\\.apache\\.commons\\.lang3\\..*");
            includeClass.add("^org\\.springframework\\..*");
    //        includeClass.add("^java\\..*");
    
            ManagerClassLoader urlClassLoader = new ManagerClassLoader(new URL[]{builderUrl(file)}, new ManagerClassLoader.Routing(
                    ClassLoaderTest.class.getClassLoader(),
                    includeClass.toArray(includeClass.toArray(new String[0]))));
            AnnotationConfigApplicationContext pluginApplicationContext = new AnnotationConfigApplicationContext();
            pluginApplicationContext.setClassLoader(urlClassLoader);
            pluginApplicationContext.scan("com.sandbox.application.plugin");
            pluginApplicationContext.refresh();
    
            Trace bean = pluginApplicationContext.getBean(Trace.class);
            String id = bean.getId();
            System.out.println(">>>>> 执行 :: " + id);
            return pluginApplicationContext;
        }
        // 简单的自定义加载方式
        private static AnnotationConfigApplicationContext newMyClassLoader(File file) {
            MyClassLoader urlClassLoader = new MyClassLoader(new URL[]{builderUrl(file)});
            AnnotationConfigApplicationContext pluginApplicationContext = new AnnotationConfigApplicationContext();
            pluginApplicationContext.setClassLoader(urlClassLoader);
            pluginApplicationContext.scan("com.sandbox.application.plugin");
            pluginApplicationContext.refresh();
            return pluginApplicationContext;
        }
        // 最简单的加载方式
        private static AnnotationConfigApplicationContext newURLClassloader(File file) {
            URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{builderUrl(file)}, ClassLoaderTest.class.getClassLoader());
            AnnotationConfigApplicationContext pluginApplicationContext = new AnnotationConfigApplicationContext();
            pluginApplicationContext.setClassLoader(urlClassLoader);
            pluginApplicationContext.scan("com.sandbox.application.plugin");
            pluginApplicationContext.refresh();
            return pluginApplicationContext;
        }
    
        private static URL builderUrl(File file) {
            try {
                // 每次都是构建一个新的临时的jar
                File tempFile = File.createTempFile("manager_plugin", ".jar");
                tempFile.deleteOnExit();
                FileUtils.copyFile(file, tempFile);
                return new URL("file:" + tempFile.getPath());
            } catch (IOException e) {
                e.printStackTrace();
            }
            return null;
        }
    }
    
    

    执行右键,运行main方法

    • 反复输入1 不断重复加载。

    这个时候我用的是JProfile、其实还可以查看java自带的jvisualvm.exe工具查看。

    这里还是稍微记录一下jvisualvm.exe的使用方式:

    • 位置是在C:\Program Files\Java\jdk1.8.0_261\bin\jvisualvm.exe。可以根据自己的java安装环境去查找。
    1. 你运行了程序,直接点击jvisualvm.exe打开。

    这个时候你会看到虚拟机的运行环境,但是这个时候我们需要看某个实例的运行个数时。最好是在运行java程序中加入
    -Djava.rmi.server.hostname=127.0.0.1 -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=4444 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false

    开启一个可远程观测的端口。

    image.png
    这个时候,你基本上可以看到实例的加载情况,但是无法追查到引用数据。

    3. 使用jprofile去追查

    Jprofile 11的下载
    纯干货:内存溢出通过Jprofile排查思路以及实践总结
    有需要的先了解一下上面的排查文章。

    1. 定位java应用程序
    image.png

    点击OK。这个时候虚拟机的信息基本上都展现出来了。

    2. 查看存活的类
    image.png

    定位你需要关注的类

    3. 选择你关注的类,并生成快照
    image.png
    image.png

    这个时候基本上中和类的总数和大小引入眼帘。

    4. 追踪这个类的引用类

    右键你选择的类

    image.png
    image.png
    这个时候,有多少个实例就会有多少条记录。
    image.png
    其实我们目前按照正常情况来讲,触发GC之后应该只剩一个。但是现在显然不是。

    这种情况一定是该实例引用被外部持有,没有被释放掉,导致GC无法回收这个实例。

    随便打开一个看看:

    关键引用图

    image.png

    说实话,一开始真看不出啥,确实没啥经验,只能慢慢摸索呗~

    没有思路,这时我们可以换种方式: 排除法


    遇到不会的,先搭一个简单的demo,一步一步朝着我们实际的实现出发。

    越简单的案例越能快速反应问题,复杂的东西导致的因素会很多。

    1. 先写了一个newURLClassloader 方法,从URLClassLoder出发,发现没问题,能被回收。
    2. 然后在手写了一个简单自定义的方法newMyClassLoader,发现也没问题。
    public class MyClassLoader extends URLClassLoader {
    
    
        public MyClassLoader(URL[] urls, ClassLoader parent) {
            super(urls, parent);
        }
    
        public MyClassLoader(URL[] urls) {
            super(urls);
        }
    
        public MyClassLoader(URL[] urls, ClassLoader parent, URLStreamHandlerFactory factory) {
            super(urls, parent, factory);
        }
    
    }
    

    嗯,那一定就是实现的方式出了毛病。

    1. 然后从实现的ManagerClassLoader类中把实现方法loadClass给注释掉了,发现居然是OK的。

    嗯,越来越近了。

    细看了一下loadClass方法:

    发现也没啥,就是特定的路径使用特定的类加载器加载。

    protected Class<?> loadClass(final String javaClassName, final boolean resolve) throws ClassNotFoundException {
            // 优先查询类加载路由表,如果命中路由规则,则优先从路由表中的ClassLoader完成类加载
            if (ArrayUtils.isNotEmpty(routingArray)) {
                for (final Routing routing : routingArray) {
                    if (!routing.isHit(javaClassName)) {
                        continue;
                    }
                    final ClassLoader routingClassLoader = routing.classLoader;
                    try {
                        System.out.println("被转发的类名称:" + javaClassName);
                        return routingClassLoader.loadClass(javaClassName);
                    } catch (Exception cause) {
                        // 如果在当前routingClassLoader中找不到应该优先加载的类(应该不可能,但不排除有就是故意命名成同名类)
                        // 此时应该忽略异常,继续往下加载
                        // ignore...
                    }
                }
            }
    
            // 先走一次已加载类的缓存,如果没有命中,则继续往下加载
            final Class<?> loadedClass = findLoadedClass(javaClassName);
            if (loadedClass != null) {
                return loadedClass;
            }
    
            try {
                Class<?> aClass = findClass(javaClassName);
                if (resolve) {
                    resolveClass(aClass);
                }
                return aClass;
            } catch (Exception cause) {
                System.out.println("================================" + javaClassName);
                return super.loadClass(javaClassName, resolve);
            }
        }
    

    应该就是使用方式的问题。

    private static AnnotationConfigApplicationContext newManager(File file) {
            List<String> includeClass = new ArrayList<>();
            includeClass.add("^com\\.sandbox\\.manager\\.api\\..*");
            includeClass.add("^com\\.alibaba\\.jvm\\.sandbox\\.api\\..*");
           //  includeClass.add("^com\\.lkx\\..*"); //todo 原来如此
    //        // includeClass.add("^org\\.apache\\.commons\\.lang3\\..*");
            includeClass.add("^org\\.springframework\\..*");
    //        includeClass.add("^java\\..*");
    
            ManagerClassLoader urlClassLoader = new ManagerClassLoader(new URL[]{builderUrl(file)}, new ManagerClassLoader.Routing(
                    ClassLoaderTest.class.getClassLoader(),
                    includeClass.toArray(includeClass.toArray(new String[0]))));
            AnnotationConfigApplicationContext pluginApplicationContext = new AnnotationConfigApplicationContext();
            pluginApplicationContext.setClassLoader(urlClassLoader);
            pluginApplicationContext.scan("com.sandbox.application.plugin");
            pluginApplicationContext.refresh();
    
            Trace bean = pluginApplicationContext.getBean(Trace.class);
            String id = bean.getId();
            System.out.println(">>>>> 执行 :: " + id);
            return pluginApplicationContext;
        }
    

    这里的话就是遇到这些类的话使用主加载器去加载,否则使用自己的加载器。

    然后联想到关键引用图中有一个,这里有点运气的因素。

    image.png
    这个属于主加载器也有的,但是没在转发中声明路径,然后加入了这个路径。
    //加上这个
    includeClass.add("^com\\.lkx\\..*"); //todo 原来如此
    

    然后按照上述步骤重新测试,发现com.lkx.jvm.sandbox.core.classloader.ManagerClassLoader#finalize的方法被回调了,类也被回收了。

    此时,脑瓜子依然嗡嗡作响~。。。


    给个解释吧?我也不知道啊!睡服不了自己啊?

    强装镇定...

    按照正常来讲,A和B是两个不同的加载器,B负责加载插件范围内的实例,比如lang3的工具类,这个是不会和A的工具类起冲突的,因为是各自独立的。那么InterfaceProxyUtils这个工具类为什么不同呢?即便A和B都依赖这个工具类,也是各自独立的。为什么会有引用关系呢?

    知道了结果,这个时候我们开始反推过程。

    然后开始捣鼓JProfile,发现有个功能可以从实例一直往上查找直到GC ROOT ! 绝了~

    • 选中一个应该被回收的类
    image.png
    image.png

    从这个路径中可以发现挺多问题的,原来这个类是被Spring持有的。从之前的图也能看出端倪..


    image.png

    4. 胡说八道

    为什么Spring会持有呢?首先我们加载插件包的时候是用的Spring的scan方式扫描的包,但是我们先看一下入口类 AttributeMethods

    // 省略大部分源码
    final class AttributeMethods {
        // 静态缓存类,而且还是全局的
        private static final Map<Class<? extends Annotation>, AttributeMethods> cache =
                new ConcurrentReferenceHashMap<>();
        
        // 重点看是哪里调用了这个静态方法
        static AttributeMethods forAnnotationType(@Nullable Class<? extends Annotation> annotationType) {
            if (annotationType == null) {
                return NONE;
            }
            return cache.computeIfAbsent(annotationType, AttributeMethods::compute);
        }
    }
    

    原来这里面是有一个保存属性结构的全局缓存工具类,一旦加载插件包中发现属性注解的时候都会先缓存起来。

    调用入口在org.springframework.core.annotation.AnnotationTypeMapping#AnnotationTypeMapping中调用了AttributeMethods._forAnnotationType_(annotationType);

    我们插件包中确实有一个类注解缓存比如:

    interface IHttpServletRequest {
    
        @InterfaceProxyUtils.ProxyMethod(name = "getRemoteAddr")
        String getRemoteAddress();
    
    }
    

    Spring在解析的时候会把一些结构性的东西保存下来。

    这个时候相当于B加载器的实例对象引用被A加载器的实例应用持有了,所以一直回收不了。但是如果在ManagerClassLoader声明这个类的路径就是由A加载,B去A里面找的话,就能够被回收。

    image.png

    以上兜兜转转终于定位到了,也是对JProfile有了更深一步的了解。
    很多时候当你知识面不够广的时候,可以换一种思路去验证:

    • 比如排除法,先把复杂的东西简单化,一步一步验证。
    • 在无意中得到解决方法的时候,你不知道为什么会这样?
    • 此时再通过结果反推过程,得到最终的原因。

    如果此时你正在观看这篇文章,不要纠结能不能解决你目前的问题,排查思路和工具的使用能够让你让你多一种解决方案。

    不太喜欢贴大量代码,影响阅读,所以不要纠结代码。

    相关文章

      网友评论

        本文标题:排查实战之ClassLoader动态加载插件无法回收引用排查

        本文链接:https://www.haomeiwen.com/subject/isbpqrtx.html