Java 虛擬機類載入機制
本文部分摘自《深入理解 Java 虛擬機第三版》
概述
Java 虛擬機把描述類的數據從 Class 文件載入到記憶體,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的 Java 類型,這個過程被稱作虛擬機的類載入機制
與那些在編譯時需要進行連接的語言不同,在 Java 語言裡面,類型的載入、連接和初始化過程都是在程式運行期間完成的,這種做法雖然讓類載入時稍微增加了一些性能開銷,但也為 Java 應用提供了極高的擴展性和靈活性,Java 可動態擴展的語言特性就是依賴運行期動態載入和動態連接這個特點實現的
類載入的時機
一個類型從被載入到虛擬機記憶體開始,到卸載出記憶體為止,它的整個生命周期將會經歷載入(Loading)、驗證(Verification)、準備(Prepartion)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)七個階段,其中驗證、準備、解析三個部分統稱為連接(Linking)

其中載入、驗證、準備、初始化和卸載這五個階段的順序是確定的,必須按這個順序開始,而解析階段則不一定,它在某些情況下可以在初始化階段之後再開始,這是為了支援Java 語言的運行時動態綁定特性(也稱動態綁定或晚期綁定)。注意這裡寫的是按順序開始,而非進行,因為這些階段通常是互相交互地混合進行的,會在一個階段執行的過程中調用、激活另一個階段
關於在什麼情況下開始類載入過程的第一個階段載入,Java 虛擬機規範並沒有強制約束,可以交給虛擬機的具體實現來自由把握。但對於初始化階段,Java 虛擬機則是嚴格規定了有且只有六種情況必須立即對類進行初始化(而載入、驗證、準備自然要在此之前開始):
- 遇到 new、getstatic、putstatic 或 invokestatic 這四條位元組碼指令時,如果類型沒有初始化,則先觸發初始化階段。能夠生產這四條指令的典型 Java 程式碼場景有:
- 使用 new 關鍵字實例化對象
- 讀取或設置一個類型的靜態欄位(被 final 修飾、已在編譯期把結果放入常量池的靜態欄位除外)
- 調用一個類型的靜態方法
- 對類型進行反射調用時,如果類型沒有初始化,則先觸發初始化階段
- 初始化類時,其父類尚未初始化,則需先觸發其父類的初始化
- 虛擬機啟動時,用戶需要指定一個要執行的類(包含 main() 方法的類),會先初始化該主類
- 使用了 JDK7 新加入的動態語言支援
- 一個介面定義了 JDK8 新加入的默認方法,如果該介面的實現類發生了初始化,那介面要先被初始化
對於這六種會觸發類型進行初始化的場景,稱為對類型進行主動引用。其他所有引用類型的方式都不會觸發初始化,稱為被動引用,例如通過子類引用父類的靜態欄位不會導致子類初始化、通過數組定義來引用類不會觸發類的初始化、引用常量等
介面的載入過程與類稍有不同,區別在於主動引用的第三種場景:當一個類在初始化時,要求其父類全部都已初始化,但一個介面在初始化時,並不要求其父介面全部都完成了初始化,只有在真正使用到父介面的時候(如引用介面中定義的常量)才會初始化
類載入的過程
接下來我們會詳細了解 Java 虛擬機中類載入的全過程,即載入、驗證、準備、解析和初始化這五個階段所執行的具體動作
1. 載入
在載入截斷,Java 虛擬機需要完成完成以下三件事情:
- 通過一個類的全限定名來獲取定義此類的二進位位元組流
- 將這個位元組流所代表的靜態存儲結構轉化為方法區的運行時數據結構
- 在記憶體中生成一個代表該類的 java.lang.Class 對象,作為方法區這個類的各種數據的訪問入口
Java 虛擬機規範對這三點要求並不是特別具體,因此實現十分靈活。例如第一點,它並沒有指明二進位位元組流必須從某個 Class 文件中獲取,僅僅這一點空隙,充滿創造力的開發人員就玩出了各種花樣,許多日後舉足輕重的 Java 技術都基於這一基礎:
- 從 ZIP 壓縮包中讀取,這是日後 JAR、EAR、WAR 格式的基礎
- 從網路中獲取
- 運行時計算生成,最常見的就是動態代理技術
- 由其他文件生成,如 JSP 文件生成對應 Class 文件
- 從資料庫讀取
- 從加密文件讀取
相對於類載入的其他階段,非數組類型的載入階段是開發人員可控性最強的階段。載入階段既可以使用 Java 虛擬機內置的引導類載入器完成,也可以由用戶自定義的類載入完成。數組類本身不通過類載入器創建,它由 Java 虛擬機直接在記憶體中動態構造出來。但數組類中元素的類型最終還是要靠類載入器來完成載入
載入階段結束後,Java 虛擬機外部的二進位位元組流按照虛擬機設定的格式存儲在方法區之中,存儲格式由虛擬機自行定義。類型數據在方法區安置完後,會在堆中實例化一個 java.lang.Class 類的對象,作為程式訪問方法區中類型數據的外部介面
2. 驗證
驗證是連接階段的第一步,目的是確保 Class 文件的位元組流中包含的資訊符合 Java 虛擬機規範的要求,保證這些資訊被當作程式碼運行後不會危害虛擬機自身的安全
驗證階段大致上會完成下面四個階段的檢驗動作:
- 文件格式校驗:驗證位元組流是否符合 Class 文件格式的規範,並能被當前版本的虛擬機所處理,例如是否以魔數開頭、主次版本號是否在當前虛擬機的接受範圍之內等等
- 元數據驗證:對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合 Java 語言規範的要求,例如該類是否有父類、是否實現其父類或介面所要求實現的所有方法等等
- 位元組碼驗證:通過數據流分析和控制流分析,確定程式語義是合法的、符合邏輯的,例如保證任何時刻操作數棧的數據與指令程式碼序列都能配合工作、保證任何跳轉指令都不會跳到方法體以外的位元組碼指令上等等
- 符號引用驗證:該階段的校驗行為發生在虛擬機將符號引用轉化為直接引用的時候,這個轉化的動作發生在解析階段。符號引用驗證可以看作是對類自身以外(常量池中的各種符號引用)的各類資訊進行匹配性校驗,該類是否缺少或被禁止訪問它所依賴的某些外部類、方法、欄位等資源
驗證階段並不是一個必須要執行的階段,如果程式運行的所有程式碼都已經被反覆使用和驗證過,那麼在生產環境的實驗階段可以考慮使用 -Xverify:none 參數來關閉大部分的類驗證,縮短虛擬機載入的時間
3. 準備
準備階段是整數為類中定義的變數(即靜態變數)分配記憶體並設置類變數初始值的階段。注意這裡進行記憶體分配的僅包括類變數,而非實例變數,實例變數會在對象實例化時隨著對象一起分配在 Java 堆中。其次這裡說的初始值通常情況下是數據類型的零值
4. 解析
解析階段是 Java 虛擬機將常量池內的符號引用替換為直接引用的過程,直接引用和符號引用之間的關係如下:
- 符號引用:以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可
- 直接引用:可以是直接指向目標指針的指針、相對偏移量或者是一個能間接定位到目標的句柄
5. 初始化
在準備階段,變數已經賦過一次系統要求的初始零值,而在初始化階段,則會根據程式設計師通過程式程式碼制定的主觀計划去初始化類變數和其他資源
初始化階段就是執行類構造器 <clinit> 方法的過程,該方法是 Javac 編譯器的自動生成物,由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊中的語句合併產生,語句順序按源文件的順序決定
<clinit> 方法與類的構造方法不同,它不需要顯式地調用父類構造器,Java 虛擬機會保證子類的 <clinit> 方法執行前,父類的 <clinit> 方法已經執行完畢。由於父類 <clinit> 方法先執行,因此父類中定義的靜態語句塊要優於子類的變數賦值操作
介面中不能使用靜態語句塊,但仍有變數初始化的賦值操作,因此介面與類一樣會生成 <clinit> 方法。不同的是,執行介面的 <clinit> 方法不需要先執行父介面的 <clinit> 方法,只有當父介面中定義的變數被使用,父介面才會被初始化。此外,介面的實現類在初始化時也一樣不會執行介面的 <clinit> 方法
Java 虛擬機必須保證一個類的 <clinit> 方法在多執行緒環境下被正確地加鎖同步,如果有多個執行緒同時初始化一個類,那麼只會有一個執行緒去執行 <clinit> 方法,其他執行緒阻塞等待,直至活動執行緒執行完 <clinit> 方法。但其他執行緒喚醒後不會再次進入 <clinit> 方法,同一個類載入器下,一個類型只會被初始化一次
類載入器
Java 虛擬機設計團隊有意把類載入階段中的「通過一個類的全限定名來獲取描述該類的二進位位元組流」這個動作放到 Java 虛擬機外部去實現,以便讓程式自己決定如何而去獲取所需的類,實現這個動作的程式碼被稱為類載入器
對於任意一個類,都必須由載入它的類載入器和這個類本身一起確立其在 Java 虛擬機中的唯一性,每一個類載入器,都擁有一個獨立的類名稱空間。這句話更通俗地表達是:比較兩個類是否相等,只有在這兩個類是由同一個類載入器載入的前提下才有意義,否則,即使這兩個類來源於同一個 Class 文件,被同一個 Java 虛擬機載入,只要載入它們的類載入器不同,那這兩個兩位就必定不相等
1. 雙親委派模型
絕大多數 Java 程式會使用到以下三個系統提供的類載入器來進行載入
-
啟動類載入器(Bootstrap Class Loader)
啟動類載入器使用 C++ 語言實現,負責載入存放在 <JAVA_HOME>\lib 目錄,或者被 -Xbootclasspath 參數所指定的路徑中存放的,而且是 Java 虛擬機所能識別的類庫載入到虛擬機的記憶體中。啟動類載入器無法被 Java 程式直接引用
-
擴展類載入器(Extension Class Loader)
擴展類載入器是在類 sun.misc.Launcher$ExtClassLoader 中以 Java 程式碼的形式來實現,負責載入 <JAVA_HOME>\lib\ext 目錄中,或者被 java.ext.dirs 系統變數所指定的路徑中所有的類庫,即用來擴展 Java SE 功能的類庫。由於擴展類載入器是由 Java 程式碼實現,開發者可以直接在程式中使用擴展類載入器來載入 Class 文件
-
應用程式類載入器(Application Class Loader)
這個類載入器由 sun.misc.Launcher$AppClassLoader 來實現。由於它是 ClassLoader 類中的 getSystemClassLoader 方法的返回值,所以有些場合也稱它為系統類載入器。它負責載入用戶類路徑(ClassPath)上所有的類庫,開發者也可以在程式碼中使用這個類載入器。如果應用程式沒有自定義自己的類載入器,一般情況下這個就是程式中默認的類載入器
圖示的各種類載入器之間的層次關係被稱為類載入器的雙親委派模型。雙親委派模型要求除了頂層的啟動類載入器外,其餘的類載入器都應有自己的父類載入器,不過這裡的類載入器之間的父子關係一般不以繼承關係實現,而使用組合關係來複用父載入器的程式碼

雙親委派模型的工作工程是:如果一個類載入器收到了類載入的請求,首先它不會自己嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的類載入器都是如此,因此所有的載入請求最終都應該被傳送到最頂層的啟動類載入器中,只有當父載入器回饋自己無法完成這個載入請求時,子載入器才會嘗試自己去完成載入
使用雙親委派模型的好處就是 Java 中的類隨著它的類載入器一起具備了一種帶有優先順序的層次關係,例如 java.lang.Object 類,無論哪一個類載入器載入它,最終都是委派給處於模型最頂端的啟動類載入器進行載入,因此 Object 類在程式的各種類載入器環境中都能保證是同一個類。如果沒有雙親委派機制,那麼用戶也可以自己編寫一個 java.lang.Object 類,並放在程式的 ClassPath 中,那系統就會出現多個不同的 Object 類,導致程式出錯
