1. 程式人生 > >JVM十:虛擬機器類載入機制(1)

JVM十:虛擬機器類載入機制(1)

      虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別,這就是虛擬機器的類載入機制。

Java語言中,型別的載入,連線和初始化過程都市在程式執行期間完成,這種策略雖然會令類載入時稍微增加一些效能開銷,但是會為Java應用程式提供高度的靈活性,Java裡天生可以動態擴充套件的語言特性就是依賴執行期動態載入和動態連線這個特點實現的。

一:類載入的時機

類從被載入到虛擬機器記憶體中開除,到卸載出記憶體為止,它的整個生命週期包括:載入(Loading),驗證(Verfication),準備(Preparation),解析(Resolution),初始化(Initialization),使用(Using)和解除安裝(Unloading)

7個階段。

那什麼情況下需要開始類載入過程的第一階段:載入?Java虛擬機器中並沒有進行強制約束,而由Java虛擬機器的具體實現來自由把握但是對於初始化階段,虛擬機器則是嚴格規定了有且只有5種情況必須立即對類進行“初始化”(而載入驗證準備自然而然要在此之前開始)

主動引用進行類初始化的情況

①遇到new、讀取一個類的靜態欄位(getstatic)、設定一個類的靜態欄位(putstatic)、呼叫一個類的靜態方法(invokestatic)。

②使用java.lang.reflect包的方法對類進行反射呼叫時,如果沒有進行初始化則需要先觸發初始化。

③當類初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。(如果是介面,則不必觸發其父類初始化)。

④當虛擬機器執行一個main方法時,會首先初始化main所在的這個主類。

⑤當只用jdk1.7的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法控制代碼,並且這個方法控制代碼所對應的類沒有進行過初始化,則需要先觸發其初始化。

注意:下面情況為被動引用:

①通過子類引用父類的靜態欄位,不會導致子類的初始化。

②通過陣列定義來引用類,不會觸發此類的初始化。

③常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量類的初始化。(原因是將常量的值儲存到了NoInitialization類)的常量池中,以後對常量的引用都轉換為ConstClass.HELLOWORLD的引用實際都被轉化為NoInitialization常量池對常量的引用,與類無關係。

下面我們就來具體的介紹一下類的載入過程的幾個階段:

一:載入(生成引用)

在載入階段,虛擬機器需要完成以下3件事

1)通過一個類的全限定名獲取定義此類的二進位制位元組流。

2)將這個位元組流所代表的靜態儲存結構轉化到方法區的執行時資料結構。

3)在記憶體中生成一個代表這個類的java.lang.class物件,作為方法區這個類的各種訪問入口(引用)。

獲取位元組流(class檔案)的方式很自由,有以下方式

一:從ZIP包中讀取,這很常見,最終成為日後JAR,EAR,WAR格式的基礎

二:從網路中獲取,這種場景最典型的應用就是Applet

三:執行時計算生成,這種場景使用的最多的就是動態代理。

四:由其它檔案生成,典型場景就是JSP應用,由jsp檔案生成對應的class類。

五:從資料庫獲取,這種比較少見

在載入階段要注意以下陣列型別和非陣列型別的載入過程,因為他們的載入方式是不同的。

非陣列類的載入:

可以使用系統提供的引導類載入器來完成,也可以由使用者自定義的類載入器去完成,開發人員可以通過定義自己的類載入器去控制位元組流的獲取方式(重寫類載入器的loadClass()方法)

陣列類的載入:

陣列類本身不通過類載入器建立,由Java虛擬機器直接建立。但陣列類與類載入器仍然有很密切的關係,因為陣列類的元素型別(Element Type,陣列去掉所有維度的型別,即:陣列中儲存的資料型別)最終要靠類載入器去建立。

陣列類的建立過程遵循以下規則:

  • 如果陣列的元件型別(Component Type,指陣列去掉一個維度的型別)是引用型別,那就遞迴採用上面定義的載入過程去載入這個元件型別,陣列將在該類載入器的類名稱空間上被標識。
  • 如果陣列的元件型別不是引用型別(如int[]陣列),Java虛擬機器將會把陣列標記為與引導類載入器關聯。
  • 陣列類的可見性與它的元件型別的可見性一致,如果元件型別不是引用型別,那陣列類的可見性將預設為public。

載入階段完成後,虛擬機器外部的二進位制位元組流就按照虛擬機器所需的格式儲存在方法區之中,方法區中的資料儲存格式由虛擬機器實現自行定義。然後在記憶體中例項化一個java.lang.Class類的物件(雖然是物件,但是存放在方法區裡面),作為程式訪問方法區中的這些型別資料的外部介面。

載入階段與連線階段的部分內容是交叉進行的,載入沒完成,連線階段可能已經開始。但是兩個階段的開始時間仍然保持著固定的先後順序。

二:驗證

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

下面是驗證的幾個內容,當然實際驗證的時候會更嚴格。。。。。。。。

1.檔案格式驗證

驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。

  • 魔數是否以0xCAFEBABE開頭
  • 主,次版本號是否在當前虛擬機器處理範圍之內
  • 常量池的常量中是否有不被支援的常量型別
  • 指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的資料
  • Class檔案中各個部分及檔案本身是否有被刪除的或附加的其他資訊

通過了這個階段的驗證後,位元組流才會進入記憶體的方法區中進行儲存。後面就會基於方法區的儲存結構進行驗證,不直接操作位元組流了。

2.元資料驗證

  • 類是否有父類(除了java.lang.Object外,所有類都應當有父類)
  • 類的父類是否繼承了不允許被繼承的類(final修飾的類)
  • 如果類不是抽象類,是否實現了器父類或介面中要求實現的所有方法
  • 類中的欄位,方法是否與父類產生矛盾(如:覆蓋了父類的final欄位)

3.位元組碼驗證

是整個驗證過程中最複雜的一個階段,主要目的是通過資料流和控制流分析,確定程式語義是合法的。對類的方法體進行校驗分析,保證被校驗類的方法在執行時不會做出危害虛擬機器安全的事件,如:

  • 保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作,不會出現例如:在操作棧放置了一個int型別,使用時卻按long型別來載入入本地變量表中。
  • 保證跳轉指令不會跳轉到方法體以外的位元組碼指令上
  • 保證方法體中的型別轉換是有效的。

4.符號引用驗證

   在虛擬機器將符號引用轉化為直接引用時發生,轉化動作在--解析階段發生。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗

  • 符號引用中通過字串描述的全限定名是否能找到對應的類。
  • 在指定類中是否存在符合方法的欄位描述以及簡單名稱所描述的方法和欄位。
  • 符號引用中的類,欄位,方法的訪問性(private,protected,public,default)是否可被當前類訪問。

符號引用驗證的目的就是確保解析動作能正常執行,如果無法通過就會丟擲異常,如:java.lang.NoSuchMethodError

       驗證階段是非常重要的,但不是一定必要的階段(對程式執行期沒有影響),如果執行的全部程式碼都被反覆使用和驗證過,那麼在實施階段可以考慮通過引數-Xverify:none 來關閉類驗證措施,以縮短虛擬機器類載入的時間。

三:準備

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段。會設定static的常量的初始值而不會static或final就不會。

只對類變數進行記憶體分配(static修飾),不包括例項變數。

初始值為資料型別的零值,如:

public static int  value = 123;

準備階段後為0,在類初始化後value才被賦值為123。

特殊情況:

public static final int value = 123;

編譯時Javac將會為value生成ConstantValue屬性,在準備階段就會根據ConstantValue將value值設定為123。

四:解析

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

  1. 符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到記憶體中。符號引用的字面量形式已經明確定義在Java虛擬機器規範的Class檔案格式中。
  2. 直接引用(Direct References):直接引用可以是直接指向目標的指標,相對偏移量或是一個能間接定位到目標的控制代碼。直接引用與虛擬機器實現的記憶體佈局相關,同一個符號引用在不用虛擬機器例項上翻譯出來的直接引用一般不同。如果有了直接引用,那引用的目標必定已經在記憶體中存在。

