虛擬機器載入機制——類載入的過程
文章目錄
上一節虛擬機器載入機制——類載入時機中我們提到類從被虛擬機器載入到記憶體中到從記憶體中解除安裝的生命週期。這一節我們來具體談一下生命週期裡面的幾個階段載入、連線(驗證、準備、解析)、初始化、使用、解除安裝。
一、載入
在載入階段需要進行三個步驟:
- 通過一個類的全限定名獲取類的二進位制位元組流。
- 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
- 在方法區生成這個類的java.lang.Class物件,作為這個類的各種資料的訪問入口。
獲取二進位制流可以從磁碟中獲取,也可以從網路上,zip包等獲取。
對於非陣列類
二、連線之驗證
驗證階段是為了保證Class檔案的位元組流是滿足當前虛擬機器要求的,不危害虛擬機器自身安全的。
《java虛擬機器規範》定義了驗證的規則,大體可分為檔案格式驗證、元資料驗證、位元組碼驗證、符號引用驗證四個階段。
檔案格式驗證:
驗證Class檔案位元組流是否符合Class檔案格式,並且是否能被當前版本虛擬機器處理。比如驗證魔術是否為0xCAFFBABE開頭、版本號是否在當前虛擬機器處理範圍之內等。
元資料驗證:
對位元組碼描述的資訊進行語義分析,是否符合java語言規範。如驗證這個類是否有父類,這個類是否繼承了不被允許繼承的類(final修飾的類)
位元組碼驗證:
對資料流和控制流(方法體)分析,確保程式語義是合法的、符合邏輯的。
符號引用驗證:
這個驗證發生在連線的第三階段解析,將符號引用轉換為直接引用。該驗證可以看成是對類自身以為(常量池中的各種符號引用)的資訊進行匹配性驗證。
三、連線之準備
準備階段:
準備階段是正式為類變數(用static修飾)分配記憶體空間和初始化(初始化為相應的零值)的階段。分配空間是分配在方法區的。
需要注意的是,一般情況下類變數在這個階段初始化為零值。如下:
class A{
//在準備階段,a初始化為0而不是3
public static int a=3;
}
這個階段初始化a的值為0,而不是3。因為這個階段尚未執行java程式碼。把a賦值為3是在後面的初始化階段執行的。
然而,特殊情況,是常量。也就是類欄位的欄位屬性表中存在ConstantValue時,那麼a就被初始化為ConstantValue指定那個值。如下:
class A{
//在準備階段,a初始化為ConstantValue指定的3.
public static final int a=3;
}
四、連線之解析
解析階段虛擬機器將常量池中符號引用轉換直接引用的階段。
4.1 準備工作
符號引用:
符號引用用一組符號描述所引用的目標,符號可以是任何形式的字面量,只要使用時能夠無歧義的定位目標就行。符號引用於記憶體佈局無關,引用目標不一定在記憶體中。
直接引用:
直接引用可以是直接指向引用目標的指標、相對偏移量或是一個能夠間接指向目標的控制代碼。直接引用於虛擬機器的記憶體佈局有關。如果有了直接引用,那麼引用目標就一定存在於記憶體中。
就好比學生和教室。
符號引用就是學生(引用目標)的姓名,而此時學生在不在教室(記憶體)裡面是不確定的、無關的。而直接引用就是學生坐在教室的那個位置,既然已經知道了學生坐在教室那個位置,那麼學生就一定在教室(記憶體)裡面了。
解析階段時間
《java虛擬機器規範》並沒有指定解析的具體時間。只要求了在anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeiterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multanewarray、new、putfield、putstatic這16個操作符號引用的位元組碼指令之前,先對符號引用進行解析就行。
解析結果快取及特例
對一個符號引用請求多次解析是常有的事情。除了invokedynamic指令外,java虛擬機器提供了快取機制對解析的結果進行快取,避免多次重複解析符號引用。invokedynamic是用來支援動態語言的,“動態”是指等到程式執行到這條指令時,解析才開始。
4.2 類或介面解析
首先我們檢視常量池中的類和介面的符號引用的表結構:
其中u1用來表示該常量是屬於哪一類常量(類和介面的全限定名、自動名稱和描述符、方法名稱和描述符)。u2指向常量池中類或介面的全限定名。
現在我們來開始類或介面的解析,首先假設當前所處的類叫做D,如果要把一個未經解析的符號引用N解析一個類或介面C的直接引用,要經過一下三步:
- 如果C不是一個數組型別,那麼虛擬機器將會把符號引用N的全限定名(u2指向的常量池中的全限定民)傳遞給D的類載入器區載入。由於驗證的需要,有可能會觸發其他相關類的載入動作(如C的父類)。一旦這個步驟有問題,宣告解析失敗。
- 如果C是一個數組型別,並且陣列的元素型別是物件,那麼就會第一步的規則載入元素型別。載入元素型別成功後,由虛擬機器生成一個代表次陣列維度和元素的陣列物件。
- 如果上面步驟沒有出現問題,那麼C已經在虛擬機器內成為一個有效的類或介面了。在解析完成之前,還有驗證D對C的訪問許可權,如果D不具備訪問許可權,就會丟擲java.lang.IllegalAccessError。
4.3 欄位解析
首先也是欄位在常量池的的結構表:
tag,用於表明該常量型別,第一個index表示該欄位所屬的類或介面,第二個index表示當前欄位的描述符。
現在開始欄位解析,首先進行欄位所屬的類或介面解析。如果失敗,導致欄位符號引用解析失敗;如若成功,那麼將欄位所屬的類或介面用C表示,然後進行如下步驟:
- 如果C本身就存在簡單名稱和欄位描述符和目標欄位匹配(第二個index)的欄位,那麼就返回這個欄位的直接引用。欄位查詢結束。
- 否則,如果C實現了介面,將會按照繼承關係,從下往上遞迴查詢介面及其父介面是否存在簡單名稱和欄位描述符與目標欄位匹配的欄位,如果存在就返回這個欄位的直接引用。欄位查詢結束。
- 否則,如果C不是java.lang.Object的話,虛擬機器將會從下往上遞迴查詢父類中是否存在簡單名稱和欄位描述符與目標欄位匹配的欄位,如果存在就返回這個欄位的直接引用。欄位查詢結束。
- 否則,查詢失敗,丟擲java.lang.noSuchFiledError。
在查詢成功之後,還要對欄位的訪問許可權進行驗證,當發現C並不具有訪問許可權,將丟擲java.lang.IllegalAccessError。
4.4 類方法解析
首先,我們看類方法和介面方法在常量池裡面的結構:
tag 表示常量的型別,CONSTANT_Methodref_ref的第一個index表示方法的所屬的類索引,第二個index表示當前方法描述符。
和欄位解析一樣,首先進行方法所屬的類或介面解析。如果失敗,導致方法符號引用解析失敗;如若成功,那麼將方法所屬的類或介面用C表示,然後進行如下步驟:
- 類方法和介面方法符號引用的常量型別定義是分開的,如果類方法表中發現class_index(第一個index)中索引的C是一個介面,將丟擲java.lang.incompatibleClassChangeError。
- 如果通過第一步,就在類C中查詢是否有簡單名稱和描述符和目標相匹配。如果有,則返回這個方法的直接引用。查詢結束。
- 否則,在類C的父類中遞迴查詢是否有簡單名稱和描述符和目標方法相匹配的。如果有,則返回該方法的直接引用。查詢結束。
- 否則,在類C的介面實現列表及其符介面中遞迴查詢簡單名稱和描述符與目標方法相匹配的方法。如果存在,說明C是一個抽象類。查詢結束,丟擲java.lang.abstractMethodError。
- 否則,宣告查詢失敗,丟擲java.lang.NoSuchMethodError。
最後,如果返回方法的直接引用,還要驗證C對方法的訪問許可權,如果C對方法不具備訪問許可權,將會丟擲java.lang.IllegalAccessError。
4.5 介面方法解析
首先我們看介面方法在在常量池裡面的型別結構:
tag表示常量型別,第一個index表示方法所屬的介面描述符的索引,第二個index表示方法的名稱及型別描述符的索引。
首先還是要對介面方法所屬的類或介面進行解析。如果解析失敗,說明介面方法解析失敗。如若成功,我們任然用C表示介面方法所屬的類或介面,進行以下步驟:
- 如果發現C表示的是一個類,將丟擲java.lang.incompatibleClassChangeError。
- 否則,在介面C及其父介面中遞迴查詢(直到java.lang.Object,包括java.lang.Object)是否有簡單名稱和描述符與目標項匹配的方法,如果有,則直接返回方法的直接引用,查詢結束。
- 否則,宣告查詢失敗。丟擲java.lang.NoSuchMethodError。
由於介面中方法預設是public,不存在許可權訪問問題。因此不會丟擲java.lang.IllegalAccessError。
五、初始化
5.1 準備階段的初始化與初始化階段的初始化
準備階段的初始化只是按系統要求給類變數(不包括用final修飾的類變數)初始化為零值。而初始化階段的初始化是根據程式設計師的意願進行初始化的。
5.2 clinit()的產生
<clinit>()是由編譯器自動收集類中的類變數的賦值語句和static語句塊合併產生的。編譯器收集的順序是按程式碼出現的順序進行的。
5.3 clinit()與類的建構函式(例項建構函式init())的區別
與<init>()方法不同,<clinit>()不用顯示呼叫父類的<clinit>(),然而<init>需要。虛擬機器保證了在呼叫<clinit>()之前,其父類的<clinit>()已經被呼叫。因此虛擬機器第一個被執行的<clinit>()是java.lang.Object的<clinit>()。
5.4 非法向前引用和clinit()執行順序例子。
非法向前引用(Illegal forward reference):靜態語句塊只能訪問定義在它前面的類別量(包括用fianl修飾的),而不能訪問在它後面定義的類變數(雖然不能訪問,但可以賦值喲!!!!)。
class subClass extends superClass { static { s=6;//闊以,不報錯!!! System.out.println(s);//報錯,Illegal forward reference!!! } public static int s = 3; }
由於父類的<clinit>()方法優先於子類執行,意味著父類的靜態程式碼塊的執行要早於子類的類變數賦值語句。例如
public class Main {
public static void main(String[] args) {
System.out.println(subClass.b);//輸出結果為6而不是3
}
}
class superClass {
//父類的靜態程式碼塊的執行要早於子類的類變數賦值語句。
public static int a=3;
static {
a=6;
}
}
class subClass extends superClass {
public static int b = a;
}
5.5 clinit()並不是必須的。
<clinit>()並不是必須的,只要類中沒有靜態程式碼塊,也沒有類變數的賦值語句。那麼編譯器收集時並不產生<clinit>()。
5.6 介面和類的clinit()區別。
剛才說了,父類的<clinit>()早於子類呼叫。然而,呼叫介面的<clinit>()並不需要先呼叫父介面的<clinit>()。只有父介面的定義的變數被使用時,才呼叫父介面的<clinit>()。
5.7 多執行緒情況下的clinit()。
當有多個執行緒呼叫<clinit>()時,虛擬機器會給<clinit>()進行加鎖同步,保證只有一個執行緒執行<clinit>(),其他執行緒阻塞。需要注意的是,當使用<clinit>()的執行緒執行完之後,其他阻塞的執行緒並不會再次執行<clinit>()了。因為同一個類載入器,一個類只會被載入一次。
下面是個例子:
public class Main {
public static void main(String[] args) {
Runnable target=new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread()+"start");
DeadLoopClass d=new DeadLoopClass();
System.out.println(Thread.currentThread()+"end");
}
};
Thread a=new Thread(target);
Thread b=new Thread(target);
a.start();
b.start();
}
}
class DeadLoopClass {
static {
System.out.println(Thread.currentThread()+"clinit!!!");
if (true) {
while (true) {
}
}
}
}
執行結果: