如果你還不瞭解Java類的載入過程,來看看這一篇吧

果子爸聊技术發表於2024-05-11

文章首發於【Java天堂】,跟隨我探索Java進階之路!

虛擬機器類載入機制

在Java程式碼被編譯成Class檔案之後,最終需要載入到Java虛擬機器中才能被執行和使用,Java虛擬機器載入Class檔案到記憶體,並對資料進行校驗、轉換、解析和初始化之後,才變成了我們真正可以使用的Java型別,這個過程就叫做Java虛擬機器的類載入機制。

C++等語言在程式編譯時有一個連線的過程,在連線時相當於就是把需要依賴的資源進行整合到一起,變成一個可執行程式。但Java的編譯不同,在Java語言中,型別的載入、連線和初始化這些動作都是在程式執行期間動態完成的,這樣會導致Java語言在提前編譯方面變得困難,因為要到實際執行的時候才能知道實際的實現類。

例如,你寫了一個介面,可以等到程式實際執行的時候再動態的載入具體的實現,而且載入的方式也不一定非得從Class檔案載入,你甚至可以從網路上或者其他地方載入一個二進位制流來作為程式的一部分,這種Java執行期類載入的方式,大大提升的Java語言的靈活性。

類載入的時機

一個型別從載入開始到最終的消亡,整個生命同期會經歷載入(Loading)驗證(Verification)準備(Preparation)解析(Resolution)初始化(Initialization)使用(Using)解除安裝(Unloading)共七個階段,整個過程如下:

pke9PJg.png

驗證、準備、解析三個過程,可以統稱為連線(Linking)過程。

上圖中的各個過程,並不是嚴格按照指定的順序按部就班的執行,其中載入、驗證、準備、初始化和解除安裝,這幾個的順序是確定的,其他階段可能會穿插在這個過程當中交叉混合的執行,會在一個階段執行的過程當中呼叫另外一個階段。

關於什麼時候觸發第一個過程"載入",《Java虛擬機器規範》中沒有強制的規定,可以交給虛擬機器自由發揮,可能不同的虛擬機器觸發的時機會存在差異,這部分內容不做重點介紹。但對於初始化的觸發條件,《Java虛擬機器規範》有嚴格的規則,有且只有六種情況必須對類進行初始化動作

  1. 遇到new、getstatic、putstatic、invokestatic這四條位元組碼指令時,如果型別沒有進行過初始化,則需要觸發其初始化。這四個位元組碼指令,分別表示對建立新物件、獲取靜態欄位、設定靜態欄位、呼叫靜態方法
  2. 使用java.lang.reflect包的方法對型別進行反射呼叫時,如果型別沒有初始化,需要觸發其初始化
  3. 對於父類,如果子類在初始化的時候發現父類沒有初始化,會觸發父類的初始化
  4. 當虛擬機器啟動時,需要先指定一個啟動類(包含main方法),虛擬機器會先初始化這個啟動類
  5. 當使用JDK 7新加入的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種型別的方法控制代碼,並且這個方法控制代碼對應的類沒有進行過初始化,則需要先觸發其初始化
  6. 當一個介面中定義了JDK 8新加入的預設方法(被default關鍵字修飾的介面方法)時,如果有這個介面的實現類發生了初始化,那該介面要在其之前被初始化

1 - 4對於熟悉Java基礎的同學,應該比較好理解,基本上都是常規操作。5-6相對來說使用的不太多,瞭解一下即可

類的載入過程

上面我們介紹了類生命週期的7個過程,載入過程主要包括載入、驗證、準備、解析和初始化,重點介紹這幾個部分的過程

1、載入

載入階段,Java虛擬機器主要完成三件事情:

  1. 透過類的全限定名稱,獲取類的二進位制位元組流
  2. 將二進位制流中的靜態儲存結構轉化為方法區的執行時資料結構
  3. 在記憶體中生成一個此類的java.lang.Class物件

在根據全限定名稱獲取二進位制位元組流時,並沒有具體的規則,從哪裡獲取?如何獲取?這就給了開發者很大的發揮空間。

我們前面說過,Java虛擬機器載入的型別不僅限於從Class檔案載入, 它可以從網路或者其他任何地方載入,只要遵循Java虛擬機器的規則就行。

在Java的發展歷程中,充滿創造力的開發者們玩出了各種花樣,Java許多重要的技術都基於這種特性發展起來的

  • 從壓縮包中讀取,比如War包、Jar包,有沒有很熟悉?

  • 從網路中獲取,比如Web Applet等

  • 執行時計算生成,這個動態代理技術使用的最多,比如Spring框架大量使用到動態代理

    ......

還有許多其他的載入途徑,這裡就不一一列舉了。

載入階段結束後,表示Java型別的二進位制位元組流就按照虛擬機器的格式儲存在方法區之中了,之後會在Java堆記憶體中例項化一個java.lang.Class類的物件,這個物件作為程式訪問方法區中的型別資料的外部介面。

2、驗證

驗證階段主要是為了保證Class位元組流中包含的資訊是符合《Java虛擬機器規範》所要求的,不能出現一些危害Java虛擬機器自身安全的內容。

可能有的人會有疑問,為什麼不在編譯的時候就完驗證?直接在編譯的時候發現有惡意的程式碼,編譯器就直接拒絕編譯,這就可以避免後面載入的時候再去驗證合法性。但是前面我們說過,Class檔案二進位制位元組流不一定非得從Java檔案編譯而來,它可以從很多其他途徑載入而來,Java虛擬機器如果不檢查輸入的位元組流,對其完全信任的話,很可能會因為載入了有錯誤或有惡意企圖的位元組碼流而導致整個系統受攻擊甚至崩潰,所以驗證位元組碼是Java虛擬機器保護自身的一項必要措施

