排查實(shí)戰(zhàn)之ClassLoader動(dòng)態(tài)加載插件無法回收引用排查

最近在看jvm-sandbox的一些功能,參考著實(shí)現(xiàn)了動(dòng)態(tài)加載Jar包插件的功能,但是實(shí)現(xiàn)的這個(gè)功能有一個(gè)比較嚴(yán)重的問題,就是類加載完畢之后,當(dāng)你需要覆蓋或者卸載時(shí)候,該類加載器的引用是無法被回收的。也就是說由這個(gè)類加載器加載之后,無法卸載,這個(gè)加載器一直存在。

如果一旦新增或者覆蓋的jar包過多,會(huì)導(dǎo)致類加載器一直堆積。嚴(yán)重點(diǎn)會(huì)發(fā)生泄漏的風(fēng)險(xiǎn)。

基于以上場景開始了漫漫排查路。

代碼回顧

1. 自定義的類加載器

這個(gè)加載器的主要功能是負(fù)責(zé)路由,也是參考的jvm-sandbox。
主要目的是將加載器隔離:比如主加載器A,插件加載器為B

同樣一個(gè)接口A加載器肯定是有的,B加載器也有,如果各自加載那么同一個(gè)類也會(huì)出現(xiàn)不一致。所以為了保證全局唯一,有一些特定的類B中即便有的話也需要從A中去加載。這就是這個(gè)路由的意義。

