Java 虛擬機器類載入機制

低吟不作語發表於2021-01-16

本文部分摘自《深入理解 Java 虛擬機器第三版》


概述

Java 虛擬機器把描述類的資料從 Class 檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的 Java 型別,這個過程被稱作虛擬機器的類載入機制

與那些在編譯時需要進行連線的語言不同,在 Java 語言裡面,型別的載入、連線和初始化過程都是在程式執行期間完成的,這種做法雖然讓類載入時稍微增加了一些效能開銷,但也為 Java 應用提供了極高的擴充套件性和靈活性,Java 可動態擴充套件的語言特性就是依賴執行期動態載入和動態連線這個特點實現的


類載入的時機

一個型別從被載入到虛擬機器記憶體開始,到解除安裝出記憶體為止,它的整個生命週期將會經歷載入(Loading)、驗證(Verification)、準備(Prepartion)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)七個階段,其中驗證、準備、解析三個部分統稱為連線(Linking)

其中載入、驗證、準備、初始化和解除安裝這五個階段的順序是確定的,必須按這個順序開始,而解析階段則不一定,它在某些情況下可以在初始化階段之後再開始,這是為了支援Java 語言的執行時動態繫結特性(也稱動態繫結或晚期繫結)。注意這裡寫的是按順序開始,而非進行,因為這些階段通常是互相互動地混合進行的,會在一個階段執行的過程中呼叫、啟用另一個階段

關於在什麼情況下開始類載入過程的第一個階段載入,Java 虛擬機器規範並沒有強制約束,可以交給虛擬機器的具體實現來自由把握。但對於初始化階段,Java 虛擬機器則是嚴格規定了有且只有六種情況必須立即對類進行初始化(而載入、驗證、準備自然要在此之前開始):

  1. 遇到 new、getstatic、putstatic 或 invokestatic 這四條位元組碼指令時,如果型別沒有初始化,則先觸發初始化階段。能夠生產這四條指令的典型 Java 程式碼場景有:
    • 使用 new 關鍵字例項化物件
    • 讀取或設定一個型別的靜態欄位(被 final 修飾、已在編譯期把結果放入常量池的靜態欄位除外)
    • 呼叫一個型別的靜態方法
  2. 對型別進行反射呼叫時,如果型別沒有初始化,則先觸發初始化階段
  3. 初始化類時,其父類尚未初始化,則需先觸發其父類的初始化
  4. 虛擬機器啟動時,使用者需要指定一個要執行的類(包含 main() 方法的類),會先初始化該主類
  5. 使用了 JDK7 新加入的動態語言支援
  6. 一個介面定義了 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 類,導致程式出錯


相關文章