almost 3 years ago

Translated from "How the JVM Locates, Loads, and Runs Libraries - Class loaders are the key to understanding how the JVM executes programs" by Oleg Šelajev, Java Magazine, November/December 2015, page 30. Copyright Oracle Corporation.

JVM如何取得、載入並執行函示庫

類別載入器是了解JVM如何執行程式的關鍵

類別不只是建構Java型別系統的積木,也提供另一個基礎的用途:類別是一個編譯的單位,可以被獨立載入並執行的最小程式片段。早在JDK 1.0,Java開始時,類別載入機制就已經確立,且大大影響Java的普及,使其成為跨平台的解決方案。編譯過的Java程式,以類別檔及包裝成JAR檔的形式,能在許多支援的作業系統上被載入到執行中的JVM程序中,正是這能力,讓開發者輕易散布編譯過的二進制函式庫,因為散布JAR檔比散布原始碼或是平台相依的二進制檔要容易許多,這能力使Java普及,特別是開放原始碼專案。

在本文中,我詳細解釋Java類別載入機制及它如何運作,我同時解釋類別是如何從classpath中被找到,及如何被載入到記憶體中並完成初始化。

載入類別到JVM的機制

想像您有一個簡單的Java程式如下:

public class A {
    public static void main(String[] args) {
        B b = new B();
        int i = b.inc(0);
        System.out.println(i);
    }
}

當您編譯並執行這片段程式碼,JVM正確判斷程式的進入點然後開始執行類別Amain函式,然而,JVM不需要立即載入所有匯入的類別或甚至直接參考到的類別,尤其是,這指只有當JVM遇到new B()述句的bytecode指令時,才開始尋找並載入類別B,除了呼叫類別的建構子之外,還有其他幾個方式會啟動載入類別的流程,例如,存取類別的靜態成員或是透過Reflection API存取該類別。

為了實際載入一個類別,JVM使用classloader物件,任何已經載入的類別,都擁有載入自己的類別載入器的參照(reference),而該類別載入器用來載入所有被該類別參考到的所有類別。在前個例子中,這意味著載入B這個動作大概可以轉譯成類似A.class.getClassLoader().loadClass("B")的Java述句。

這出現一個悖論:每個類別載入器本身都是java.lang.Classloader型別的物件,開發者可以以名字用它來尋找並載入類別。如果您對這蛋生雞雞生蛋的問題感到困惑,想知道載入所有JDK類別(例如java.lang.String)的第一個類別載入器是如何建立的?那您在對的方向上思考了。確實,最初的類別載入器,稱作bootstrap class loader [譯註:這應該不用硬翻成啟動類別載入器吧?],是用原生平台相依的程式所寫成,由JVM的核心提供,它載入JVM本身必要的類別,例如java.lang套件中的類別、與基礎型別相關的類別等等,應用程式的類別是用一般以Java程式寫成的類別載入器來載入,如果有需要,開發者是可以影響載入器的處理流程。

類別載入器階層

在JVM中,類別載入器以樹狀階層的方式組織,即每個類別載入器都有一個父節點 [譯註:我暫時想不到可以排除性別的名詞],在開始尋找與載入一個類別前,最好的方式是檢查父節點是否能載入或已經載入需要的類別,這可以避免重複的工作及不停地載入類別。有個規則,父節點所擁有的類別對子類別載入器是可見的(visible),但對其他人是不可見的。基於委託與類別可見度的結構能讓階層中的類別載入器責任分離,使類別載入器能專責載入特定路徑的類別。

來看一下在一個Java應用程式中的類別載入器階層及探討它們平常載入什麼類別,在階層中的跟節點是bootstrap class loader,它載入執行JVM本身所需的系統類別,您可以預期隨著JDK發布的類別都是由這個類別載入器載入(開發者可以使用JVM的-Xbootclasspath選項擴充bootstrap class loader載入類別集合)。

注意即使函式庫可能放在啟動的classpath中,它並不會自動被載入跟初始化,類別都是經請求載入到JVM中,所以即使類別對bootstrap class loader而言是可用的,程式需要存取它們才會觸發實際的載入(一個關於這載入流程奇怪的觀點是您可以覆寫JDK,如果您的JAR預先被附加到啟動的classpath,儘管這幾乎總是一個爛點子,它確實為潛在更具威力的工具開啟了一扇門)。

bootstrap class loader的一種子載入器是extension class loader,用來載入擴充目錄(等會解釋)中的類別,這些類別用來描述特定機器的組態,例如語系(locales)、安全防護提供者(security providers)等等。這擴充目錄的位置則是透過java.ext.dirs系統屬性指定,在我的機器上是被設成:

/Users/shelajev/Library/Java/Extensions:/Library/
Java/JavaVirtualMachines/jdk1.8.0_40.jdk/Contents/
Home/jre/lib/ext:/Library/Java/Extensions:/Network/
Library/Java/Extensions:/System/Library/Java/Extensions:/usr/lib/java

透過修改這屬性,您可以改變哪些額外的函式庫要被載入到JVM的行程(process)中。

接著是system class loader,負責載入應用程式的類別與classpath中可用的類別,使用者可以用-cp指定classpath。extension class loader和system class loader都屬URLClassloader型別且行為一樣:先委託父載入器,如果需要,才會自己接著尋找與解析類別。

網頁應用程式的類別載入器階層稍微複雜點,因為多個應用程式可以被部署到同個應用程式伺服器,他們的類別需能夠被彼此區別,所以,每個網頁應用程式使用自己專屬的類別載入器,負責載入自己的函式庫。這隔離確保被部署到單一伺服器的不同網頁應用程式能擁有同個函式庫的不同版本而不會出現衝突,因此應用程式伺服器自動提供每個網頁應用程式專屬的類別載入器負責載入該應用程式的函式庫。這安排之所以能運作是因為網頁應用程式類別載入器會先試著從應用程式的WAR檔中尋找類別,接著才將搜尋委託給父類別載入器。

在JVM中,類別載入器以樹狀階層的方式組織,即每個類別載入器都有一個父節點,在開始尋找與載入一個類別前,最好的方式是檢查父節點是否能載入或已經載入需要的類別。

找到對的類別

一般而言,如果JVM中有多個類別有相同的全名(fully qualified name) [譯註:類別名稱加上所屬的package名稱,例如:java.lang.String],衝突解決策略是簡單且直覺的:第一個合適的類別優先。大多數類別載入器都繼承至URLClassloader,它會依序走訪classpath路徑上的每個目錄,然後載入第一個符合類別名稱要求的類別。

相同名稱的JAR檔也是用相同策略,JAR檔會依出現在classpath的順序被掃描,而不是他們的名稱,如果第一個JAR檔包含一個指定類別的入口,這類別就會被載入,如果沒有,classpath的掃描會繼續到第二個JAR檔。自然地,如果在classpath的任何位置都找不到類別,會拋出ClassNotFound例外。

通常,仰賴classpath目錄順序是一個脆弱的方法,所以取而代之的是開發者可以將類別加到-Xbootclasspath中,確保它們優先被載入,這方法沒什麼特別不好,只是要維護一個仰賴維boot classpath調整的專案需要額外的工作,關於類別從哪裡被載入的直覺會被打破,每個人都會為此感到困惑。一個比較好的方式是從根源解決衝突,找出為什麼classpath中有多個類別擁有相同的名稱,也許升級某些相依性版本、清除快取、執行一個乾淨的建置就足以擺脫這些副本。

解析、鏈結與驗證

