本期我們帶大家回顧一下無倦同學的直播分享《ChunJun類載入器隔離》,ChunJun類載入器隔離的方案是我們近期探索的一個新方案,這個方案目前還不是非常成熟,希望能藉由此次分享與大家一起探討下這方案,如果大家有一些新的想法歡迎大家在github上給我提issue或者pr。 一、Java 類載入器解 ...
本期我們帶大家回顧一下無倦同學的直播分享《ChunJun類載入器隔離》,ChunJun類載入器隔離的方案是我們近期探索的一個新方案,這個方案目前還不是非常成熟,希望能藉由此次分享與大家一起探討下這方案,如果大家有一些新的想法歡迎大家在github上給我提issue或者pr。
一、Java 類載入器解決類衝突基本思想
在學習方案之前,首先為大家介紹一下Java類載入器解決類衝突的基本思想。
01 什麼是 Classpath?
Classpath是JVM用到的一個環境變數,它用來指示JVM如何搜索Class。
因為Java是編譯型語言,源碼文件是.java,而編譯後的.class文件才是真正可以被JVM執行的位元組碼。因此,JVM需要知道,如果要載入一個com.dtstack.HelloWorld的類,應該去哪搜索對應的HelloWorld.class文件。
所以,Classpath就是一組目錄的集合,它設置的搜索路徑與操作系統相關,例如:
在Windows系統上,用;分隔,帶空格的目錄用""括起來,可能長這樣:
C:\work\project1\bin;C:\shared;"D:\My Documents\project1\bin"
在MacOS & Linux系統上,用:分隔,可能長這樣:
/usr/shared:/usr/local/bin:/home/wujuan/bin
啟動JVM時設置Classpath變數, 實際上就是給java命令傳入-Classpath或-cp參數.
java -Classpath .;/Users/lzq/Java/a;/Users/lzq/Java/b com.dtstack.HelloWorld
沒有設置系統環境變數,也沒有傳入-cp參數,那麼JVM預設的Classpath為,即當前目錄:
java com.dtstack.HelloWorld
02 Jar 包中的類什麼時候被載入?
● Jar包
Jar 包就是 zip 包,只不過尾碼名字不同。用於管理分散的 .class 類。
生成 jar 包可以用 zip 命令 zip -r ChunJun.zip ChunJun
java -cp ./ChunJun.zip com.dtstack.HelloWorld
● 載入
“載入”(Loading) 階段是整個“類載入”(Class Loading) 過程中的一個階段,希望讀者沒有混淆這兩個看起來很相似的名詞。在載入階段,Java虛 擬機需要完成以下三件事情:
1.通過一個類的全限定名來獲取定義此類的二進位位元組流;
2.將這個位元組流所代表的靜態存儲結構轉化為方法區的運行時數據結構;
3.在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。
● 解析
類或介面的解析
假設當前代碼所處的類為D,如果要把一個從未解析過的符號引用N解析為一個類或介面C的直接引用,那虛擬機完成整個解析的過程需要包括以下3個步驟:
1.如果C不是一個數組類型,那虛擬機將會把代表N的全限定名傳遞給D的類載入器去載入這個類C。
在載入過程中,由於元數據驗證、位元組碼驗證的需要,又可能觸發其他相關類的載入動作,例如載入這個類的父類或實現的介面。一旦這個載入過程出現了任何異常,解析過程就將宣告失敗。
2.如果C是一個數組類型,並且數組的元素類型為對象,也就是N的描述符會是類
似“[Ljava/lang/Integer的形式,那將會按照第一點的規則載入數組元素類型。
如果N的描述符如前面所假設的形式,需要載入的元素類型就是“java.lang.Integer",接著由虛擬機生成一個代表該數組維度和元素的數組對象。
3.如果上面兩步沒有出現任何異常,那麼C在虛擬機中實際上已經成為一個有效的類或介面了,但在解析完成前還要進行符號引用驗證,確認D是否具備對C的訪問許可權。如果發現不具備訪問許可權,將拋出java.lang,llegalAccessEror異常。
03 哪些行為會觸發類的載入?
關於在什麼情況下需要開始類載入過程的第一個階段“載入”,《Java虛擬機規範》中並沒有進行 強制約束,這點可以交給虛擬機的具體實現來自由把握。但是對於初始化階段,《Java虛擬機規範》 則是嚴格規定了有且只有六種情況必須立即對類進行“初始化”(而載入、驗證、準備自然需要在此之 前開始):
● 場景一
遇到new、getstatic、putstatic或invokestatic這四條位元組碼指令時,如果類型沒有進行過初始 化,則需要先觸發其初始化階段。能夠生成這四條指令的典型Java代碼場景有:
1.使用new關鍵字實例化對象的時候。
2.讀取或設置一個類型的靜態欄位(被final修飾、已在編譯期把結果放入常量池的靜態欄位除外) 的時候。
3.調用一個類型的靜態方法的時候。
● 場景二
使用java.lang.reflect包的方法對類型進行反射調用的時候,如果類型沒有進行過初始化,則需 要先觸發其初始化。
● 場景三
當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
● 場景四
當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先 初始化這個主類。
● 場景五
當使用JDK 7新加入的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種類型的方法句柄,並且這個方法句柄對應的類沒有進行過初始化,則需要先觸發其初始化。
●場景六
當一個介面中定義了JDK 8新加入的預設方法(被default關鍵字修飾的介面方法)時,如果有這個介面的實現類發生了初始化,那該介面要在其之前被初始化。
對於以上這六種會觸發類型進行初始化的場景,《Java虛擬機規範》中使用了一個非常強烈的限定語 ——“有且只有”,這六種場景中的行為稱為對一個類型進行主動引用。除此之外,所有引用類型的方 式都不會觸發初始化,稱為被動引用。
04 什麼是雙親委派機制?
雙親委派機制,是按照載入器的層級關係,逐層進行委派,例如下圖中的自定義類載入器想要載入類,它首先不會想要自己去載入,它會通過層級關係逐層進行委派,從自定義類載入器 -> App ClassLoader -> Ext ClassLoader -> BootStrap ClassLoader,如果在BootStrap ClassLoader中沒有找到想要載入的類,又會逆迴圈載入。
05 如何打破雙親委派機制?
那麼如何打破雙親委派機制呢?其實可以通過重寫 loadclass 方法來實現,具體過程大家可通過視頻瞭解,這裡就不過多贅述。
二、Flink 類載入隔離的方案
接下來我們來介紹下Flink 類載入隔離的方案,Flink有兩種類載入器Parent-First和Child-First,他們的區別是:
1.Parent-First
類似 Java 中的雙親委派的類載入機制。Parent First ClassLoader 實際的邏輯就是一個 URL ClassLoader。
2.Child-First
先用 classloader.parent-first-patterns.default 和 classloader.parent-first-patterns.additional 拼接的list做匹配,如果類名首碼匹配了,先走雙親委派。否則就用 ChildFirstClassLoader 先載入。
Child-First 存在的問題
每次新 new 一個 ChildFirstClassLoader,如果運行時間久的話,類似 Session 這種 TaskManager 一直不關閉的情況。任務運行多次以後,會出現元數據空間爆掉,導致任務失敗。
Child-First 載入原理
01 Flink是如何避免類泄露的?
大家可以參考Flink中的jira,這裡麵包含一些bug和處理方法:
https://issues.apache.org/jira/browse/FLINK-16245
https://issues.apache.org/jira/browse/FLINK-11205
Flink如何避免類泄露,主要是通過以下兩種方法:
-
增加一層委派類載入器,將真正的 UserClassloader 包裹起來。
-
增加一個回調鉤子,當任務結束的時候可以提供給用戶一個介面,去釋放未釋放的資源。
KinesisProducer 使用了這個鉤子
final RuntimeContext ctx = getRuntimeContext();
ctx.registerUserCodeClassLoaderReleaseHookIfAbsent(
KINESIS_PRODUCER_RELEASE_HOOK_NAME,
()-> this.runClassLoaderReleaseHook
(ctx.getUserCodeClassLoader()));
02 Flink 卸載用戶代碼中動態載入的類
卸載用戶代碼中動態載入的類,所有涉及動態用戶代碼類載入(會話)的場景都依賴於再次卸載的類。
類卸載指垃圾回收器發現一個類的對象不再被引用,這時會對該類(相關代碼、靜態變數、元數據等)進行移除。
當TaskManager啟動或重啟任務時會載入指定任務的代碼,除非這些類可以卸載,否則就有可能引起記憶體泄露,因為更新新版本的類可能會隨著時間不斷的被載入積累。這種現象經常會引起OutOfMemoryError: Metaspace這種典型異常。
類泄漏的常見原因和建議的修複方式:
● Lingering Threads
確保應用代碼的函數/sources/sink關閉了所有線程。延遲關閉的線程不僅自身消耗資源,同時會因為占據對象引用,從而阻止垃圾回收和類的卸載。
● Interners
避免緩存超出function/sources/sinks生命周期的特殊結構中的對象。比如Guava的Interner,或是Avro的序列化器中的類或對象。
● JDBC
JDBC驅動會在用戶類載入器之外泄漏引用。為了確保這些類只被載入一次,可以將驅動JAR包放在Flink的 lib/ 目錄下,或者將驅動類通過 classloader-parent-first-patterns-additional 加到父級優先載入類的列表中。
釋放用戶代碼類載入器的鉤子(hook)可以幫助卸載動態載入的類,這種鉤子在類載入器卸載前執行,通常情況下最好把關閉和卸載資源作為正常函數生命周期操作的一部分(比如典型的close()
方法)。有些情況下(比如靜態欄位)最好確定類載入器不再需要後就立即卸載。
釋放類載入器的鉤子可以通過
RuntimeContext.registerUserCodeClassLoaderReleaseHookIfAbsent()
方法進行註冊。
03 Flink 卸載 Classloader 源碼
BlobLibraryCacheManager$ResolvedClassLoader
private void runReleaseHooks() {
Set<map.entry> hooks = releaseHooks.entrySet();
if (!hooks.isEmpty()) {
for (Map.EntryhookEntry : hooks) {
try {
LOG.debug("Running class loader shutdown hook: {}.", hookEntry.getKey());
hookEntry.getValue().run();
} catch (Throwable t) {
LOG.warn(
"Failed to run release hook '{}' for user code class loader.",
hookEntry.getValue(),
t);
}
}
releaseHooks.clear();
}
}
三、ChunJun 如何實現類載入隔離
接下來為大家介紹下ChunJun 如何實現類載入隔離。
01 Flink jar 的上傳時機
首先我們需要上傳Jar包,整體流程如下圖所示:
● Yarn Perjob
提交任務的時候上傳 jar 包,會放到
hdfs://flink03:9000/user/root/.flink/application_1654762357754_0140。
● Yarn Session
啟動 Session 的時候,Yarn 的 App 上傳 Jar 包機制,往 Session 提交任務的時候,Flink 的 Blob Server 負責收。
02 Yarn 的分散式緩存
03 Yarn 的分散式緩存
分散式緩存機制是由各個NM實現的,主要功能是將應用程式所需的文件資源緩存到本地,以便後續任務的使用。資源緩存是用時觸發的,也就是第一個用到該資源的任務觸發,後續任務無需再進行緩存,直接使用即可。
根據資源類型和資源可見性,NM可將資源分成不同類型:
資源可見性分類
● Public
節點上所有的用戶都可以共用該資源,只要有一個用戶的應用程式將著這些資源緩存到本地,其他所有用戶的所有應用程式都可以使用。
● Private
節點上同一用戶的所有應用程式共用該資源,只要該用戶其中一個應用程式將資源緩存到本地,該用戶的所有應用程式都可以使用。
● Application
節點上同一應用程式的所有Container共用該資源
資源類型分類
● Archive
歸檔文件,支持.jar、.zip、.tar.gz、.tgz、.tar的5種歸檔文件。
● File
普通文件,NM只是將這類文件下載到本地目錄,不做任何處理
● Pattern
以上兩種文件的混合體
YARN是通過比較resource、type、timestamp和pattern四個欄位是否相同來判斷兩個資源請求是否相同的。如果一個已經被緩存到各個節點上的文件被用戶修改了,則下次使用時會自動觸發一次緩存更新,以重新從HDFS上下載文件。
分散式緩存完成的主要功能是文件下載,涉及大量的磁碟讀寫,因此整個過程採用了非同步併發模型加快文件下載速度,以避免同步模型帶來的性能開銷。
04 Yarn 的分散式緩存
NodeManager採用輪詢的分配策略將這三類資源存放在yarn.nodemanager.local-dirs指定的目錄列表中,在每個目錄中,資源按照以下方式存放:
● Public資源
存放在${yarn.nodemanager.local-dirs}/filecache/目錄下,每個資源將單獨存放在以一個隨機整數命名的目錄中,且目錄的訪問許可權均為0755。
● Private資源
存放在${yarn.nodemanager.local-dirs}/usercache/${user}/filecache/目錄下,(其中${user}是應用程式提交者,預設情況下均為NodeManager啟動者),每個資源將單獨存放在以一個隨機整數命名的目錄中,且目錄的訪問許可權均為0710。
● Application資源
存放在${yarn.nodemanager.local-dirs}/usercache/${user}/${appcache}/${appid}/filecache/目錄下(其中${appid}是應用程式ID),每個資源將單獨存放在以一個隨機整數命名的目錄中,且目錄的訪問許可權均為0710;
其中Container的工作目錄位於${yarn.nodemanager.local-dirs}/usercache/${user}/${appcache}/${appid}/${containerid}目錄下,其主要保存jar包文件、字典文件對應的軟鏈接。
05 Flink BlobServer
06 如何快速提交,減少上傳 jar 包
Flink libs 下麵 jar包、Flink Plugins 下麵的 jar 包、Flink 任務的 jar 包(對於 ChunJun 來說就是所有 connector 和 core), Flink jar 用戶自定義 jar 包。
● Perjob
如果可以提前上傳到 HDFS:
-
提前把 Flink lib 、Flink plugins、ChunJun jar 上傳到 HDFS 上面。
-
提交任務的時候通過 yarn.provided.lib.dirs 指定 HDFS 上面的路徑即可。
如果不可以提前上傳到 HDFS:
-
任務提交上傳到 HDFS 固定位置,提交的時候檢查 HDFS 上如果有對應的 jar(有緩存策略),就把本地路徑替換成遠程路徑。
-
利用回調鉤子,清楚異常任務結束的垃圾文件。
● Seeion
如果可以提前上傳到 HDFS:
-
提前把 Flink lib 、Flink plugins、ChunJun jar 上傳到 HDFS 上面。
-
啟動 session 的時候通過 yarn.provided.lib.dirs 指定 HDFS 上面的路徑即可。
-
提交任務的時候不需要上傳 core 包。
如果不可以提前上傳到 HDFS:
-
Session 啟動的時候就上傳所有 jar 到 HDFS 上面。通過 yarnship 指定。
-
Flink 任務提交到 Session 的時候,不需要提交任何 jar 包。
07 類載入隔離遇到的問題分析
● 思路分析
-
首先要把不同插件(connector) 放到不同的 Classloader 裡面。
-
然後使用 child-first 的載入策略。
-
確保不會發生 x not cast x 錯誤。
-
元數據空間不會記憶體泄露,導致任務報錯。
-
要緩存 connector jar 包。
● 遇到的問題
-
Flink 一個 job 可能有多個運算元,一個 connector 就是一個運算元。Flink 原生是為 job 級別新生成的 Classloader,無法把每個 connector 放在一個獨立的 Classloader 裡面。
-
child-first 載入策略在 Session 模式下每次都新 new 一個 Classloader,導致元數據空間記憶體泄露。
-
connecotor 之間用到公有的類會報錯。
-
和問題2類似,主要是因為有些線程池,守護線程會拿著一些類對象,或者類 class 對象的引用。
-
如果用原生 -yarnship 去上傳,會放到 App Classloader 裡面。那麼就會導致某些不期望用 App Classloader 載入的類被載入。
08 Flink JobGraph Classpath 的使用
/** Set of JAR files required to run this job. */
private final ListuserJars = new ArrayList();
/** Set of custom files required to run this job. */
private final MapuserArtifacts = new HashMap<>();
/** List of Classpaths required to run this job. */
private ListClasspaths = Collections.emptyList();
-
客戶端處理,JobGraph 處理 userJars、userArtifacts、Classpaths 這三個屬性。
-
Classpath 只留下 connector 的層級目錄。
-
啟動 Session 的時候上傳 jar,jar 緩存在 Yarn 的所有的 NodeManager 節點。
-
jobmanager 和 taskmanager 構建 Classloader 的時候去修改 Classpath 的路徑,替換成當前節點 NodeManager 的緩存路徑。
-
根據不同 connecotr 去構建Flink Job 的 Classloader。
-
把構建出來的 classlaoder 進行緩存,下次任務還有相同的 Classloader。避免記憶體泄露。
-
重寫新的 ChildFirstCacheClassloader 裡面的 loadclass 方法,根據不同的 connector url 去生成 單獨的 Classloader。
四、遇到的問題和排查方案?
jar包衝突常見的異常為找不到類(java.lang.ClassNotFoundException)、找不到具體方法(java.lang.NoSuchMethodError)、欄位錯誤( java.lang.NoSuchFieldError)或者類錯誤(java.lang.LinkageError)。
● 常見的解決方法如下
1、首先做法是打出工程文件的依賴樹,將根據jar包依賴情況判定是不是同一個jar包依賴了多個版本,如果確認問題所在,直接exclusion其中錯誤的jar包即可。
2、如果通過看依賴樹不能確定具體衝突的jar包,可以使用添加jvm參數的方式啟動程式,將類載入的具體jar信息列印出來;-verbose:class 。
3、經過上述步驟基本就可以解決jar包衝突問題,具體的問題要具體分析。
● 常用工具推薦
1.Maven-helper
主要排查類衝突的 IDEA 插件。
2.Jstack
死鎖的一些問題可以通過這個工具查看 jstack 調用棧。
3.Arthas
排查一些性能問題和 Classloader 泄露問題。
4.VisualVM
排查一些對象記憶體泄露、dump 文件分析等。
袋鼠雲開源框架釘釘技術交流qun(30537511),歡迎對大數據開源項目有興趣的同學加入交流最新技術信息,開源項目庫地址:https://github.com/DTStack/Taier