深入理解java虛擬機(八)類加載過程詳解


類從被加載到虛擬機內存開始,到卸載出內存為止,它的整個生命周期包括:加載(Loading)、驗證(Verification)、准備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中,驗證、准備和解析統稱為連接(Linking)。過程如下圖所示。

 

下面我們來詳細講解Java虛擬機類中加載的5個過程,即 加載、驗證、准備、解析和初始化五個過程。

第一步:加載

“加載”是虛擬機類加載的第一個階段,在這個階段中,虛擬機要完成3件事情:
  1. 通過權限定名,獲取類的二進制字節流
  2. 將字節流的靜態數據結構轉化為方法區的運行時數據結構
  3. 在內存中生成一個代表這個類的 java.lang.Class 對象,作為方法區中這個類數據結構的訪問入口
而獲取類的二進制流又有很多種方式:
  • 從壓縮包中獲取,比如 JAR包、EAR、WAR包等
  • 從網絡中獲取,比如紅極一時的Applet技術
  • 從運行過程中動態生成,最出名的便是動態代理技術,在java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 來為特定接口生成形式為“$Proxy”的代理類的二進制流
  • 從其它文件生成,如JSP文件生成Class 類
  • 從數據庫中讀取,比如說有些中間件服務器,通過數據庫完成程序代碼在集群之間的分發
相對於類加載中的其它階段,“加載”這一階段是開發人員可控性最強的階段。因為“加載”階段既可以使用系統提供的引導類加載器完成,也可以由用戶自定義的類加載器完成。即開發人員可以根據實際需要指定二進制數據流的獲取方式。
 
對於數組類來說,它不是由虛擬機加載得來的,而是在運行過程中直接創建的。其創建過程遵循以下規則:
  • 如果數組的組件類型(數組去掉一個維度的數據類型)是引用類型,就遞歸使用這些引用類型的類加載器進行加載。
  • 如果組件類型不是引用類型,例如 int[] 數組,Java 虛擬機會將數組標記為與引導類加載器管理
  • 數組的可見性與它的組件類型可見性一致,如果組件類型不是引用類型,那數組類的可見性將默認為public。
加載過程完成之后首先會在方法區中建立類的數據結構,然后會在內存中實例化一個 java.lang.Class 對象來作文程序訪問方法去中這些類型數據的外部接口。Java規范中並沒有規定 Class 對象的存放位置,對於Hot Spot 虛擬機來說,Class 對象雖然是對象,但卻是存放在方法區中。
 

第二步:驗證

“驗證”是連接階段的第一步,這一階段是為了確保 Class 文件內的字節流格式符合Java虛擬機要求,不會危害虛擬機安全。
 
Java 語言本身是相對安全的語言,但是用純粹的Java 代碼無法做到諸如訪問數組邊界之外的數據、將對象轉型為它未實現的類型、跳轉到不存在的代碼行之類的事情,如果這么做了,編譯器會拒絕編譯。但是由於 Class 文件的產生並不強制要求必須是 Java 代碼編譯而來,你甚至可以自己用十六進制編輯器直接編寫 Class 文件。如果不進行 Class 文件校驗的話,很有可能會因為載入了有害的字節流導致虛擬機崩潰。
 
驗證總體上分為4個階段: 文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。

文件格式驗證

這階段主要是檢查字節流是否符合Class 文件的規范,版本是否能夠處理,包括下面這些驗證點:

 

  • 是否以魔數開頭
  • 主次版本是否在當前虛擬機處理范圍之內
  • 常量池的常量中是否有不被支持的常量類型(檢查常量tag標志)
  • 指向常量的各種索引中是否有指向不存在的常量或不符合類型的常量
  • CONSTANT_Utf8_info 型的常量中是否有不符合UTF8 編碼的數據
  • Class 文件中各個部分以及文件本身是否有被刪除的或被附加的其它信息
文件格式驗證主要是針對字節流進行驗證,經過了這一步驗證之后,字節流才會進入內存的方法區進行存儲,所以后面的3個階段全部都是基於方法區的存儲結構進行的,不會再操作字節流。

 

元數據驗證

這一階段主要是對字節碼描述的信息進行語義分析,驗證類的繼承關系,驗證點如下:
  • 這個類的父類是否允許被繼承(final 修飾的類)
  • 這個類如果不是抽象類,是否實現了父類或接口之中要求實現的所有方法
  • 類中的字段、方法是否和父類產生矛盾(如覆蓋了父類final 字段,出現了非法的方法重載)

字節碼驗證

字節碼驗證是驗證過程中最復雜的一個階段,它的目的是確定程序語義是合法的、符合邏輯的。在元數據驗證完成之后,它會對方法體進行驗證,確保程序不會做出危害虛擬機安全的行為。
  • 保證任何時候,操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現int 數據入棧,而卻按 long 類型加載如本地變量表中
  • 保證跳轉指令不會跳轉到方法體外的字節碼指令上
  • 保證方法體中類型轉換是有效的

符號引用驗證

最后一個階段的驗證放生在虛擬機將符號引用轉化為直接引用的時候,這個階段將會在解析階段發生。符號引用驗證可以看做是對類自身以外的信息進行匹配校驗。
  • 符號引用中通過字符串描述的權限定名是否能找到相應的類
  • 在指定類中對否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段
  • 符號引用中的類、字段、方法的訪問性是否可被當前類訪問
符號引用驗證是為了保證解析動作能正常執行。這個階段可以用 -Xverify:none 參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。