當一個類別被找到且在JVM程序中的初始記憶體配置建立完成,它會被檢驗(verified)、做好準備(prepared)、解析(resolved)並初始化(initialized)。

  • 檢驗(Verification)確保類別沒被破壞且結構是正確的:執行環境的常數池(runtime constant pool)是有效的、變數的型別是正確的,且變數在被存取前是初始化過的。透過-noverify選項可以關閉檢驗,如果JVM程序不是執行潛在的惡意程式,嚴格的檢驗也許不是必要的。關閉檢驗可以加速JVM的啟動,另一個好處是某些類別,特別是用工具在執行其中建立的類別,無法通過嚴格的檢驗程序,可以讓它們變成對JVM是有效地且安全的,為了使用這些工具,開發者應該關掉檢驗,一般來說這在開發環境中可以被接受的。
  • 準備(Preparation)對一個類別而言會將其靜態成員根據型別進行初始化(當準備完成,int型別的成員會為0,參照會設為null等等)
  • 解析(Resolution)對一個類別而言是指檢查執行環境常數池中所有參照是否都指向有效的指定型別,參照的解析是透過載入參考的類別觸發,根據JVM規格,這解析的過程可以延遲進行,因此它會被延到真正使用這類別的時候。
  • 初始化(Initialization)預期一個驗證過且準備好的類別,然後執行類別的初始化,初始化過程中靜態成員會被設為程式碼中指定的值,程式中靜態初始化的片段也會被執行,這初始化的程序對每個已載入的類別應該只能執行一次,所以是同步的(synchronized),特別是這初始化會觸發其他類別的初始化,因此應該小心處理避免死結(deadlocks)。

關於JVM如何載入、鏈結與初始化類別更詳細的說明請參考Chapter 5 of the JavaVirtual Machine Specification.

關於類別載入器的其他考量

類別載入器模型是Java平台動態運作的核心,它不只允許在執行期中動態尋找與鏈結類別,更能提供一個介面讓各式工具掛載到應用程式中。此外,許多安全性功能仰賴類別載入器階層進行許可的檢查,例如,只有當由一個bootstrap class loader載入的類別呼叫時,才能成功用著名的sun.misc.Unsafe.getUnsafe()函式取得Unsafe類別的實體,因為只有系統類別會由此載入器回傳,任何函式庫想使用Unsafe API,都必須依賴Reflection API從私有欄位中取得該參照。[譯註:sun.misc.Unsafe.getUnsafe()是私有函式,無法直接呼叫,只能透過Reflection API呼叫,但如果不是由bootstrap class loader載入的類別呼叫會拋出SecurityException]。

許多安全性功能仰賴類別載入器階層進行許可的檢查。

結論

當您在開發一個函式庫或框架,多數情況下,您不需擔心任何關於類別載入的議題,它是執行期間發生的一個動態程序,所以您很少會需要影響它,同樣地,調整類別載入機制很少會對一般的Java函式庫帶來好處。

但是,若您建立期望彼此獨立的系統模組或外掛,加強類別載入機制也許是個好主意,要記住,客製類別載入器從根本上影響所有類別,可能帶入難以觀察的臭蟲到您應用程式中的每個角落,所以要額外小心設計您的類別載入機能。

在本文中,我們了解JVM如何載入類別到執行環境中,Java使用的類別載入器階層模型,以及一般Java應用程式使用的階層模型。

從各方面來說,即使您不會每天與類別載入的問題奮鬥或是建立外掛架構,了解類別載入的機制幫助您了解您的應用程式發生什麼事,還提供許多Java工具如何運作的洞察力,且它真的展示保持classpath檢查與更新的好處。

Oleg Šelajev (@shelajev) is an engineer, author, speaker, lecturer, and developer advocate at ZeroTurnaround. He enjoys spending time tinkering with Clojure, Git, and MacVim and is pursuing a PhD in dynamic software updates and code evolution at the University of Tartu.

LEARN MORE
• Information on controlling class loaders
• Class loaders in the JVM Specification

譯者的告白
中文跟英文有個明顯的不同是中文的動詞與名詞大多相同,例如verify和verification都翻成檢驗,但動詞在英文中是無法擔任主詞的,所以句型上動作的名詞當作主詞時,硬翻成中文讀起來確實怪怪的。當然,有時候我刻意分開,文中refer我會翻成參考,reference我翻成參照,主要是reference在Java程式語言中有特殊的意義,我不希望跟動詞搞混了,但也許之後reference也維持原文不特別翻譯了。

← 用Byte Buddy於執行期生成程式碼 首部曲:使用WebSockets建構應用程式 →