Java虛擬機器詳解(十)------類載入過程
在上一篇文章中,我們詳細的介紹了Java類檔案結構,那麼這些Class檔案是如何被載入到記憶體,由虛擬機器來直接使用的呢?這就是本篇部落格將要介紹的——類載入過程。
1、類的生命週期
類從被載入到虛擬機器記憶體開始,到卸載出記憶體為止,其宣告週期流程如下:
上圖中紅色的5個部分(載入、驗證、準備、初始化、解除安裝)順序是確定的,也就是說,類的載入過程必須按照這種順序按部就班的開始。這裡的“開始”不是按部就班的“進行”或者“完成”,因為這些階段通常是互相交叉混合的進行的,通常會在一個階段執行過程中呼叫另一個階段。
2、載入
“載入”階段是“類載入”生命週期的第一個階段。在載入階段,虛擬機器要完成下面三件事:
①、通過一個類的全限定名來獲取定義此類的二進位制位元組流。
②、將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
③、在Java堆中生成一個代表這個類的java.lang.Class物件,作為方法區這些資料的訪問入口。
PS:類的全限定名可以理解為這個類存放的絕對路徑。方法區是JDK1.7以前定義的執行時資料區,而在JDK1.8以後改為元資料區(Metaspace),主要用於存放被Java虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。詳情可以參考這邊該系列的第二篇文章——執行時記憶體結構。
另外,我們看第一點——通過類的許可權定名來獲取定義此類的二進位制流,這裡並沒有明確指明要從哪裡獲取以及怎樣獲取,也就是說並沒有明確規定一定要我們從一個 Class 檔案中獲取。基於此,在Java的發展過程中,充滿創造力的開發人員在這個舞臺上玩出了各種花樣:
1、從 ZIP 包中讀取。這稱為後面的 JAR、EAR、WAR 格式的基礎。
2、從網路中獲取。比較典型的應用就是 Applet。
3、執行時計算生成。這就是動態代理技術。
4、由其它檔案生成。比如 JSP 應用。
5、從資料庫中讀取。
載入階段完成後,虛擬機器外部的二進位制位元組流就按照虛擬機器所需的格式儲存在方法區中,然後在Java堆中例項化一個 java.lang.Class 類的物件,這個物件將作為程式訪問方法區中這些型別資料的外部介面。
注意,載入階段與連線階段的部分內容(如一部分位元組碼檔案的格式校驗)是交叉進行的,載入階段尚未完成,連線階段可能已經開始了。
3、驗證
驗證是連線階段的第一步,作用是為了確保 Class 檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。
我們說Java語言本身是相對安全,因為編譯器的存在,純粹的Java程式碼要訪問陣列邊界外的資料、跳轉到不存在的程式碼行之類的,是要被編譯器拒絕的。但是前面我們也說過,Class 檔案不一定非要從Java原始碼編譯過來,可以使用任何途徑,包括你很牛逼,直接用十六進位制編輯器來編寫 Class 檔案。
所以,如果虛擬機器不檢查輸入的位元組流,將會載入有害的位元組流而導致系統崩潰。但是虛擬機器規範對於檢查哪些方面,何時檢查,怎麼檢查都沒有明確的規定,不同的虛擬機器實現方式可能都會有所不同,但是大致都會完成下面四個方面的檢查。
①、檔案格式驗證
校驗位元組流是否符合Class檔案格式的規範,並且能夠被當前版本的虛擬機器處理。
一、是否以魔數 0xCAFEBABE 開頭。
二、主、次版本號是否是當前虛擬機器處理範圍之內。
三、常量池的常量中是否有不被支援的常量型別(檢查常量tag標誌)
四、指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量。
五、CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 編碼的資料。
六、Class 檔案中各個部分及檔案本身是否有被刪除的或附加的其他資訊。
以上是一部分校驗內容,當然遠不止這些。經過這些校驗後,位元組流才會進入記憶體的方法區中儲存,接下來後面的三個階段校驗都是基於方法區的儲存結構進行的。
②、元資料驗證
第二個階段主要是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範要求。
一、這個類是否有父類(除了java.lang.Object 類之外,所有的類都應當有父類)。
二、這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
三、如果這個類不是抽象類,是否實現了其父類或介面之中要求實現的所有普通方法。
四、類中的欄位、方法是否與父類產生了矛盾(例如覆蓋了父類的final欄位、或者出現不符合規則的過載)
③、位元組碼驗證
第三個階段位元組碼驗證是整個驗證階段中最複雜的,主要是進行資料流和控制流分析。該階段將對類的方法進行分析,保證被校驗的方法在執行時不會做出危害虛擬機器安全的行為。
一、保證任意時刻運算元棧中的資料型別與指令程式碼序列都能配合工作。例如不會出現在運算元棧中放置了一個 int 型別的資料,使用時卻按照 long 型別來載入到本地變量表中。
二、保證跳轉指令不會跳轉到方法體以外的位元組碼指令中。
三、保證方法體中的型別轉換是有效的。比如把一個子類物件賦值給父類資料型別,這是安全的。但是把父類物件賦值給子類資料型別,甚至賦值給完全不相干的型別,這就是不合法的。
④、符號引用驗證
符號引用驗證主要是對類自身以外(常量池中的各種符號引用)的資訊進行匹配性的校驗,通常需要校驗如下內容:
一、符號引用中通過字串描述的全限定名是否能夠找到相應的類。
二、在指定類中是否存在符合方法的欄位描述符及簡單名稱所描述的方法和欄位。
三、符號引用中的類、欄位和方法的訪問性(private、protected、public、default)是否可以被當前類訪問。
4、準備
準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些記憶體是在方法區中進行分配。
注意:
一、上面說的是類變數,也就是被 static 修飾的變數,不包括例項變數。例項變數會在物件例項化時隨著物件一起分配在堆中。
二、初始值,指的是一些資料型別的預設值。基本的資料型別初始值如下(引用型別的初始值為null):
比如,定義 public static int value = 123 。那麼在準備階段過後,value 的值是 0 而不是 123,把 value 賦值為123 是在程式被編譯後,存放在類的構造器方法之中,是在初始化階段才會被執行。但是有一種特殊情況,通過final 修飾的屬性,比如 定義 public final static int value = 123,那麼在準備階段過後,value 就被賦值為123了。
5、解析
解析階段是虛擬機器將常量池中的符號引用替換為直接引用的過程。
符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義的定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標不一定已經載入到記憶體中。
直接引用(Direct References):直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制代碼。直接引用是與虛擬機器實現記憶體佈局相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接引用,那麼引用的目標必定已經在記憶體中存在。
解析動作主要針對類或介面、欄位、類方法、介面方法四類符號引用,分別對應於常量池的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANTS_InterfaceMethodref_info四種類型常量。
6、初始化
初始化階段是類載入階段的最後一步,前面過程中,除第一個載入階段可以通過使用者自定義類載入器參與之外,其餘過程都是完全由虛擬機器主導和控制。而到了初始化階段,則開始真正執行類中定義的Java程式程式碼(或者說是位元組碼)。
在前面介紹的準備階段中,類變數已經被賦值過初始值了,而初始化階段,則根據程式設計師的編碼去初始化變數和資源。
換句話來說,初始化階段是執行類構造器<clinit>() 方法的過程。
①、<clinit>() 方法 是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{})中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊中可以賦值,但是不能訪問。
比如如下程式碼會報錯:
但是你把第 14 行程式碼放到 static 靜態程式碼塊的上面就不會報錯了。或者不改變程式碼順序,將第 11 行程式碼移除,也不會報錯。
②、<clinit>() 方法與類的建構函式(或者說是例項構造器<init>()方法)不同,它不需要顯示的呼叫父類構造器,虛擬機器會保證在子類的<init>()方法執行之前,父類的<init>()方法已經執行完畢。因此虛擬機器中第一個被執行的<init>()方法的類肯定是 java.lang.Object。
③、由於父類的<clinit>() 方法先執行,所以父類中定義的靜態語句塊要優先於子類的變數賦值操作。
④、<clinit>() 方法對於介面來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>() 方法。
⑤、介面中不能使用靜態語句塊,但仍然有變數初始化的賦值操作,因此介面與類一樣都會生成<clinit>() 方法。但介面與類不同的是,執行介面中的<clinit>() 方法不需要先執行父介面的<clinit>() 方法。只有當父介面中定義的變數被使用時,父接口才會被初始化。
⑥、介面的實現類在初始化時也一樣不會執行介面的<clinit>() 方法。
⑦、虛擬機器會保證一個類的<clinit>() 方法在多執行緒環境中被正確的加鎖和同步。如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的<clinit>() 方法,其他的執行緒都需要阻塞等待,直到活動執行緒執行<clinit>() 方法完畢。如果在一個類的<clinit>() 方法中有很耗時的操作,那麼可能造成多個程序的阻塞。
比如對於如下程式碼:
package com.yb.carton.controller; /** * Create by YSOcean */ public class ClassLoadInitTest { static class Hello{ static { if(true){ System.out.println(Thread.currentThread().getName() + "init"); while(true){} } } } public static void main(String[] args) { new Thread(()->{ System.out.println(Thread.currentThread().getName()+"start"); Hello h1 = new Hello(); System.out.println(Thread.currentThread().getName()+"run over"); }).start(); new Thread(()->{ System.out.println(Thread.currentThread().getName()+"start"); Hello h2 = new Hello(); System.out.println(Thread.currentThread().getName()+"run over"); }).start(); } }View Code
執行結果如下:
執行緒1搶到了執行<clinit>() 方法,但是該方法是一個死迴圈,執行緒2將一直阻塞等待。
知道了類的初始化過程,那麼類的初始化何時被觸發呢?JVM大概規定了如下幾種情況:
①、當虛擬機器啟動時,初始化使用者指定的類。
②、當遇到用以新建目標類例項的 new 指令時,初始化 new 指定的目標類。
③、當遇到呼叫靜態方法的指令時,初始化該靜態方法所在的類。
④、當遇到訪問靜態欄位的指令時,初始化該靜態欄位所在的類。
⑤、子類的初始化會觸發父類的初始化。
⑥、如果一個介面定義了 default 方法,那麼直接實現或間接實現該介面的類的初始化,會觸發該介面的初始化。
⑦、使用反射 API 對某個類進行反射呼叫時,會初始化這個類。
⑧、當初次呼叫 MethodHandle 例項時,初始化該 MethodHandle 指向的方法所在的類。
&n