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

最近在看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有了更深一步的了解。
很多时候当你知识面不够广的时候,可以换一种思路去验证:

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

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

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容