線程上下文類加載器ContextClassLoader內存泄漏隱患

前提

今天(2020-01-18)在編寫Netty相關代碼的時候,從Netty源碼中的ThreadDeathWatcherGlobalEventExecutor追溯到兩個和線程上下文類加載器ContextClassLoader內存泄漏相關的Issuejava

兩個Issue分別是兩位前輩在2017-12的時候提出的,描述的是同一類問題,最後被Netty的負責人採納,而且修復了對應的問題從而關閉了Issue。這裏基於這兩個Issue描述的內容,對ContextClassLoader內存泄漏隱患作一次覆盤。git

ClassLoader相關的內容

  • 一個JVM實例(Java應用程序)裏面的全部類都是經過ClassLoader加載的。
  • 不一樣的ClassLoaderJVM中有不一樣的命名空間,一個類實例(Class)的惟一標識是全類名 + ClassLoader,也就是不一樣的ClassLoader加載同一個類文件,也會獲得不相同的Class實例。
  • JVM不提供類卸載的功能,從目前參考到的資料來看,類卸載須要知足下面幾點:
    • 條件一:Class的全部實例不被強引用(不可達)。
    • 條件二:Class自己不被強引用(不可達)。
    • 條件三:加載該ClassClassLoader實例不被強引用(不可達)。

有些場景下須要實現類的熱部署和卸載,例如定義一個接口,而後由外部動態傳入代碼的實現。github

這一點很常見,最典型的就是在線編程,代碼傳到服務端再進行編譯和運行。sql

因爲應用啓動期全部非JDK類庫的類都是由AppClassLoader加載,咱們沒有辦法經過AppClassLoader去加載非類路徑下的已存在同名的類文件(對於一個ClassLoader而言,每一個類文件只能加載一次,生成惟一的Class),因此爲了動態加載類,每次必須使用徹底不一樣的自定義ClassLoader實例加載同一個類文件或者使用同一個自定義的ClassLoader實例加載不一樣的類文件。類的熱部署這裏舉個簡單例子:shell

// 此文件在項目類路徑
package club.throwable.loader;
public class DefaultHelloService implements HelloService {

    @Override
    public String sayHello() {
        return "default say hello!";
    }
}

// 下面兩個文件編譯後放在I盤根目錄
// I:\\DefaultHelloService1.class
package club.throwable.loader;
public class DefaultHelloService1 implements HelloService {

    @Override
    public String sayHello() {
        return "1 say hello!";
    }
}
// I:\\DefaultHelloService2.class
package club.throwable.loader;
public class DefaultHelloService2 implements HelloService {

    @Override
    public String sayHello() {
        return "2 say hello!";
    }
}

// 接口和運行方法
public interface HelloService {

    String sayHello();

    static void main(String[] args) throws Exception {
        HelloService helloService = new DefaultHelloService();
        System.out.println(helloService.sayHello());
        ClassLoader loader = new ClassLoader() {

            @Override
            protected Class<?> findClass(String name) throws ClassNotFoundException {
                String location = "I:\\DefaultHelloService1.class";
                if (name.contains("DefaultHelloService2")) {
                    location = "I:\\DefaultHelloService2.class";
                }
                File classFile = new File(location);
                ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
                try {
                    InputStream stream = new FileInputStream(classFile);
                    int b;
                    while ((b = stream.read()) != -1) {
                        outputStream.write(b);
                    }
                } catch (IOException e) {
                    throw new IllegalArgumentException(e);
                }
                byte[] bytes = outputStream.toByteArray();
                return super.defineClass(name, bytes, 0, bytes.length);
            }
        };
        Class<?> klass = loader.loadClass("club.throwable.loader.DefaultHelloService1");
        helloService = (HelloService) klass.newInstance();
        System.out.println(helloService.sayHello());
        klass = loader.loadClass("club.throwable.loader.DefaultHelloService2");
        helloService = (HelloService) klass.newInstance();
        System.out.println(helloService.sayHello());
    }
}

// 控制檯輸出
default say hello!
1 say hello!
2 say hello!

若是新建過多的ClassLoader實例和Class實例,會佔用大量的內存,若是因爲上面幾個條件沒法所有知足,也就是這些ClassLoader實例和Class實例一直堆積沒法卸載,那麼就會致使內存泄漏(memory leak,後果很嚴重,有可能耗盡服務器的物理內存,由於JDK1.8+類相關元信息存在在元空間metaspace,而元空間使用的是native memory)。編程

線程中的ContextClassLoader

ContextClassLoader其實指的是線程類java.lang.Thread中的contextClassLoader屬性,它是ClassLoader類型,也就是類加載器實例。有些場景下,JDK提供了一些標準接口須要第三方提供商去實現(最多見的就是SPIService Provider Interface,例如java.sql.Driver),這些標準接口類是由啓動類加載器(Bootstrap ClassLoader)加載,可是這些接口的實現類須要從外部引入,自己不屬於JDK的原生類庫,沒法用啓動類加載器加載。爲了解決此困境,引入了線程上下文類加載器Thread Context ClassLoader。線程java.lang.Thread實例在初始化的時候會調用Thread#init()方法,Thread類和contextClassLoader相關的核心代碼塊以下:性能優化

// 線程實例的初始化方法,new Thread()的時候必定會調用
private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
    // 省略其餘代碼
    Thread parent = currentThread();
    // 省略其餘代碼
    if (security == null || isCCLOverridden(parent.getClass()))
        this.contextClassLoader = parent.getContextClassLoader();
    else
        this.contextClassLoader = parent.contextClassLoader;
    // 省略其餘代碼
}

public void setContextClassLoader(ClassLoader cl) {
    SecurityManager sm = System.getSecurityManager();
    if (sm != null) {
        sm.checkPermission(new RuntimePermission("setContextClassLoader"));
    }
    contextClassLoader = cl;
}

@CallerSensitive
public ClassLoader getContextClassLoader() {
    if (contextClassLoader == null)
        return null;
    SecurityManager sm = System.getSecurityManager();
    if (sm != null) {
        ClassLoader.checkClassLoaderPermission(contextClassLoader, Reflection.getCallerClass());
    }
    return contextClassLoader;
}

首先明確兩點:服務器

  • Thread實例容許手動設置contextClassLoader屬性,覆蓋當前的線程上下文類加載器實例。
  • Thread在初始化實例(調用new Thread())的時候必定會調用Thread#init()方法,新建的子線程實例會繼承父線程的contextClassLoader屬性,而應用主線程[main]contextClassLoader通常是應用類加載器(Application ClassLoader,有時也稱爲系統類加載器),其餘用戶線程都是主線程派生出來的後代線程,若是不覆蓋contextClassLoader,那麼新建的後代線程的contextClassLoader就是應用類加載器。

分析到這裏,筆者只想說明一個結論:後代線程的線程上下文類加載器會繼承父線程的線程上下文類加載器,其實這裏用繼承這個詞語也不是太準確,準確來講應該是後代線程的線程上下文類加載器和父線程的上下文類加載器徹底相同,若是都派生自主線程,那麼都是應用類加載器。對於這個結論能夠驗證一下(下面例子在JDK8中運行):併發

public class ThreadContextClassLoaderMain {

    public static void main(String[] args) throws Exception {
        AtomicReference<Thread> grandSonThreadReference = new AtomicReference<>();
        Thread sonThread = new Thread(() -> {
            Thread thread = new Thread(()-> {},"grand-son-thread");
            grandSonThreadReference.set(thread);
        }, "son-thread");
        sonThread.start();
        Thread.sleep(100);
        Thread main = Thread.currentThread();
        Thread grandSonThread = grandSonThreadReference.get();
        System.out.println(String.format("ContextClassLoader of [main]:%s", main.getContextClassLoader()));
        System.out.println(String.format("ContextClassLoader of [%s]:%s",sonThread.getName(), sonThread.getContextClassLoader()));
        System.out.println(String.format("ContextClassLoader of [%s]:%s", grandSonThread.getName(), grandSonThread.getContextClassLoader()));
    }
}

控制檯輸出以下:ide

ContextClassLoader of [main]:sun.misc.Launcher$AppClassLoader@18b4aac2
ContextClassLoader of [son-thread]:sun.misc.Launcher$AppClassLoader@18b4aac2
ContextClassLoader of [grand-son-thread]:sun.misc.Launcher$AppClassLoader@18b4aac2

印證了前面的結論,主線程、子線程、孫子線程的線程上下文類加載器都是AppClassLoader類型,而且指向同一個實例sun.misc.Launcher$AppClassLoader@18b4aac2

ContextClassLoader設置不當致使內存泄漏的隱患

只要有大量熱加載和卸載動態類的場景,就須要警戒後代線程ContextClassLoader設置不當致使內存泄漏。畫個圖就能比較清楚:

父線程中設置了一個自定義類加載器,用於加載動態類,子線程新建的時候直接使用了父線程的自定義類加載器,致使該自定義類加載器一直被子線程強引用,結合前面的類卸載條件分析,全部由該自定義類加載器加載出來的動態類都不能被卸載,致使了內存泄漏。這裏仍是基於文章前面的那個例子作改造:

  • 新增一個線程X用於進行類加載,新建一個自定義類加載器,設置線程X的上下文類加載器爲該自定義類加載器。
  • 線程X運行方法中建立一個新線程Y,用於接收類加載成功的事件而且進行打印。
public interface HelloService {

    String sayHello();

    BlockingQueue<String> CLASSES = new LinkedBlockingQueue<>();

    BlockingQueue<String> EVENTS = new LinkedBlockingQueue<>();

    AtomicBoolean START = new AtomicBoolean(false);

    static void main(String[] args) throws Exception {
        Thread thread = new Thread(() -> {
            ClassLoader loader = new ClassLoader() {

                @Override
                protected Class<?> findClass(String name) throws ClassNotFoundException {
                    String location = "I:\\DefaultHelloService1.class";
                    if (name.contains("DefaultHelloService2")) {
                        location = "I:\\DefaultHelloService2.class";
                    }
                    File classFile = new File(location);
                    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
                    try {
                        InputStream stream = new FileInputStream(classFile);
                        int b;
                        while ((b = stream.read()) != -1) {
                            outputStream.write(b);
                        }
                    } catch (IOException e) {
                        throw new IllegalArgumentException(e);
                    }
                    byte[] bytes = outputStream.toByteArray();
                    Class<?> defineClass = super.defineClass(name, bytes, 0, bytes.length);
                    try {
                        EVENTS.put(String.format("加載類成功,類名:%s", defineClass.getName()));
                    } catch (Exception ignore) {

                    }
                    return defineClass;
                }
            };
            Thread x = new Thread(() -> {
                try {
                    if (START.compareAndSet(false, true)) {
                        Thread y = new Thread(() -> {
                            try {
                                for (; ; ) {
                                    String event = EVENTS.take();
                                    System.out.println("接收到事件,事件內容:" + event);
                                }
                            } catch (Exception ignore) {

                            }
                        }, "Y");
                        y.setDaemon(true);
                        y.start();
                    }
                    for (; ; ) {
                        String take = CLASSES.take();
                        Class<?> klass = loader.loadClass(take);
                        HelloService helloService = (HelloService) klass.newInstance();
                        System.out.println(helloService.sayHello());
                    }
                } catch (Exception ignore) {

                }
            }, "X");
            x.setContextClassLoader(loader);
            x.setDaemon(true);
            x.start();
        });
        thread.start();
        CLASSES.put("club.throwable.loader.DefaultHelloService1");
        CLASSES.put("club.throwable.loader.DefaultHelloService2");
        Thread.sleep(5000);
        System.gc();
        Thread.sleep(5000);
        System.gc();
        Thread.sleep(Long.MAX_VALUE);
    }
}

控制檯輸出:

接收到事件,事件內容:加載類成功,類名:club.throwable.loader.DefaultHelloService1
1 say hello!
接收到事件,事件內容:加載類成功,類名:club.throwable.loader.DefaultHelloService2
2 say hello!

打開VisualVMDump對應進程的內存快照,多執行幾回GC,發現了全部動態類都沒有被卸載(這裏除非主動終止線程Y釋放自定義ClassLoader,不然永遠都不可能釋放該強引用),驗證了前面的結論。

固然,這裏只是加載了兩個動態類,若是在特殊場景之下,例如在線編碼和運行代碼,那麼有可能極度頻繁動態編譯和動態類加載,若是出現了上面相似的內存泄漏,那麼很容易致使服務器內存耗盡。

解決方案

參考那兩個Issue,解決方案(或者說預防手段)基本上有兩個:

  1. 不須要使用自定義類加載器的線程(如事件派發線程等)優先初始化,那麼通常它的線程上下文類加載器是應用類加載器。
  2. 新建後代線程的時候,手動覆蓋它的線程上下文類加載器,參考Netty的作法,在線程初始化的時候作以下的操做:
// ThreadDeathWatcher || GlobalEventExecutor
AccessController.doPrivileged(new PrivilegedAction<Void>() {
    @Override
    public Void run() {
        watcherThread.setContextClassLoader(null);
        return null;
    }
});

小結

這篇文章算是近期研究得比較深刻的一篇文章,ContextClassLoader內存泄漏的隱患歸根究竟是引用使用不當致使一些原本在方法棧退出以後須要釋放的引用沒法釋放致使的。這種問題有些時候隱藏得很深,而一旦命中了一樣的問題而且在併發的場景之下,那麼內存泄漏的問題會惡化得十分快。這類問題歸類爲性能優化,而性能優化是十分大的專題,之後應該也會遇到相似的各種問題,這些經驗但願能對將來產生正向的做用。

參考資料:

  • 《深刻理解Java虛擬機 - 3rd》

個人我的博客

(本文完 c-2-d e-a-20200119)

相關文章
相關標籤/搜索