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

前言

上一篇我們介紹到一個類的生命周期大概分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>()方法。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※為什麼 USB CONNECTOR 是電子產業重要的元件?

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

※台北網頁設計公司全省服務真心推薦

※想知道最厲害的網頁設計公司"嚨底家"!

新北清潔公司,居家、辦公、裝潢細清專業服務

※推薦評價好的iphone維修中心

您可能也會喜歡…