前提
今天(2020-01-18
)在編寫Netty
相關代碼的時候,從Netty
源碼中的ThreadDeathWatcher
和GlobalEventExecutor
追溯到兩個和線程上下文類加載器ContextClassLoader
內存泄漏相關的Issue
:
- ThreadDeathWatcher causes custom classLoader script memory leaks
- Ensure ThreadDeathWatcher and GlobalEventExecutor will not cause clas…
兩個Issue
分別是兩位前輩在2017-12
的時候提出的,描述的是同一類問題,最后被Netty
的負責人采納,並且修復了對應的問題從而關閉了Issue
。這里基於這兩個Issue
描述的內容,對ContextClassLoader
內存泄漏隱患做一次復盤。
ClassLoader相關的內容
- 一個
JVM
實例(Java
應用程序)里面的所有類都是通過ClassLoader
加載的。 - 不同的
ClassLoader
在JVM
中有不同的命名空間,一個類實例(Class
)的唯一標識是全類名 +ClassLoader
,也就是不同的ClassLoader
加載同一個類文件,也會得到不相同的Class
實例。 JVM
不提供類卸載的功能,從目前參考到的資料來看,類卸載需要滿足下面幾點:- 條件一:
Class
的所有實例不被強引用(不可達)。 - 條件二:
Class
本身不被強引用(不可達)。 - 條件三:加載該
Class
的ClassLoader
實例不被強引用(不可達)。
- 條件一:
有些場景下需要實現類的熱部署和卸載,例如定義一個接口,然后由外部動態傳入代碼的實現。
這一點很常見,最典型的就是在線編程,代碼傳到服務端再進行編譯和運行。
由於應用啟動期所有非JDK
類庫的類都是由AppClassLoader
加載,我們沒有辦法通過AppClassLoader
去加載非類路徑下的已存在同名的類文件(對於一個ClassLoader
而言,每個類文件只能加載一次,生成唯一的Class
),所以為了動態加載類,每次必須使用完全不同的自定義ClassLoader
實例加載同一個類文件或者使用同一個自定義的ClassLoader
實例加載不同的類文件。類的熱部署這里舉個簡單例子:
// 此文件在項目類路徑
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
提供了一些標准接口需要第三方提供商去實現(最常見的就是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()));
}
}
控制台輸出如下:
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!
打開VisualVM
,Dump
對應進程的內存快照,多執行幾次GC
,發現了所有動態類都沒有被卸載(這里除非主動終止線程Y
釋放自定義ClassLoader
,否則永遠都不可能釋放該強引用),驗證了前面的結論。
當然,這里只是加載了兩個動態類,如果在特殊場景之下,例如在線編碼和運行代碼,那么有可能極度頻繁動態編譯和動態類加載,如果出現了上面類似的內存泄漏,那么很容易導致服務器內存耗盡。
解決方案
參考那兩個Issue
,解決方案(或者說預防手段)基本上有兩個:
- 不需要使用自定義類加載器的線程(如事件派發線程等)優先初始化,那么一般它的線程上下文類加載器是應用類加載器。
- 新建后代線程的時候,手動覆蓋它的線程上下文類加載器,參考
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)
技術公眾號(《Throwable文摘》),不定期推送筆者原創技術文章(絕不抄襲或者轉載):
娛樂公眾號(《天天沙雕》),甄選奇趣沙雕圖文和視頻不定期推送,緩解生活工作壓力: