深刻理解JVM類加載機制

簡述:虛擬機把描述類的數據從class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終造成能夠被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。java

下面咱們具體來看類加載的過程:數組

類的生命週期
類的生命週期

類從被加載到內存中開始,到卸載出內存,經歷了加載、鏈接、初始化、使用四個階段,其中鏈接又包含了驗證、準備、解析三個步驟。這些步驟整體上是按照圖中順序進行的,可是Java語言自己支持運行時綁定,因此解析階段也能夠是在初始化以後進行的。以上順序都只是說開始的順序,實際過程當中是交叉進行的,加載過程當中可能就已經開始驗證了。緩存

類加載的時機

首先要知道何時類須要被加載,Java虛擬機規範並無約束這一點,可是卻規定了類必須進行初始化的5種狀況,很顯然加載、驗證、準備得在初始化以前,下面具體來講說這5種狀況:
安全

類加載時機
類加載時機

其中狀況1中的4條字節碼指令在Java裏最多見的場景是:
1 . new一個對象時
2 . set或者get一個類的靜態字段(除去那種被final修飾放入常量池的靜態字段)
3 . 調用一個類的靜態方法bash

類加載的過程

下面咱們一步一步分析類加載的每一個過程網絡

1. 加載

加載是整個類加載過程的第一步,若是須要建立類或者接口,就須要如今Java虛擬機方法區建立於虛擬機實現規定相匹配的內部表示。通常來講類的建立是由另外一個類或者接口觸發的,它經過本身的運行時常量池引用到了須要建立的類,也多是因爲調用了Java核心類庫中的某些方法,譬如反射等。數據結構

通常來講加載分爲如下幾步:多線程

  1. 經過一個類的全限定名獲取此類的二進制字節流
  2. 將這個字節流所表明的靜態存儲結構轉化爲方法區的運行時數據結構
  3. 在內存中生成一個表明這個類的java.lang.Class對象,做爲方法區這個類的各類數據的訪問入口

建立名字爲C的類,若是C不是數組類型,那麼它就能夠經過類加載器加載C的二進制表示(即Class文件)。若是是數組,則是經過Java虛擬機建立,虛擬機遞歸地採用上面提到的加載過程不斷加載數組的組件。函數

Java虛擬機支持兩種類加載器:佈局

  • 引導類加載器(Bootstrap ClassLoader)
  • 用戶自定義類加載器(User-Defined Class Loader)

用戶自定義的類加載器應該是抽象類ClassLoader的某個子類的實例。應用程序使用用戶自定義的類加載器是爲了擴展Java虛擬機的功能,支持動態加載並建立類。好比,在加載的第一個步驟中,獲取二進制字節流,經過自定義類加載器,咱們能夠從網絡下載、動態產生或者從一個加密文件中提取類的信息。

關於類加載器,會新開一篇文章描述。

2.驗證

驗證做爲連接的第一步,用於確保類或接口的二進制表示結構上是正確的,從而確保字節流包含的信息對虛擬機來講是安全的。Java虛擬機規範中關於驗證階段的規則也是在不斷增長的,但大致上會完成下面4個驗證動做。

驗證
驗證

1 . 文件格式驗證:主要驗證字節流是否符合Class文件格式規範,而且能被當前版本的虛擬機處理。
主要驗證點:

  • 是否以魔數0xCAFEBABE開頭
  • 主次版本號是否在當前虛擬機處理範圍以內
  • 常量池的常量是否有不被支持的類型 (檢查常量tag標誌)
  • 指向常量的各類索引值中是否有指向不存在的常量或不符合類型的常量
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的數據
  • Class文件中各個部分及文件自己是否有被刪除的或者附加的其餘信息
    ...
    實際上驗證的不只僅是這些,關於Class文件格式能夠參考個人深刻理解JVM類文件格式,這階段的驗證是基於二進制字節流的,只有經過文件格式驗證後,字節流纔會進入內存的方法區中進行存儲。

