執行緒上下文類載入器ContextClassLoader記憶體洩漏隱患
前提
今天(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》
我的個人部落格
- Throwable
(本文完 c-2-d e-a-20200119