深入理解JVM(③)虛擬機器的類載入過程

紀莫發表於2020-06-27

前言

上一篇我們介紹到一個類的生命週期大概分7個階段:載入、驗證、準備、解析、初始化、使用、解除安裝。並且也介紹了類的載入時機,下面我們將介紹一下虛擬機器中類的載入的全過程。主要是類生命週期的,載入、驗證、準備、解析和初始化這五個階段所執行的具體動作。

載入

類載入過程的第一個階段就是載入,在載入階段,Java虛擬機器需要完成以下三件事情:

1. 通過一個類的全限定名來獲取定義此類的二進位制位元組流。
2. 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
3. 在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。
《Java虛擬機器規範》對這三點要求其實並不是特別具體,這樣留給虛擬機器實現和Java應用的靈活度都是相當大的。僅第一條,獲取二進位制位元組流,並沒有有指出從哪裡獲取,如何獲取。這樣就已經能被我們的Java開發人員玩出各種花樣了。
例如:

  • 從ZIP包中讀取(JAR、EAR、WAR)。
  • 從網路中獲取(Web Applet)。
  • 執行時計算生成,最典型的就是動態代理技術,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass()來為特定介面生成形式為“$Proxy”的代理類的二進位制位元組流。
  • 由其他檔案生成(JSP)。
  • 從資料庫中讀取。
  • 從加密檔案中獲取(防止被反編譯獲取原始碼)。
  • ........ .....

相對於類載入的其他階段,非陣列型別的載入階段是開發人員可控性最強的階段。載入階段即可以使用Java虛擬機器裡內建的引導類載入器完成,也可以由使用者自定義的類載入器去完成。

驗證

驗證這一階段的目的是確保Class檔案的位元組流中包含的資訊符合《Java虛擬機器規則》的全部約束要求,保證這些資訊被當作程式碼執行後不會危害虛擬機器自身安全。
驗證階段大致上會完成下面四個階段的檢驗動作:
檔案格式驗證、後設資料驗證、位元組碼驗證和複合引用驗證

檔案格式驗證

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

  • 是否以魔數0xCAFEBABE開頭。
  • 主、次版本號是否在當前Java虛擬機器接受範圍之內。
  • 常量池的常量中是否有不被支援的常量型別。
  • 指向常量的各種索引值是否有指向不錯在的常量或不符合類的常量。
  • ... ...

這個階段的驗證是基於二進位制位元組流進行的,只有通過了這個階段的驗證之後,這段位元組流才被允許進入Java虛擬機器記憶體的方法區中進行儲存,後面的階段都是基於方法區的儲存結構進行的,不會再直接讀取、操作位元組流了。

後設資料驗證

第二階段是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合《Java虛擬機器規則》的要求,這個階段主要有以下一些驗證點:

  • 當前類是否有父類(除java.lang.Object外,所有類都應當有父類)。
  • 當前類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
  • 如果當前類非抽象類,是否實現了父類或介面要求實現的所有方法。
  • 類中的欄位、方法是否與父類產生矛盾。
  • ... ...
位元組碼驗證

第三階段是整個驗證過程最複雜的一個階段,主要目的是通過資料流分析和孔劉分析,確定程式語義是合法的、符合邏輯的。
為了保證被校驗的方法在執行時不會做出危害虛擬機器的安全的行為,主要做了如下一些校驗:

  • 保證任意時刻操作棧的資料型別與指令程式碼序列都能配合工作,例如不會出現類似於“在操作放置了一個int型別資料,使用時卻按long型別來載入如本地變數表中”這樣的情況。
  • 保證任何跳轉指令都不會跳轉到方法體以外的位元組碼指令上。
  • 保證方法體中的型別轉換總是有效的。例如:一個子類物件賦值給父類資料型別,這是安全的,但是把父類物件賦值為子類資料型別,獲取賦值給另外一個毫無關係的資料型別,則是不合法的。
  • ... ...

如果一個型別中有方法體的位元組碼沒有通過位元組碼驗證,那它肯定是有問題的;但如果一個方法體通過了位元組碼驗證,也仍然不能保證它一定就是安全的。因為位元組碼驗證也是在程式中進行的,即不能通過程式準確地檢查出程式是否能在有限時間之內結束執行。

符號引用驗證

最後一個階段的校驗行為發生在虛擬機器將符號引用轉化為直接引用的時候,這個轉化動作將在連線的第三個階段——解析階段發生。
本階段通常需要校驗下列內容:

  • 符號引用中通過字串描述的全限定名是否能找到對應的類。
  • 在指定類中是否存在符合方法的欄位描述符及簡單名稱所描述的方法和欄位。
  • 符合引用中的類、欄位、方法的可訪問性,是否可被當前類訪問。

驗證階段對於虛擬機器的類載入機制來說,是一個非常重要的、但卻不是必須要執行的階段,因為如果程式執行的全部程式碼都已經被反覆使用和驗證過,在生成環境的實施階段就可以考慮使用-Xverify:none引數來關閉大部分的類驗證措施,來縮短類載入的時間。

準備

準備階段是正式為類中定義的變數分配記憶體並設定類變數的初始值的階段,這些變數所使用的記憶體都應當在方法區中進行分配,需要注意的是,這裡所說的方法區只是一個概念上的區域,在JDK7以及之前HotpSpot用永久代實現方法區,這個概念是正確的,但是在JDK8以及之後,類變數會隨著Class物件一起存放在Java堆中,這個時候類變數存在於方法區就僅僅是一個概念了。
在準備階段有兩點需要著重強調
1、在準備階段進行記憶體分配的僅包括類變數,而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在Java堆中。
2、這裡所說的為類變數設定初始值,“通常情況”下是資料類的零值。
例如一個類變數定義為:

public static int value = 666;

那變數在準備階段過後的初始值為0而不為666,因為這個時候還未開始執行任何Java方法,而把value賦值為666的putstatic指令是程式被編譯後,存放於類構造器()方法之中,所以把value賦值為666的動作要到類的初始化階段才會被執行。
在這裡插入圖片描述
但是如果類欄位的屬性表中存在ConstantValue屬性,那在準備階段變數值就會被初始化為ConstantValue屬性所指定的初始值,例如:

public static final int value = 666;

在編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機器就會根據ConstantValue的設定將value賦值為666。

解析

解析階段是Java虛擬機器將常量池內的符號引用替換為直接引用的過程,符號引用在Class檔案中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等型別的常量出現。
先來解釋一下什麼是符號引用和直接引用。

  • 符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。
  • 直接引用:直接引用是可以直接指向目標的指標、相對偏移量或者是一個能間接定位到目標的控制程式碼。

解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定這7類符號引用進行,分別對應於常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MehodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dynamic_info和CONSTANT_InvokeDynamic_info
這8種常量型別。

初始化

初始化階段是類載入過程的最後一個步驟,之前介紹的幾個類載入的動作裡,出了在載入階段使用者應用程式可以通過自定義類載入器的方式區域性參與外,其餘動作都完全由Java虛擬機器來主導控制。
簡單的來說,初始化階段就是執行類構造器<clinit>()方法的過程。那麼<clinit>()是如何執行的呢?

  • <clinit>()方法是由編譯器自動收集類中所有變數的複製動作和靜態語句塊(stataic{}塊)中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問。
    例如:
    在這裡插入圖片描述
  • <clinit>()方法與類的建構函式不同,它不需要顯式地呼叫父類構造器,Java虛擬機器會保證在子類()方法執行前,父類()方法以及執行完畢。
  • 由於父類的<clinit>()方法先執行,即父類中定義的靜態語句塊要優先於子類的變數賦值操作。
    如下程式碼執行結果會是 4
    父類
public class FatherClass {

    public static int fatherObject = 3;

    static {
        fatherObject = 4;
    }
}

子類

public class SonClass extends FatherClass{

    public static int sonObject = fatherObject;

}

測試

@Test
public void testClassLoad(){
    System.out.println(SonClass.sonObject);
}

執行結果

4
  • <clinit>()方法對於類或介面來說並不是必需的。
  • 介面中不能使用靜態語句塊,但仍然有變數初始化的賦值操作,因此介面與類一樣都會生成<clinit>()方法。
  • Java虛擬機器必須保證一個類的<clinit>()方法在多執行緒環境中被正確地加鎖同步,若同時多個執行緒區初始化一個類,那麼只會有其中一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行完畢<clinit>()方法。

相關文章