第三步:准備

“准備”階段是正式為類變量(僅僅是類變量,即 static 修飾的變量)分配內存並設置類變量初始值(除了 final 變量初始值是數據類型的零值,並不是類構造器<clinit> 方法中的初始值)的階段,這些變量所使用的內存都將在方法區中進行。

第四步:解析

”解析“階段是虛擬機將常量池內的符號引用替換為直接引用的過程,符號引用以 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Mehodref_info 等類型的常量出現(即 類名稱,變量名稱,方法名稱等信息)。那么符號引用與直接引用有什么關聯呢?
  • 符號引用(Symbolic References):即用一組符號來描述所引用的目標。它與虛擬機的內存布局無關,引用的目標不一定已經加載到內存中。
  • 直接引用(Direct References):直接引用可以是指向目標的指針、相對偏移量或是一個能簡介定位到目標的句柄。它是和虛擬機內存布局相關的。
解析動作主要針對 類或接口、字段、類方法、接口方法、方法類型、方法句柄 和 調用限定符 7類符號引用進行。

類或接口的解析

假如類D 要把一個從未解析過的符號引用N 解析為一個類或接口C 的直接引用,那虛擬機解析過程需要3個過程:

 

  • 如果C不是一個數組類型,虛擬機會把 N 的全限定名傳遞給 D 的類加載器去加載這個類C 。由於元數據驗證 和 字節碼驗證 的過程, 可能會觸發其它相關類的加載動作。
  • 如果C是一個數組類型,並且數組元素類型為對象,那將會按照第一點規則加載數組元素類型。
  • 如果上面步驟沒有任何異常, 那么 C 已經成為了一個有效的類和接口了,但在解析完成之前還要進行符號引用驗證,確認D是否具備對C的訪問權限。否則會拋出java.lang.IllegalAccessError 異常。

 

字段解析

首先對字段的位於class_index 項中索引的 CONSTANT_Class_info 符號引用,也就是字段所屬的類或接口的符號引用進行解析,如果解析順利的話,虛擬機將按照下列步驟進行字段搜索。
  • 如果C本身就包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
  • 否則,如果C 中實現了接口,會按繼承關系從下往上遞歸搜索其父接口,如果在父接口中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
  • 否則,如果C 不是 java.lang.Object 的話,將會按繼承關系從下往上遞歸搜索其父類,如果在父類中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
  • 否則,查找失敗,拋出 java.lang.NoSuchFieldError 異常。
如果查找過程成功返回了引用,將會對這個字段進行權限驗證,如果發現不具備對字段的訪問權限,將拋出 java.lang.IllegalAccessError 異常。

類方法解析

類方法解析第一步和字段解析是一樣的,都是先解析出類方法表的 class_index 項中索引的方法所屬的類或接口的符號引用,如果順利的話,虛擬機將按下列步驟進行字段搜索。
  • 如果發現class_index 索引的C是個接口,那就拋出 java.lang.IncompatibleClassChangeError 異常。
  • 如果通過了第一步,在類C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
  • 否則,在類C的父類中遞歸查找,如果有就直接返回方法的直接引用,查找結束。
  • 否則,在類C 實現的接口列表以及他們的父接口中遞歸查找,如果存在,則說明C是一個抽象類,查找結束並拋出 java.lang.AbstractMehodError 的異常。
  • 否則,查找失敗,拋出 java.lang.NoSuchMethodError 異常.
最后如果查找成功,會進行權限驗證。失敗會拋出 java.lang.IllegalAccessError

接口方法解析

接口方法解析第一步和字段解析是一樣的,都是先解析出類方法表的 class_index 項中索引的方法所屬的類或接口的符號引用,如果順利的話,虛擬機將按下列步驟進行字段搜索。
  • 如果接口方法表中發現 class_index 中的索引C是個類而不是接口,就直接拋出 java.lang.IncompatibleClassChangeError 異常。
  • 否則,在接口C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束
  • 否則,在幾口C 的父接口中遞歸查找,直到 java.lang.Object 類位置,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
  • 否則,查找失敗,拋出 java.lang.NoSuchMethodError 異常。
由於接口方法都是public的,所以不存在訪問權限的問題,因此接口方法的符號解析應當不會拋出 java.lang.IllegalAccessError 異常。

第五步:初始化

“初始化”是類加載的最后一步,到了這一步,才是真正開始執行類中定義的Java 程序代碼。
  • *類構造器<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static塊)中的語句合並產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之后的變量,在前面的靜態語句快可以賦值,但是不能訪問。
  • *類構造器<clinit>()方法與類的構造函數(實例構造函數<init>()方法)不同,它不需要顯式調用父類構造,虛擬機會保證在子類<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此在虛擬機中的第一個執行的<clinit>()方法的類肯定是java.lang.Object。
  • *由於父類的<clinit>()方法先執行,也就意味着父類中定義的靜態語句快要優先於子類的變量賦值操作。
  • *<clinit>()方法對於類或接口來說並不是必須的,如果一個類中沒有靜態語句,也沒有變量賦值的操作,那么編譯器可以不為這個類生成<clinit>()方法。
  • *接口中不能使用靜態語句塊,但接口與類不同是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法。只有當父接口中定義的變量被使用時,父接口才會被初始化。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。
  • *虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確加鎖和同步,如果多個線程同時去初始化一個類,那么只會有一個線程執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。如果一個類的<clinit>()方法中有耗時很長的操作,那就可能造成多個進程阻塞。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM