今天(2020-01-18
)在編寫Netty
相關代碼的時候,從Netty
源碼中的ThreadDeathWatcher
和GlobalEventExecutor
追溯到兩個和線程上下文類加載器ContextClassLoader
內存泄漏相關的Issue
:java
兩個Issue
分別是兩位前輩在2017-12
的時候提出的,描述的是同一類問題,最後被Netty
的負責人採納,而且修復了對應的問題從而關閉了Issue
。這裏基於這兩個Issue
描述的內容,對ContextClassLoader
內存泄漏隱患作一次覆盤。git
JVM
實例(Java
應用程序)裏面的全部類都是經過ClassLoader
加載的。ClassLoader
在JVM
中有不一樣的命名空間,一個類實例(Class
)的惟一標識是全類名 + ClassLoader
,也就是不一樣的ClassLoader
加載同一個類文件,也會獲得不相同的Class
實例。JVM
不提供類卸載的功能,從目前參考到的資料來看,類卸載須要知足下面幾點:
Class
的全部實例不被強引用(不可達)。Class
自己不被強引用(不可達)。Class
的ClassLoader
實例不被強引用(不可達)。有些場景下須要實現類的熱部署和卸載,例如定義一個接口,而後由外部動態傳入代碼的實現。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
其實指的是線程類java.lang.Thread
中的contextClassLoader
屬性,它是ClassLoader
類型,也就是類加載器實例。有些場景下,JDK
提供了一些標準接口須要第三方提供商去實現(最多見的就是SPI
,Service 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
設置不當致使內存泄漏。畫個圖就能比較清楚:
父線程中設置了一個自定義類加載器,用於加載動態類,子線程新建的時候直接使用了父線程的自定義類加載器,致使該自定義類加載器一直被子線程強引用,結合前面的類卸載條件分析,全部由該自定義類加載器加載出來的動態類都不能被卸載,致使了內存泄漏。這裏仍是基於文章前面的那個例子作改造:
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!
打開VisualVM
,Dump
對應進程的內存快照,多執行幾回GC
,發現了全部動態類都沒有被卸載(這裏除非主動終止線程Y
釋放自定義ClassLoader
,不然永遠都不可能釋放該強引用),驗證了前面的結論。
固然,這裏只是加載了兩個動態類,若是在特殊場景之下,例如在線編碼和運行代碼,那麼有可能極度頻繁動態編譯和動態類加載,若是出現了上面相似的內存泄漏,那麼很容易致使服務器內存耗盡。
參考那兩個Issue
,解決方案(或者說預防手段)基本上有兩個:
Netty
的作法,在線程初始化的時候作以下的操做:// ThreadDeathWatcher || GlobalEventExecutor AccessController.doPrivileged(new PrivilegedAction<Void>() { @Override public Void run() { watcherThread.setContextClassLoader(null); return null; } });
這篇文章算是近期研究得比較深刻的一篇文章,ContextClassLoader
內存泄漏的隱患歸根究竟是引用使用不當致使一些原本在方法棧退出以後須要釋放的引用沒法釋放致使的。這種問題有些時候隱藏得很深,而一旦命中了一樣的問題而且在併發的場景之下,那麼內存泄漏的問題會惡化得十分快。這類問題歸類爲性能優化,而性能優化是十分大的專題,之後應該也會遇到相似的各種問題,這些經驗但願能對將來產生正向的做用。
參考資料:
(本文完 c-2-d e-a-20200119)