【JVM】JVM系列之類載入機制(四)

leesf發表於2016-03-12

一、前言

  前面分析了class檔案具體含義,接著需要將class檔案載入到虛擬機器中,這個過程是怎樣的呢,下面,我們來仔細分析。

二、什麼是類載入機制

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

三、類載入總體流程圖

  

  說明:類的整個生命週期分為以上七個階段,驗證、準備、解析統稱為連線階段。關於載入流程筆者之前也寫過一篇文章JVM之類載入器,從程式碼層面瞭解類載入機制。下面我們將更加詳細的講解各個階段,載入階段只是類載入的一個步驟,類載入包括以上的七個步驟。

四、何時進行類載入

  加虛擬機器規範規定了如下幾種情況就必須要進行初始化(開始類載入)。

  1.  遇到new、getstatic、putstatic、invokestatic指令時,對應到程式中就是使用到new例項化物件時、讀取或設定類靜態欄位時(非final)、呼叫靜態方法時。需要進行初始化。

  2. 使用java.lang.reflect包的方法對類進行反射呼叫時,需要進行初始化。

  3. 使用一個類時,若其父類還未初始化,則需先初始化其父類。

  4. 虛擬機器啟動時,包含main方法的類,虛擬機器會將其初始化。

  5. java.lang.invoke.MethodHandle例項最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic方法控制程式碼,並且這個方法控制程式碼對應的類沒有進行初始化,則需要先進行初始化。

  以上五種情況稱為主動使用,其他的情況均稱為被動使用,被動使用不會導致初始化。

五、初始化示例說明

  1. 對於類而言,使用父類的靜態欄位(非final)不會導致子類的初始化。  

class SuperClass {
    static {
        System.out.println("super");
    }
    
    public static final int value = 123;
}

class SubClass extends SuperClass {
    static {
        System.out.println("sub");
    }
}

public class TestInit {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}
View Code

  結果:

  super
  123

  說明:並沒有初始化子類,雖然使用SubClass.value,但實際使用的是子類繼承父類的靜態欄位,不會初始化SubClass。即只有直接定義了這個欄位的類才會被初始化。

  2. 對於類或介面而言,使用其常量欄位(final、static)不會導致其初始化。 

class SuperClass {
    static {
        System.out.println("super");
    }
    
    public final static int value = 123;
}

public class TestInit {
    public static void main(String[] args) {
        System.out.println(SuperClass.value);
    }
}
View Code

  結果:

  123

  說明:使用常量並不會導致類或介面的常量並不會導致類或介面的初始化。因為常量在編譯時進行優化,直接嵌入在TestInit.class檔案的位元組碼中。

  3. 對於類而言,初始化子類會導致父類(不包括介面)的初始化。  

class SuperClass {
    static {
        System.out.println("super");
    }
    
    public final static int value = 123;
}

class SubClass extends SuperClass {
    public static int i = 3;
    static {
        System.out.println("sub");
    }
}
public class TestInit {
    public static void main(String[] args) {
        System.out.println(SubClass.i);
    }
}
View Code

  結果:

  super
  sub
  3

  說明:初始化子類會導致父類的初始化,並且父類的初始化在子類初始化的前面。

  4. 對於介面而言,初始化子介面不會導致父介面的初始化,只有在真正使用到父介面的時候(如使用父介面中定義的常量),才會初始化。

六、載入

  載入階段虛擬機器需要完成如下事情。

  1. 通過類的全限定名來獲取此類的二進位制位元組流。

  2. 將二進位制位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。

  3. 建立一個代表帶該的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。

  使用者可以自定義類載入器(重寫loadClass方法)獲取二進位制位元組流。對於陣列類而言,陣列類由java虛擬機器直接建立,不通過類載入器建立。陣列類的建立過程如下:

  ①如果陣列元素型別是引用型別,就採用雙親委派模型進行載入(之後會介紹),陣列類將在載入該元素型別的類名稱空間上被標識。

  ②如果陣列元素型別為基本型別,陣列類被標記為與引導類載入器關聯。

  ③陣列類的可見性與其元素型別可見性一致,如果元素型別不是引用型別,那陣列類的可見性預設為public。

  建立的Class物件在方法區中。物件絕大多數放在堆中,Class物件是一個例外。