/**
 * 可路由的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 {
        // 優(yōu)先查詢類加載路由表,如果命中路由規(guī)則,則優(yōu)先從路由表中的ClassLoader完成類加載
        if (ArrayUtils.isNotEmpty(routingArray)) {
            for (final Routing routing : routingArray) {
                if (!routing.isHit(javaClassName)) {
                    continue;
                }
                final ClassLoader routingClassLoader = routing.classLoader;
                try {
                    System.out.println("被轉(zhuǎn)發(fā)的類名稱:" + javaClassName);
                    return routingClassLoader.loadClass(javaClassName);
                } catch (Exception cause) {
                    // 如果在當(dāng)前routingClassLoader中找不到應(yīng)該優(yōu)先加載的類(應(yīng)該不可能,但不排除有就是故意命名成同名類)
                    // 此時(shí)應(yīng)該忽略異常,繼續(xù)往下加載
                    // ignore...
                }
            }
        }

        // 先走一次已加載類的緩存,如果沒有命中,則繼續(xù)往下加載
        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;

        /**
         * 構(gòu)造類加載路由匹配器
         *
         * @param classLoader       目標(biāo)ClassLoader
         * @param regexExpressArray 匹配規(guī)則表達(dá)式數(shù)組
         */
        public Routing(final ClassLoader classLoader, final String... regexExpressArray) {
            if (ArrayUtils.isNotEmpty(regexExpressArray)) {
                regexExpresses.addAll(Arrays.asList(regexExpressArray));
            }
            this.classLoader = classLoader;
        }

        /**
         * 當(dāng)前參與匹配的Java類名是否命中路由匹配規(guī)則
         * 命中匹配規(guī)則的類加載,將會(huì)從此ClassLoader中完成對應(yīng)的加載行為
         *
         * @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 {
        // 一旦這個(gè)類被回收的話,會(huì)被回調(diào)。
        System.out.println("ManagerClassLoader 終于被回收了!");
        super.finalize();
    }
}

2. 構(gòu)建測試

這個(gè)測試比較簡單:

  • 構(gòu)建一個(gè)Map來管理加載的類
  • 每次加載一個(gè)ClassLoader的時(shí)候,先清空上一個(gè)。

為了簡單方便,管理器永遠(yuǎn)只有一個(gè)加載器。但是為了查看效果,你可以重復(fù)一直加載。

  • 控制臺輸入1 的時(shí)候會(huì)手動(dòng)加載一個(gè)jar包中的類。2 卸載jar包中的類和加載器. 3 觸發(fā)GC看是否會(huì)被回收掉。

/**
 * @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("請輸入執(zhí)行 [1 : 加載 , 3 : 卸載]");
            int next = input.nextInt();
            System.out.println("接收到的指令:" + next);

            if (1 == next) {
                // 先清除上一個(gè)加載器
                clearClassLoader(cacheMap);
                // 加載一個(gè)新的類加載器
                AnnotationConfigApplicationContext applicationContext = newManager(file);
                cacheMap.put("A", applicationContext);
            } else if (2 == next) {
                clearClassLoader(cacheMap);
            } else if (3 == next) {
                System.gc();
                System.out.println("觸發(fā)了一次GC操作!");
            }
        }
    }
    
    // 先清空上一個(gè)加載器。
    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("清除緩存");
        });
    }
    
    // 實(shí)際中的自定義管理器
    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(">>>>> 執(zhí)行 :: " + 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 {
            // 每次都是構(gòu)建一個(gè)新的臨時(shí)的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;
    }
}

執(zhí)行右鍵,運(yùn)行main方法

  • 反復(fù)輸入1 不斷重復(fù)加載。

這個(gè)時(shí)候我用的是JProfile、其實(shí)還可以查看java自帶的jvisualvm.exe工具查看。

這里還是稍微記錄一下jvisualvm.exe的使用方式:

  • 位置是在C:\Program Files\Java\jdk1.8.0_261\bin\jvisualvm.exe??梢愿鶕?jù)自己的java安裝環(huán)境去查找。
  1. 你運(yùn)行了程序,直接點(diǎn)擊jvisualvm.exe打開。

這個(gè)時(shí)候你會(huì)看到虛擬機(jī)的運(yùn)行環(huán)境,但是這個(gè)時(shí)候我們需要看某個(gè)實(shí)例的運(yùn)行個(gè)數(shù)時(shí)。最好是在運(yùn)行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

開啟一個(gè)可遠(yuǎn)程觀測的端口。

image.png

這個(gè)時(shí)候,你基本上可以看到實(shí)例的加載情況,但是無法追查到引用數(shù)據(jù)。

3. 使用jprofile去追查

Jprofile 11的下載
純干貨:內(nèi)存溢出通過Jprofile排查思路以及實(shí)踐總結(jié)
有需要的先了解一下上面的排查文章。

1. 定位java應(yīng)用程序
image.png

點(diǎn)擊OK。這個(gè)時(shí)候虛擬機(jī)的信息基本上都展現(xiàn)出來了。

2. 查看存活的類
image.png

定位你需要關(guān)注的類

3. 選擇你關(guān)注的類,并生成快照
image.png

image.png

這個(gè)時(shí)候基本上中和類的總數(shù)和大小引入眼簾。

4. 追蹤這個(gè)類的引用類

右鍵你選擇的類

image.png

image.png

這個(gè)時(shí)候,有多少個(gè)實(shí)例就會(huì)有多少條記錄。
image.png

其實(shí)我們目前按照正常情況來講,觸發(fā)GC之后應(yīng)該只剩一個(gè)。但是現(xiàn)在顯然不是。

這種情況一定是該實(shí)例引用被外部持有,沒有被釋放掉,導(dǎo)致GC無法回收這個(gè)實(shí)例。

隨便打開一個(gè)看看:

關(guān)鍵引用圖

image.png

說實(shí)話,一開始真看不出啥,確實(shí)沒啥經(jīng)驗(yàn),只能慢慢摸索唄~

沒有思路,這時(shí)我們可以換種方式: 排除法


遇到不會(huì)的,先搭一個(gè)簡單的demo,一步一步朝著我們實(shí)際的實(shí)現(xiàn)出發(fā)。

越簡單的案例越能快速反應(yīng)問題,復(fù)雜的東西導(dǎo)致的因素會(huì)很多。

  1. 先寫了一個(gè)newURLClassloader 方法,從URLClassLoder出發(fā),發(fā)現(xiàn)沒問題,能被回收。
  2. 然后在手寫了一個(gè)簡單自定義的方法newMyClassLoader,發(fā)現(xiàn)也沒問題。
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);
    }

}

嗯,那一定就是實(shí)現(xiàn)的方式出了毛病。

  1. 然后從實(shí)現(xiàn)的ManagerClassLoader類中把實(shí)現(xiàn)方法loadClass給注釋掉了,發(fā)現(xiàn)居然是OK的。

嗯,越來越近了。

細(xì)看了一下loadClass方法:

發(fā)現(xiàn)也沒啥,就是特定的路徑使用特定的類加載器加載。

protected Class<?> loadClass(final String javaClassName, final boolean resolve) throws ClassNotFoundException {
        // 優(yōu)先查詢類加載路由表,如果命中路由規(guī)則,則優(yōu)先從路由表中的ClassLoader完成類加載
        if (ArrayUtils.isNotEmpty(routingArray)) {
            for (final Routing routing : routingArray) {
                if (!routing.isHit(javaClassName)) {
                    continue;
                }
                final ClassLoader routingClassLoader = routing.classLoader;
                try {
                    System.out.println("被轉(zhuǎn)發(fā)的類名稱:" + javaClassName);
                    return routingClassLoader.loadClass(javaClassName);
                } catch (Exception cause) {
                    // 如果在當(dāng)前routingClassLoader中找不到應(yīng)該優(yōu)先加載的類(應(yīng)該不可能,但不排除有就是故意命名成同名類)
                    // 此時(shí)應(yīng)該忽略異常,繼續(xù)往下加載
                    // ignore...
                }
            }
        }

        // 先走一次已加載類的緩存,如果沒有命中,則繼續(xù)往下加載
        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);
        }
    }

應(yīng)該就是使用方式的問題。

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(">>>>> 執(zhí)行 :: " + id);
        return pluginApplicationContext;
    }

這里的話就是遇到這些類的話使用主加載器去加載,否則使用自己的加載器。

然后聯(lián)想到關(guān)鍵引用圖中有一個(gè),這里有點(diǎn)運(yùn)氣的因素。

image.png

這個(gè)屬于主加載器也有的,但是沒在轉(zhuǎn)發(fā)中聲明路徑,然后加入了這個(gè)路徑。

//加上這個(gè)
includeClass.add("^com\\.lkx\\..*"); //todo 原來如此

然后按照上述步驟重新測試,發(fā)現(xiàn)com.lkx.jvm.sandbox.core.classloader.ManagerClassLoader#finalize的方法被回調(diào)了,類也被回收了。

此時(shí),腦瓜子依然嗡嗡作響~。。。


給個(gè)解釋吧?我也不知道??!睡服不了自己?。?/strong>

強(qiáng)裝鎮(zhèn)定...

按照正常來講,A和B是兩個(gè)不同的加載器,B負(fù)責(zé)加載插件范圍內(nèi)的實(shí)例,比如lang3的工具類,這個(gè)是不會(huì)和A的工具類起沖突的,因?yàn)槭歉髯元?dú)立的。那么InterfaceProxyUtils這個(gè)工具類為什么不同呢?即便A和B都依賴這個(gè)工具類,也是各自獨(dú)立的。為什么會(huì)有引用關(guān)系呢?

知道了結(jié)果,這個(gè)時(shí)候我們開始反推過程。

然后開始搗鼓JProfile,發(fā)現(xiàn)有個(gè)功能可以從實(shí)例一直往上查找直到GC ROOT ! 絕了~

  • 選中一個(gè)應(yīng)該被回收的類
image.png

image.png

從這個(gè)路徑中可以發(fā)現(xiàn)挺多問題的,原來這個(gè)類是被Spring持有的。從之前的圖也能看出端倪..


image.png

4. 胡說八道

為什么Spring會(huì)持有呢?首先我們加載插件包的時(shí)候是用的Spring的scan方式掃描的包,但是我們先看一下入口類 AttributeMethods

// 省略大部分源碼
final class AttributeMethods {
    // 靜態(tài)緩存類,而且還是全局的
    private static final Map<Class<? extends Annotation>, AttributeMethods> cache =
            new ConcurrentReferenceHashMap<>();
    
    // 重點(diǎn)看是哪里調(diào)用了這個(gè)靜態(tài)方法
    static AttributeMethods forAnnotationType(@Nullable Class<? extends Annotation> annotationType) {
        if (annotationType == null) {
            return NONE;
        }
        return cache.computeIfAbsent(annotationType, AttributeMethods::compute);
    }
}

原來這里面是有一個(gè)保存屬性結(jié)構(gòu)的全局緩存工具類,一旦加載插件包中發(fā)現(xiàn)屬性注解的時(shí)候都會(huì)先緩存起來。

調(diào)用入口在org.springframework.core.annotation.AnnotationTypeMapping#AnnotationTypeMapping中調(diào)用了AttributeMethods._forAnnotationType_(annotationType);

我們插件包中確實(shí)有一個(gè)類注解緩存比如:

interface IHttpServletRequest {

    @InterfaceProxyUtils.ProxyMethod(name = "getRemoteAddr")
    String getRemoteAddress();

}

Spring在解析的時(shí)候會(huì)把一些結(jié)構(gòu)性的東西保存下來。

這個(gè)時(shí)候相當(dāng)于B加載器的實(shí)例對象引用被A加載器的實(shí)例應(yīng)用持有了,所以一直回收不了。但是如果在ManagerClassLoader聲明這個(gè)類的路徑就是由A加載,B去A里面找的話,就能夠被回收。

image.png

以上兜兜轉(zhuǎn)轉(zhuǎn)終于定位到了,也是對JProfile有了更深一步的了解。
很多時(shí)候當(dāng)你知識面不夠廣的時(shí)候,可以換一種思路去驗(yàn)證:

  • 比如排除法,先把復(fù)雜的東西簡單化,一步一步驗(yàn)證。
  • 在無意中得到解決方法的時(shí)候,你不知道為什么會(huì)這樣?
  • 此時(shí)再通過結(jié)果反推過程,得到最終的原因。

如果此時(shí)你正在觀看這篇文章,不要糾結(jié)能不能解決你目前的問題,排查思路和工具的使用能夠讓你讓你多一種解決方案。

不太喜歡貼大量代碼,影響閱讀,所以不要糾結(jié)代碼。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容