類或介面解析

要把一個類或者介面的符號引用解析為直接引用,需要以下三個步驟:

1. 如果該符號引用不是一個數組型別,那麼虛擬機器將會把該符號代表的全限定名稱傳遞給類載入器去載入這個類。這個過程由於涉及驗證過程所以可能會觸發其他相關類的載入

2. 如果該符號引用是一個數組型別,並且該陣列的元素型別是物件。我們知道符號引用是存在方法區的常量池中的,該符號引用的描述符會類似”[java/lang/Integer”的形式,將會按照上面的規則進行載入陣列元素型別,如果描述符如前面假設的形式,需要載入的元素型別就是java.lang.Integer ,接著由虛擬機器將會生成一個代表此陣列物件的直接引用

3. 如果上面的步驟都沒有出現異常,那麼該符號引用已經在虛擬機器中產生了一個直接引用,但是在解析完成之前需要對符號引用進行驗證,主要是確認當前呼叫這個符號引用的類是否具有訪問許可權,如果沒有訪問許可權將丟擲java.lang.IllegalAccess異常

欄位解析

對欄位的解析需要首先對其所屬的類進行解析,因為欄位是屬於類的,只有在正確解析得到其類的正確的直接引用才能繼續對欄位的解析。對欄位的解析主要包括以下幾個步驟:

1. 如果該欄位符號引用就包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,解析結束

2. 否則,如果在該符號的類實現了介面,將會按照繼承關係從下往上遞迴搜尋各個介面和它的父介面,如果在介面中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,那麼久直接返回這個欄位的直接引用,解析結束

3. 否則,如果該符號所在的類不是Object類的話,將會按照繼承關係從下往上遞迴搜尋其父類,如果在父類中包含了簡單名稱和欄位描述符都相匹配的欄位,那麼直接返回這個欄位的直接引用,解析結束

4. 否則,解析失敗,丟擲java.lang.NoSuchFieldError異常

類方法解析

進行類方法的解析仍然需要先解析此類方法的類,在正確解析之後需要進行如下的步驟:

1. 類方法和介面方法的符號引用是分開的,所以如果在類方法表中發現class_index(類中方法的符號引用)的索引是一個介面,那麼會丟擲java.lang.IncompatibleClassChangeError的異常

2. 如果class_index的索引確實是一個類,那麼在該類中查詢是否有簡單名稱和描述符都與目標欄位相匹配的方法,如果有的話就返回這個方法的直接引用,查詢結束

3. 否則,在該類的父類中遞迴查詢是否具有簡單名稱和描述符都與目標欄位相匹配的欄位,如果有,則直接返回這個欄位的直接引用,查詢結束

4. 否則,在這個類的介面以及它的父介面中遞迴查詢,如果找到的話就說明這個方法是一個抽象類,查詢結束,返回java.lang.AbstractMethodError異常

5. 否則,查詢失敗,丟擲java.lang.NoSuchMethodError異常

如果最終返回了直接引用,還需要對該符號引用進行許可權驗證,如果沒有訪問許可權,就丟擲java.lang.IllegalAccessError異常

介面方法解析

同類方法解析一樣,也需要先解析出該方法的類或者介面的符號引用,如果解析成功,就進行下面的解析工作:

1. 如果在介面方法表中發現class_index的索引是一個類而不是一個介面,那麼也會丟擲java.lang.IncompatibleClassChangeError的異常(這是介面不能例項化的根本原因

2. 否則,在該介面方法的所屬的介面中查詢是否具有簡單名稱和描述符都與目標欄位相匹配的方法,如果有的話就直接返回這個方法的直接引用。

3. 否則,在該介面以及其父介面中查詢,直到Object類,如果找到則直接返回這個方法的直接引用

4. 否則,查詢失敗

介面的所有方法都是public,所以不存在訪問許可權問題

六:初始化

類初始化階段是類載入過程的最後一步,到了這個階段才真正開始執行類中定義的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()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行類初始化方法完畢。