七、連線

  7.1 驗證

  此階段是確保class檔案的位元組流包含的資訊符合虛擬機器的要求。主要會進行如下的驗證。

  1. 檔案格式驗證

  驗證位元組流是否符合Class檔案格式規範。如是否以魔數開頭、主次版本號是否能被虛擬機器處理、常量池的常量中是否有不被支援的常量型別等等。

  2. 後設資料驗證

  對位元組碼描述的資訊進行語義分析。如這個類是否有父類、這個類是否繼承了不允許被繼承的類、非抽象類是否實現了父類或介面中要求實現的所有方法等等。

  3. 位元組碼驗證

  分析資料流和控制流,確定程式語義是否合法,符合邏輯。如任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作、保證跳轉指令不會跳轉到方法體以外的位元組碼指令上等等。

  4. 符號引用驗證

  符號引用轉化為直接引用的時候進行驗證。如符號引用中通過字串描述的全限定名是否能夠找到對應的類、指定類中是否存在符合方法的欄位描述符以及簡單名稱所描述的方法和欄位等等。

  7.2 準備

  為類變數分配記憶體併為類變數設定系統初始值的階段,這些變數所使用的記憶體都將在方法區進行分配。靜態欄位(非final)會被賦予系統預設值,而對於常量欄位(final、static),在準備階段直接賦予使用者設定的值。

  7.3 解析

  將常量池中的符號引用(包括類或介面的全限定名、欄位名和描述符、方法名和描述符)替換為直接引用(記憶體地址)。符號引用與最終class檔案載入記憶體的佈局無關,直接引用與記憶體的佈局有關。為了加快解析效率,可以對解析結果進行快取,之後再解析符號引用時直接返回即可,但是對於invokedynamic則不能進行快取。解析主要是針對CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_InvokeDynamic_info七種常量型別。

  指向型別、類變數、類方法的直接引用可能是指向方法區的本地指標。型別的直接引用可能簡單地指向儲存型別資料的方法區中的與實現相關的資料結構。類變數的直接引用可以指向方法區中儲存的類變數的值。類方法的直接引用可以指向方法區中的一段資料結構方法區中包含呼叫方法的必要資料。

  指向例項變數和例項方法的直接引用都是偏移量。例項變數的直接引用可能是從物件的映像開始算起到這個例項變數位置的偏移量。例項方法的直接引用可能是到方法表的偏移量。

  1. 類或介面的解析

  將符號引用替換為直接引用包括如下幾步。假設符號引用記為S,當前類記為C,S對應的類或介面記為I。

  ① 若S不是陣列型別,則把S傳遞給當前類C的類載入器進行載入,這個過程可能會觸發其他的載入,這個過程一旦出現異常,則解析失敗。

  ② 若S是陣列型別,並且陣列元素型別為物件,則S的描述符會形如[java/lang/String,按照第一條去載入該型別,如果S的描述符符合,則需要載入的型別就是java.lang.String,接著有虛擬機器生成一個代表此陣列唯獨和元素的陣列物件。

  ③ 若以上兩個步驟沒有出現異常,即I已經存在於記憶體中了,但是解析完成時還需要進行符號引用驗證,確認C是否具備對I的訪問許可權。若不具備,則丟擲java.lang.IllegalAccessError異常。

  2. 欄位解析

  首先將CONSTANT_Fieldref_info中的class_index索引的CONSTANT_Class_info符號引用進行解析,即解析欄位所在類或介面,若解析出現異常,則欄位解析失敗。如解析成功,則進行下面的解析步驟。假設該欄位所屬的類或介面標記為C。

  ① 如果C包含了欄位的簡單名和描述符與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。

  ② 否則,如果C實現了介面,按照繼承關係從下往上遞迴搜尋各個介面和它的父介面,看是否存在相匹配的欄位。存在,則返回直接引用,查詢結束。

  ③ 否則,如果C不是Object物件,按照繼承關係從下往上遞迴搜尋父類,看是否存在相匹配的欄位。存在,則返回直接引用,查詢結束。

  ④ 否則,查詢失敗,丟擲java.lang.NoSuchFieldError異常。

  說明:欄位解析對介面優先搜尋。

  3. 類方法解析

  首先將CONSTANT_Methodref_info中的class_index索引的CONSTANT_Class_info符號引用進行解析,即解析方法所在的類或介面,若解析出現異常,則方法解析失敗;如解析成功,則進行下面解析步驟。假設該方法所屬的類標記為C。

  ① 如果在方法表中發現CONSTANT_Class_info中索引的C是一個介面而不是一個類,則丟擲java.lang.IncompatibleClassChangeError異常。

  ② 否則,如果C中包含了方法的簡單名和描述符與目標相匹配的欄位,則返回這個方法的直接引用,查詢結束。

  ③ 否則,在C的父類中遞迴搜尋,看是否存在相匹配的方法,存在,則返回直接引用,查詢結束。

  ④ 否則,在C實現的介面列表及父介面中遞迴搜尋,看是否存在相匹配的方法,存在,說明C是一個抽象類(沒有實現該方法,否則,在第一步就查詢成功),丟擲java.lang.AbstractMethodError異常。

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

  ⑥ 若查詢過程成功,則對方法進行許可權驗證,如果發現不具備對此方法的訪問許可權,則丟擲java.lang.lllegalAccessError異常。

  說明:方法解析對父類優先搜尋。

  4. 介面方法解析

  首先將CONSTANT_InterfaceMethodref_info中的class_index索引的CONSTANT_Class_info符號引用進行解析,即解析方法所在的類或介面,若解析出現異常,則方法解析失敗;如解析成功,則進行下面解析步驟。假設該方法所屬的類標記為C。

  ① 如果在方法表中發現CONSTANT_Class_info中索引的C是一個類而不是介面,則丟擲java.lang.IncompatibleClassChangeError異常。

  ② 否則,如果C中包含了方法的簡單名和描述符與目標相匹配的欄位,則返回這個方法的直接引用,查詢結束。

  ③ 否則,在C的父介面中遞迴搜尋,直到Object類,看是否存在相匹配的方法,存在,則返回直接引用,查詢結束。

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

  ⑤ 若查詢過程成功,不需要進行許可權驗證,因為介面方法為public,不會丟擲java.lang.IllegalAccessError異常。

八、初始化

  在此階段,才開始真正執行使用者自定的java程式碼。在準備階段,類變數已經被賦予了系統預設值,而在初始化階段,會賦予使用者自定義的值。而初始化階段是在<clinit>()方法中執行的。

  8.1 <clinit>()方法

  ① 由編譯器收集類中的所有類變數的賦值動作(如果僅僅只是宣告,不會被收集)和靜態語句塊中的語句合併產生的,收集順序按照語句在原始檔中出現的順序所決定;在靜態語句塊中只能訪問定義在靜態語句之前的變數;而對於定義在靜態語句塊之後的變數,可以進行賦值,但是不能夠訪問。

  ② 不需要顯示呼叫父類構造器,虛擬機器會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢,所以,第一個被執行的<clinit>()方法的類肯定是java.lang.Object。

  ③ 父類中定義的靜態語句塊優先於子類的靜態語句。

  ④ 此方法對類和介面都不是必須的,若類中沒有靜態語句塊和靜態變數賦值操作,則不會生成<clinit>()方法。

  ⑤ 介面會生成此方法,因為對介面的欄位可以進行賦值操作。執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法,只有在使用父介面的變數時,才會進行初始化;介面的實現類在初始化時也不會執行介面的<clinit>()方法。

  ⑥ 此方法在多執行緒環境中會被正確的加鎖、同步。

九、類載入器

  類載入器用於載入類,任何類都需要由載入它的類載入器和這個類一同確立其在Java虛擬機器中的唯一性,每一個類載入器,都有一個獨立的類名稱空間,由不同類載入的類不可能相等。

  9.1. 雙親委派模型

  從虛擬機器角度看,只存在兩種類載入器:1. 啟動類載入器。2. 其他類載入器。從開發人員角度看,包括如下類載入器:1. 啟動類載入器。2. 擴充套件類載入器。3. 應用程式類載入器。4. 自定義類載入器。

  ① 啟動類載入器,用於載入Java API,載入<JAVA_HOME>\lib目錄下的類庫。

  ② 擴充套件類載入類,由sun.misc.Launcher$ExtClassLoader實現,用於載入<JAVA_HOME>\lib\ext目錄下或者被java.ext.dirs系統變數指定路徑下的類庫。

  ③ 應用程式類載入器,也成為系統類載入器,由sun.misc.Launcher$AppClassLoader實現,用於載入使用者類路徑(ClassPath)上所指定的類庫。

  ④ 自定義類載入器,繼承系統類載入器,實現使用者自定義載入邏輯。

  各個類載入器之間是組合關係,並非繼承關係。

  當一個類載入器收到類載入的請求,它將這個載入請求委派給父類載入器進行載入,每一層載入器都是如此,最終,所有的請求都會傳送到啟動類載入器中。只有當父類載入器自己無法完成載入請求時,子類載入器才會嘗試自己載入。

  雙親委派模型可以確保安全性,可以保證所有的Java類庫都是由啟動類載入器載入。如使用者編寫的java.lang.Object,載入請求傳遞到啟動類載入器,啟動類載入的是系統中的Object物件,而使用者編寫的java.lang.Object不會被載入。如使用者編寫的java.lang.virus類,載入請求傳遞到啟動類載入器,啟動類載入器發現virus類並不是核心Java類,無法進行載入,將會由具體的子類載入器進行載入,而經過不同載入器進行載入的類是無法訪問彼此的。由不同載入器載入的類處於不同的執行時包。所有的訪問許可權都是基於同一個執行時包而言的。 

十、使用

  完成了初始化階段後,我們就可以使用物件了,在程式中可以隨意進行訪問,只要類還沒有被解除安裝。

十一、解除安裝

  對型別進行解除安裝,在之前的垃圾回收中我們已經講解了如何才能對型別進行解除安裝,即回收操作。啟動類載入的型別永遠是可觸及的,回收的是由使用者自定義加載入器載入的類。

十二、總結

  對於類載入機制的講解就到這裡了,相信經過本篇學習對於類載入機制有了更深刻的理解。謝謝各位園友觀看~

  

 

  

  

相關文章