1. 程式人生 > >JAVA虛擬機器(JVM)——類載入的過程(載入、驗證、準備、解析、初始化)

JAVA虛擬機器(JVM)——類載入的過程(載入、驗證、準備、解析、初始化)

載入

“載入”是”類載入”過程的一個階段。在載入階段,虛擬機器需要完成以下3件事情:

1.通過一個類的全限定名來獲取定義此類的二進位制位元組流。
2.將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
3.在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。

驗證

驗證是連線階段的第一步,這一階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並不會危害虛擬機器的自身安全。

1.檔案格式驗證:
(1)是否以魔數0xCAFEBABE開頭。
(2)主、次版本號是否在當前虛擬機器處理範圍之內。
(3)常量池的常量中是否有不被支援的常量型別(檢查常量tag標誌)。
(4)指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量。
(5)CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的資料。
(6)Class檔案中各個部分及檔案本身是否有被刪除的或附加的其他資訊。
  ......
2.元資料驗證:
(1)這個類是否有父類(除了java.lang.Object之外,所有類都應當有父類)。
(2)這個類是否繼承了不允許被繼承的類(被final修飾的類)。
(3)如果這個類不是抽象類,是否實現了其父類或介面之中所要求實現的所有方法。
(4)類中的欄位、方法是否與父類產生矛盾(例如覆蓋了父類的final欄位,或者出現不符合規則的方法過載,例如方法引數都一致,但返回值型別卻不同等等)。
 ......
3.位元組碼驗證:
主要目的是通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在執行時不會產生危害虛擬機器安全的事件,例如:
(1)保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作,例如不會出現類似這樣的情況:在運算元棧放置了一個int型別的資料,使用時卻按long型別來載入入本地變量表中。
(2)保證跳轉指令不會跳轉到方法體以外的位元組碼指令上。
(3)保證方法體中的型別轉換是有效的,例如可以把一個子類物件賦值給父類資料型別,但是把父類物件賦值給子類資料型別,甚至把物件賦值給與它毫無繼承關係、完全不相干的一個數據型別,則是危險不合法的。
......
(Halting Problem:通過程式去校驗程式邏輯是無法做到絕對準確的——不能通過程式準確的檢查出程式是否能在有限時間之內結束執行。)
4.符號引用驗證:
符號引用驗證可以看作是類對自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗,通常需要校驗以下內容:
(1)符號引用中通過字串描述的全限定名是否能夠找到對應的類。
(2)在指定類中是否存在符合方法的欄位描述符以及簡單名稱所描述的方法和欄位。
(3)符號引用中的類、欄位、方法的訪問性(private、protected、public、default)是否可被當前類訪問。
 ......

準備

準備階段是正式為類變數分配記憶體並設定類變數初始值(通常情況下是資料型別的零值)的階段,這些變數所使用的記憶體都將在方法區中進行分配。這時候進行記憶體分配的僅包括類變數(被static修飾的變數),而不包括例項變數,例項變數將會在物件例項化的時候隨著物件一起分配在Java堆中。

這裡寫圖片描述

下面來說一下初始值設定時的特殊情況:
如果類欄位的欄位屬性表中存在ConstantValue屬性,那在準備階段變數value就會被初始化為ConstantValue屬性所指定的值,假設類變數value定義為:

    public static final int value = 123;

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

解析

解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。

符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。

直接引用(Direct References):直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制代碼。如果有了直接引用,那麼引用的目標一定是已經存在於記憶體中。

1.類或介面的解析

假設當前程式碼所處的類為D,如果要把一個從未解析過的符號引用N解析為一個類或介面C的引用,那虛擬機器完成整個解析過程需要以下3個步驟:
(1)如果C不是一個數組型別,那虛擬機器將會把代表N的全限定名傳遞給D的類載入器去載入這個類C。
(2)如果C是一個數組型別,並且陣列的元素型別為物件,那將會按照第1點的規則載入陣列元素型別。
(3)如果上面的步驟沒有出現任何異常,那麼C在虛擬機器中實際上已經成為了一個有效的類或介面了,但在解析完成之前還要進行符號引用驗證,確認D是否具有對C的訪問許可權。如果發現不具備訪問許可權,則丟擲java.lang.IllegalAccessError異常。

2.欄位解析