從整體上看,驗證階段會完成四個方面的驗證

  • 檔案格式驗證
  • 後設資料驗證
  • 位元組碼驗證
  • 符號引用驗證

1、檔案格式驗證

這一階段主要驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的Java虛擬機器處理。主要的驗證點包括

  • 魔數驗證,是否以0xCAFEBABE開頭

  • 主、次版本號是否在當前Java虛擬機器接受範圍之內

  • 常量池的常量中是否有不被支援的常量型別(檢查常量tag標誌)

  • 指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量

    .......

還有很多其他的驗證,總之這一階段側重於對格式的驗證,保證位元組流能被正確的解析並儲存於方法區內,格式上符合一個Java型別的基本要求。

2、後設資料驗證

這一階段主要是對於位元組碼的描述檔案進行分析,以保證其符合《Java語言規範》的要求,主要的驗證點包括

  • 是否有除了Object類以外的父類

  • 是否繼承了不被允許繼承的類

  • 類中的方法、欄位是否與父類有衝突

    ......

還有很多其他型別的驗證,總之這一階段側重於對類的後設資料資訊進行分析,保證不存在與《Java語言規範》相違背的內容出現

3、位元組碼驗證

這一階段主要是對位元組碼進行分析,透過分析資料流和控制流,確定程式的語義是合法的,符合邏輯的。這一階段是對類的方法體進行分析,保證類的方法在執行時不會做出危害Java虛擬機器的行為。主要驗證點包括:

  • 保證任何跳轉指令都不會跳轉到方法體以外的位元組碼指令上

  • 保證方法體中的型別轉換總是有效的

  • 保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作

    ......

還有很多其他型別的驗證,總之這一階段側重於對程式碼邏輯進行分析和驗證,確保不會有非法行為。由於資料流分析和控制流分析的高度複雜性,所以這一階段是整個驗證過程最複雜的部分

4、符號引用驗證

這一階段可以看作是對類自身以外的各類資訊進行匹配性校驗,通俗來說就是,該類是否缺少或者被禁止訪問它依賴的某些外部類、方法、欄位等資源。主要驗證點包括:

  • 符號引用中透過字串描述的全限定名是否能找到對應的類

  • 在指定類中是否存在符合方法的欄位描述符及簡單名稱所描述的方法和欄位

  • 符號引用中的類、欄位、方法的可訪問性(private、protected、public、)是否可被當前類訪問

    ......

還有很多其他型別的驗證,總之這一階段的主要目的是確保解析行為能正常執行

驗證階段對於虛擬機器的類載入機制來說,是一個非常重要的、但卻不是必須要執行的階段,因為驗證階段只有透過或者不透過的差別,只要透過了驗證,其後就對程式執行期沒有任何影響了。如果程式執行的全部程式碼(包括自己編寫的、第三方包中的、從外部載入的、動態生成的等所有程式碼)都已經被反覆使用和驗證過,在生產環境的實施階段就可以考慮使用-Xverify:none引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間

3、準備

準備階段主要的任務就是為類中的靜態(static修飾)變數分配記憶體空間並設定初始值。這裡需要注意,準備階段僅僅是為靜態變數分配記憶體,因為靜態變數是屬於類的變數,普通變數分配記憶體需要等到建立物件時才會進行。而且在這一階段設定初始值,是設定各個型別的零值,比如:

public static int hello = 123456;

變數hello在準備階段會被賦初始值0,而不是123456。

當然,上面是一般情況下,也會有特殊情況,如果類的欄位存在常量值,那麼在準備階段就會被賦值常量值,比如:

public static final int hello = 123456;

javac在編譯時,就會將123456賦給hello,那麼在準備階段虛擬機器就會根據123456的值來設定給hello

4、解析

解析階段是Java虛擬機器將常量池內的符號引用替換為直接引用的過程,先來解釋一下兩個概念:符號引用、直接引用

  • 符號引用:是以一組符號來表示所引用的目標,引用的目標不一定是已經載入到虛擬機器當中的的內容
  • 直接引用:表示可以直接指向目標的指標、相對偏移量或者是能間接定位到目標的控制代碼。如果有了直接引用,引用的目標必然已經存在於虛擬機器的記憶體中

《Java虛擬機器規範》之中並未規定解析階段發生的具體時間,只要求了在執行ane-warray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic這17個用於運算子號引用的位元組碼指令之前,先對它們所使用的符號引用進行解析

解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼和呼叫點限定符這7類符號引用進行,分別對應於常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和CONSTANT_InvokeDynamic_info 8種常量型別

具體解析動作較複雜,對於每一種型別都有不同的解析方法,如果對於解析過程想深入研究,可以參考《Java虛擬機器規範》,這裡再不進行過多的闡述

5、初始化過程

前面的過程,都是由Java虛擬機器來主導的。從初始化開始,Java虛擬機器才真正開始執行類中編寫的Java程式程式碼,這是類載入的最後一個階段。

在準備階段,靜態變數已經被賦值過一次零值。在初始化階段才會真正賦予程式設計師編碼時給定的值。

其實簡單來講,初始化階段就是執行類構造器<init>()方法的過程,<init>()並不是程式設計師自己寫的,是由javac編譯器自動呼叫的

<init>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問

Java虛擬機器必須保證一個類的<init>()方法在多執行緒環境中被正確地加鎖同步,如果多個執行緒同時去初始化一個類,那麼只會有其中一個執行緒去執行這個類的<init>()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行完畢<init>()方法

本文由部落格一文多發平臺 OpenWrite 釋出!

相關文章