2 . 元數據驗證:主要對字節碼描述的信息進行語義分析,以保證其提供的信息符合Java語言規範的要求。
主要驗證點:

  • 該類是否有父類(只有Object對象沒有父類,其他都有)
  • 該類是否繼承了不容許被繼承的類(被final修飾的類)
  • 若是這個類不是抽象類,是否實現了其父類或接口之中要求實現的全部方法
  • 類中的字段、方法是否與父類產生矛盾(例如覆蓋了父類的final字段,出現不符合規則的方法重載,例如方法參數都一致,可是返回值類型卻不一樣)
    ...

3 . 字節碼驗證:主要是經過數據流和控制流分析,肯定程序語義是合法的、符合邏輯的。在第二階段對元數據信息中的數據類型作完校驗後,字節碼驗證將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會作出危害虛擬機安全的事件。
主要有:

  • 保證任意時刻操做數棧的數據類型與指令代碼序列都能配合工做,例如不會出現相似的狀況:操做數棧裏的一個int數據,可是使用時卻當作long類型加載到本地變量中
  • 保證跳轉不會跳到方法體之外的字節碼指令上
  • 保證方法體內的類型轉換是合法的。例如子類賦值給父類是合法的,可是父類賦值給子類或者其它毫無繼承關係的類型,則是不合法的。
  1. 符號引用驗證:最後一個階段的校驗發生在虛擬機將符號引用轉化爲直接引用的時候,這個轉化動做將在鏈接的第三階段解析階段發生。符號引用是對類自身之外(常量池中的各類符號引用)的信息進行匹配校驗。
    一般有:
  • 符號引用中經過字符串描述的全限定名是否找到對應的類
  • 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段
  • 符號引用中的類、方法、字段的訪問性(private,public,protected、default)是否可被當前類訪問
    符號引用驗證的目的是確保解析動做可以正常執行,若是沒法經過符號引用驗證,那麼將會拋出一個java.lang.IncompatibleClassChangeError異常的子類,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

驗證階段很是重要,但不必定必要,若是全部代碼極影被反覆使用和驗證過,那麼能夠經過虛擬機參數-Xverify: none來關閉驗證,加速類加載時間。

3.準備

準備階段的任務是爲類或者接口的靜態字段分配空間,而且默認初始化這些字段。這個階段不會執行任何的虛擬機字節碼指令,在初始化階段纔會顯示的初始化這些字段,因此準備階段不會作這些事情。假設有:

public static int value = 123;複製代碼

value在準備階段的初始值爲0而不是123,只有到了初始化階段,value纔會爲0。
下面看一下Java中全部基礎類型的零值:

數據類型 零值
int 0
long 0L
short (short)0
char '\u0000'
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference null

一種特殊狀況是,若是字段屬性表中包含ConstantValue屬性,那麼準備階段變量value就會被初始化爲ConstantValue屬性所指定的值,好比上面的value若是這樣定義:

public static final int value = 123;複製代碼

編譯時,value一開始就指向ConstantValue,因此準備期間value的值就已是123了。

4.解析

解析階段是把常量池內的符號引用替換成直接引用的過程,符號引用就是Class文件中的CONSTANT_Class_info CONSTANT_Fieldref_infoCONSTANT_Methodref_info等類型的常量。下面咱們看符號引用和直接引用的定義。

符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號能夠是任何形式的字面量,只要能夠惟必定位到目標便可。符號引用於內存佈局無關,因此所引用的對象不必定須要已經加載到內存中。各類虛擬機實現的內存佈局能夠不一樣,可是接受的符號引用必須是一致的,由於符號引用的字面量形式已經明肯定義在Class文件格式中。

直接引用(Direct References):直接引用時直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用和虛擬機實現的內存佈局相關,同一個符號引用在不一樣虛擬機上翻譯出來的直接引用通常不會相同。若是有了直接引用,那麼它必定已經存在於內存中了。

如下Java虛擬機指令會將符號引用指向運行時常量池,執行任意一條指令都須要對它的符號引用進行解析:

引發解析的命令
引發解析的命令

對同一個符號進行屢次解析請求是很常見的,除了invokedynamic指令之外,虛擬機基本都會對第一次解析的結果進行緩存,後面再遇到時,直接引用,從而避免解析動做重複。

對於invokedynamic指令,上面規則不成立。當遇到前面已經由invokedynamic指令觸發過解析的符號引用時,並不意味着這個解析結果對於其餘invokedynamic指令一樣生效。這是由invokedynamic指令的語義決定的,它原本就是用於動態語言支持的,也就是必須等到程序實際運行這條指令的時候,解析動做纔會執行。其它的命令都是「靜態」的,能夠再剛剛完成記載階段,尚未開始執行代碼時就解析。

下面來看幾種基本的解析:
類與接口的解析: 假設Java虛擬機在類D的方法體中引用了類N或者接口C,那麼會執行下面步驟:

  1. 若是C不是數組類型,D的定義類加載器被用來建立類N或者接口C。加載過程當中出現任何異常,能夠被認爲是類和接口解析失敗。
  2. 若是C是數組類型,而且它的元素類型是引用類型。那麼表示元素類型的類或接口的符號引用會經過遞歸調用來解析。
  3. 檢查C的訪問權限,若是D對C沒有訪問權限,則會拋出java.lang.IllegalAccessError異常。

字段解析
要解析一個未被解析過的字段符號引用,首先會對字段表內class_index項中索引的CONSTANT_Class_info符號引用進行解析,這邊記不清的能夠繼續回顧深刻理解JVM類文件格式,也就是字段所屬的類或接口的符號引用。若是在解析這個類或接口符號引用的過程當中出現了任何異常,都會致使字段解析失敗。若是解析完成,那將這個字段所屬的類或者接口用C表示,虛擬機規範要求按照以下步驟對C進行後續字段的搜索。

1 . 若是C自己包含了簡單名稱和字段描述符都與目標相匹配的字段,則直接返回這個字段的直接引用,查找結束。
2 . 不然,若是在C中實現了接口,將會按照繼承關係從下往上遞歸搜索各個接口和它的父接口,若是接口中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
3 . 再否則,若是C不是java.lang.Object的話,將會按照繼承關係從下往上遞歸搜索其父類,若是在類中包含
了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
4 . 若是都沒有,查找失敗退出,拋出java.lang.NoSuchFieldError異常。若是返回了引用,還須要檢查訪問權限,若是沒有訪問權限,則會拋出java.lang.IllegalAccessError異常。

在實際的實現中,要求可能更嚴格,若是同一字段名在C的父類和接口中同時出現,編譯器可能拒絕編譯。

類方法解析
類方法解析也是先對類方法表中的class_index項中索引的方法所屬的類或接口的符號引用進行解析。咱們依然用C來表明解析出來的類,接下來虛擬機將按照下面步驟對C進行後續的類方法搜索。
1 . 首先檢查方法引用的C是否爲類或接口,若是是接口,那麼方法引用就會拋出IncompatibleClassChangeError異常
2 . 方法引用過程當中會檢查C和它的父類中是否包含此方法,若是C中確實有一個方法與方法引用的指定名稱相同,而且聲明是簽名多態方法(Signature Polymorphic Method),那麼方法的查找過程就被認爲是成功的,全部方法描述符所提到的類也須要解析。對於C來講,沒有必要使用方法引用指定的描述符來聲明方法。
3 . 不然,若是C聲明的方法與方法引用擁有一樣的名稱與描述符,那麼方法查找也是成功。
4 . 若是C有父類的話,那麼按照第2步的方法遞歸查找C的直接父類。
5 . 不然,在類C實現的接口列表及它們的父接口之中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,若是存在相匹配的方法,說明類C時一個抽象類,查找結束,而且拋出java.lang.AbstractMethodError異常。

  1. 不然,宣告方法失敗,而且拋出java.lang.NoSuchMethodError
    最後的最後,若是查找過程成功返回了直接引用,將會對這個方法進行權限驗證,若是發現不具有對此方法的訪問權限,那麼會拋出 java.lang.IllegalAccessError異常。

接口方法解析
接口方法也須要解析出接口方法表的class_index項中索引的方法所屬的類或接口的符號引用,若是解析成功,依然用C表示這個接口,接下來虛擬機將會按照以下步驟進行後續的接口方法搜索。
1 . 與類方法解析不一樣,若是在接口方法表中發現class_index對應的索引C是類而不是接口,直接拋出java.lang.IncompatibleClassChangeError異常。
2 . 不然,在接口C中查找是否有簡單名稱和描述符都與目標匹配的方法,若是有則直接返回這個方法的直接引用,查找結束。
3 . 不然,在接口C的父接口中遞歸查找,直到java.lang.Object類爲止,看是否有簡單名稱和描述符都與目標相匹配的方法,若是有則返回這個方法的直接引用,查找結束。
4 . 不然,宣告方法失敗,拋出java.lang.NoSuchMethodError異常。

因爲接口的方法默認都是public的,因此不存在訪問權限問題,也就基本不會拋出java.lang.IllegalAccessError異常。

5.初始化

初始化是類加載的最後一步,在前面的階段裏,除了加載階段能夠經過用戶自定義的類加載器加載,其他部分基本都是由虛擬機主導的。可是到了初始化階段,纔開始真正執行用戶編寫的java代碼了。

在準備階段,變量都被賦予了初始值,可是到了初始化階段,全部變量還要按照用戶編寫的代碼從新初始化。換一個角度,初始化階段是執行類構造器<clinit>()方法的過程。

<clinit>()方法是由編譯器自動收集類中的全部類變量的賦值動做和靜態語句塊(static語句塊)中的語句合併生成的,編譯器收集的順序是由語句在源文件中出現的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊以前的變量,定義在它以後的變量,在前面的靜態語句塊中能夠賦值,可是不能訪問

public class Test {
  static {
    i=0;  //能夠賦值
    System.out.print(i); //編譯器會提示「非法向前引用」
  }
  static int i=1;
}複製代碼

<clinit>()方法與類的構造函數<init>()方法不一樣,它不須要顯示地調用父類構造器,虛擬機會寶成在子類的<clinit>()方法執行以前,父類的<clinit>()已經執行完畢,所以在虛擬機中第一個被執行的<clinit>()必定是java.lang.Object的。

也是因爲<clinit>()執行的順序,因此父類中的靜態語句塊優於子類的變量賦值操做,因此下面的代碼段,B的值會是2。

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);
}複製代碼

<clinit>()方法對於類來講不是必須的,若是一個類中既沒有靜態語句塊也沒有靜態變量賦值動做,那麼編譯器都不會爲類生成<clinit>()方法。

接口中不能使用靜態語句塊,可是容許有變量初始化的賦值操做,所以接口與類同樣都會生成<clinit>()方法,可是接口中的<clinit>()不須要先執行父類的,只有當父類中定義的變量使用時,父接口才會初始化。除此以外,接口的實現類在初始化時也不會執行接口的<clinit>()方法

虛擬機會保證一個類的<clinit>()方法在多線程環境中能被正確的枷鎖、同步。若是多個線程初始化一個類,那麼只有一個線程會去執行<clinit>()方法,其它線程都須要等待。

6.Java虛擬機退出

Java虛擬機退出的通常條件是:某些線程調用Runtime類或System類的exit方法,或者時Runtime類的halt方法,而且Java安全管理器也容許這些exit或者halt操做。除此以外,在JNI(Java Native Interface)規範中還描述了當使用JNI API來加載和卸載(Load & Unload)Java虛擬機時,Java虛擬機退出過程。

相關文章
相關標籤/搜索