開源直播課丨大資料整合框架ChunJun類載入器隔離方案探索及實踐

數棧DTinsight發表於2022-10-09

本期我們帶大家回顧一下無倦同學的直播分享《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 虛擬機器規範》 則是嚴格規定了有且只有六種情況必須立即對類進行 “初始化”(而載入、驗證、準備自然需要在此之 前開始):  file

● 場景一

遇到 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 中沒有找到想要載入的類,又會逆迴圈載入。

file

05 如何打破雙親委派機制?

那麼如何打破雙親委派機制呢?其實可以透過重寫 loadclass 方法來實現,具體過程大家可透過影片瞭解,這裡就不過多贅述。

file  file

二、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 載入原理

file

file

file  file

01 Flink 是如何避免類洩露的?

大家可以參考 Flink 中的 jira,這裡麵包含一些 bug 和處理方法:

Flink 如何避免類洩露,主要是透過以下兩種方法:

  1. 增加一層委派類載入器,將真正的 UserClassloader 包裹起來。

  2. 增加一個回撥鉤子,當任務結束的時候可以提供給使用者一個介面,去釋放未釋放的資源。

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 包,整體流程如下圖所示:

file

● Yarn Perjob

提交任務的時候上傳 jar 包,會放到

● Yarn Session

啟動 Session 的時候,Yarn 的 App 上傳 Jar 包機制,往 Session 提交任務的時候,Flink 的 Blob Server 負責收。

02 Yarn 的分散式快取

file

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 包檔案、字典檔案對應的軟連結。  file

05 Flink BlobServer

file

06 如何快速提交,減少上傳 jar 包

Flink libs 下面 jar 包、Flink Plugins 下面的 jar 包、Flink 任務的 jar 包 (對於 ChunJun 來說就是所有 connector 和 core), Flink jar 使用者自定義 jar 包。

● Perjob

如果可以提前上傳到 HDFS:

  1. 提前把 Flink lib 、Flink plugins、ChunJun jar 上傳到 HDFS 上面。

  2. 提交任務的時候透過 yarn.provided.lib.dirs 指定 HDFS 上面的路徑即可。

如果不可以提前上傳到 HDFS:

  1. 任務提交上傳到 HDFS 固定位置,提交的時候檢查 HDFS 上如果有對應的 jar (有快取策略),就把本地路徑替換成遠端路徑。

  2. 利用回撥鉤子,清楚異常任務結束的垃圾檔案。

● Seeion

如果可以提前上傳到 HDFS:

  1. 提前把 Flink lib 、Flink plugins、ChunJun jar 上傳到 HDFS 上面。

  2. 啟動 session 的時候透過 yarn.provided.lib.dirs 指定 HDFS 上面的路徑即可。

  3. 提交任務的時候不需要上傳 core 包。

如果不可以提前上傳到 HDFS:

  1. Session 啟動的時候就上傳所有 jar 到 HDFS 上面。透過 yarnship 指定。

  2. Flink 任務提交到 Session 的時候,不需要提交任何 jar 包。

file

07 類載入隔離遇到的問題分析

● 思路分析

  1. 首先要把不同外掛 (connector) 放到不同的 Classloader 裡面。

  2. 然後使用 child-first 的載入策略。

  3. 確保不會發生 x not cast x 錯誤。

  4. 後設資料空間不會記憶體洩露,導致任務報錯。

  5. 要快取 connector jar 包。

● 遇到的問題

  1. Flink 一個 job 可能有多個運算元,一個 connector 就是一個運算元。Flink 原生是為 job 級別新生成的 Classloader,無法把每個 connector 放在一個獨立的 Classloader 裡面。

  2. child-first 載入策略在 Session 模式下每次都新 new 一個 Classloader,導致後設資料空間記憶體洩露。

  3. connecotor 之間用到公有的類會報錯。

  4. 和問題 2 類似,主要是因為有些執行緒池,守護執行緒會拿著一些類物件,或者類 class 物件的引用。

  5. 如果用原生 -yarnship 去上傳,會放到 App Classloader 裡面。那麼就會導致某些不期望用 App Classloader 載入的類被載入。

file

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();

  1. 客戶端處理,JobGraph 處理 userJars、userArtifacts、Classpaths 這三個屬性。

  2. Classpath 只留下 connector 的層級目錄。

  3. 啟動 Session 的時候上傳 jar,jar 快取在 Yarn 的所有的 NodeManager 節點。

  4. jobmanager 和 taskmanager 構建 Classloader 的時候去修改 Classpath 的路徑,替換成當前節點 NodeManager 的快取路徑。

  5. 根據不同 connecotr 去構建 Flink Job 的 Classloader。

  6. 把構建出來的 classlaoder 進行快取,下次任務還有相同的 Classloader。避免記憶體洩露。

  7. 重寫新的 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 檔案分析等。



來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69995740/viewspace-2917409/,如需轉載,請註明出處,否則將追究法律責任。

相關文章