首先解析欄位表內class_index項中索引的CONSTANT_Class_info符號引用,也就是欄位所屬的類或介面的符號引用,如果解析完成,將這個欄位所屬的類或介面用C表示,虛擬機器規範要求按照如下步驟對C進行後續欄位的搜尋。
(1)如果C 本身就包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。
(2)否則,如果C中實現了介面,將會按照繼承關係從下往上遞迴搜尋各個介面和它的父介面如果介面中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。
(3)否則,如果C 不是java.lang.Object的話,將會按照繼承關係從下往上遞迴搜尋其父類,如果在父類中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。
(4)否則,查詢失敗,丟擲java.lang.NoSuchFieldError異常。

如果查詢過程成功返回了引用,將會對這個欄位進行許可權驗證,如果發現不具備對欄位的訪問許可權,將丟擲java.lang.IllegalAccessError異常。

如果有一個同名欄位同時出現在C的介面和父類中,或者同時在自己的父類或多個介面中出現,那編譯器可能拒絕編譯,並提示”The field xxx is ambiguous”。

3.類方法解析

首先解析類方法表內class_index項中索引的CONSTANT_Class_info符號引用,也就是方法所屬的類或介面的符號引用,如果解析完成,將這個類方法所屬的類或介面用C表示,虛擬機器規範要求按照如下步驟對C進行後續類方法的搜尋。
(1)類方法和介面方法符號引用的常量型別定義是分開的,如果在類方法表中發現class_index中索引的C 是個介面,那就直接丟擲java.lang.IncompatibleClassChangeError異常。
(2)如果通過了第一步,在類C 中查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
(3)否則,在類C的父類中遞迴查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
(4)否則,在類C實現的介面列表以及他們的父介面中遞迴查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果存在相匹配的方法,說明類C是一個抽象類這時查詢結束,丟擲java.lang.AbstractMethodError異常。
(5)否則,宣告方法查詢失敗,丟擲java.lang.NoSuchMethodError。

最後,如果查詢成功返回了直接引用,將會對這個方法進行許可權驗證,如果發現不具備此方法的訪問許可權,則丟擲java.lang.IllegalAccessError異常。

4.介面方法解析

首先解析介面方法表內class_index項中索引的CONSTANT_Class_info符號引用,也就是方法所屬的類或介面的符號引用,如果解析完成,將這個介面方法所屬的介面用C表示,虛擬機器規範要求按照如下步驟對C進行後續介面方法的搜尋。
(1)與類解析方法不同,如果在介面方法表中發現class_index中的索引C是個類而不是個介面,那就直接丟擲java.lang.IncompatibleClassChangeError異常。
(2)否則,在介面C中查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
(3)否則,在介面C的父介面中遞迴查詢,直到java.lang.Object類(查詢範圍包括Object類)為止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
(4)否則,宣告方法查詢失敗,丟擲java.lang.NoSuchMethodError。

由於介面中所有的方法預設都是public的,所以不存在訪問許可權的問題,因此介面方法的符號解析應當不會丟擲java.lang.IllegalAccessError異常。

初始化

類初始化階段是類載入過程的最後一步,到了這個階段才真正開始執行類中定義的Java程式程式碼(或者說是位元組碼)。在準備階段,變數已經賦過一次系統要求的初始值,而在初始化階段,則根據程式設計師通過程式制定的主觀計劃去初始化類變數和其他資源。
需要注意以下幾點:

1.編譯器收集的順序是由語句在原始檔中出現的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,而定義在它之後的變數,在前面的靜態語句塊可以賦值,但不能訪問,程式碼解釋如下:

public class Test {
    static {
        i = 0;                       //給變數賦值可以正常編譯通過
        System.out.print(i);         //編譯器會提示“非法向前引用”
        }
    static int i = 1;
}

2.初始化方法執行的順序,虛擬機器會保證在子類的初始化方法執行之前,父類的初始化方法已經執行完畢,因此在虛擬機器中第一個被執行的類初始化方法一定是java.lang.Object。另外,也意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作,例如:

static class Parent {
    public static int A = 1;
    static {
        A = 2;
        }
    }

static class Sub extends Parent {
    public static int B = A;
    }

public static void main(String[] args) {
    System.out.println(Sub.B);
    }

執行的結果。欄位B的值將會是2而不是1。

3.clinit ()方法對於類或介面來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成clinit()方法。

4.介面中不能使用靜態語句塊,但仍然有變數初始化的操作,因此介面與類一樣都會生成clinit()方法,但與類不同的是,執行介面的初始化方法之前,不需要先執行父介面的初始化方法。只有當父介面中定義的變數使用時,才會執行父介面的初始化方法。另外,介面的實現類在初始化時也一樣不會執行介面的clinit()方法。

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