深入理解JVM類加載機制

簡述:虛擬機把描述類的數(shù)據(jù)從class文件加載到內(nèi)存,并對數(shù)據(jù)進行校驗、轉(zhuǎn)換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。

下面我們具體來看類加載的過程:
類的生命周期

類從被加載到內(nèi)存中開始,到卸載出內(nèi)存,經(jīng)歷了加載、連接、初始化、使用四個階段,其中連接又包含了驗證、準備、解析三個步驟。這些步驟總體上是按照圖中順序進行的,但是Java語言本身支持運行時綁定,所以解析階段也可以是在初始化之后進行的。以上順序都只是說開始的順序,實際過程中是交叉進行的,加載過程中可能就已經(jīng)開始驗證了。

類加載的時機

首先要知道什么時候類需要被加載,Java虛擬機規(guī)范并沒有約束這一點,但是卻規(guī)定了類必須進行初始化的5種情況,很顯然加載、驗證、準備得在初始化之前,下面具體來說說這5種情況:

類加載時機

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

類加載的過程

下面我們一步一步分析類加載的每個過程

1. 加載

加載是整個類加載過程的第一步,如果需要創(chuàng)建類或者接口,就需要現(xiàn)在Java虛擬機方法區(qū)創(chuàng)建于虛擬機實現(xiàn)規(guī)定相匹配的內(nèi)部表示。一般來說類的創(chuàng)建是由另一個類或者接口觸發(fā)的,它通過自己的運行時常量池引用到了需要創(chuàng)建的類,也可能是由于調(diào)用了Java核心類庫中的某些方法,譬如反射等。

一般來說加載分為以下幾步:

  1. 通過一個類的全限定名獲取此類的二進制字節(jié)流
  2. 將這個字節(jié)流所代表的靜態(tài)存儲結(jié)構(gòu)轉(zhuǎn)化為方法區(qū)的運行時數(shù)據(jù)結(jié)構(gòu)
  3. 在內(nèi)存中生成一個代表這個類的java.lang.Class對象,作為方法區(qū)這個類的各種數(shù)據(jù)的訪問入口

創(chuàng)建名字為C的類,如果C不是數(shù)組類型,那么它就可以通過類加載器加載C的二進制表示(即Class文件)。如果是數(shù)組,則是通過Java虛擬機創(chuàng)建,虛擬機遞歸地采用上面提到的加載過程不斷加載數(shù)組的組件。

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

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

用戶自定義的類加載器應該是抽象類ClassLoader的某個子類的實例。應用程序使用用戶自定義的類加載器是為了擴展Java虛擬機的功能,支持動態(tài)加載并創(chuàng)建類。比如,在加載的第一個步驟中,獲取二進制字節(jié)流,通過自定義類加載器,我們可以從網(wǎng)絡下載、動態(tài)產(chǎn)生或者從一個加密文件中提取類的信息。

關(guān)于類加載器,會新開一篇文章描述。

2. 驗證

驗證作為鏈接的第一步,用于確保類或接口的二進制表示結(jié)構(gòu)上是正確的,從而確保字節(jié)流包含的信息對虛擬機來說是安全的。Java虛擬機規(guī)范中關(guān)于驗證階段的規(guī)則也是在不斷增加的,但大體上會完成下面4個驗證動作。


驗證

1 . 文件格式驗證:主要驗證字節(jié)流是否符合Class文件格式規(guī)范,并且能被當前版本的虛擬機處理。
主要驗證點:

  • 是否以魔數(shù)0xCAFEBABE開頭
  • 主次版本號是否在當前虛擬機處理范圍之內(nèi)
  • 常量池的常量是否有不被支持的類型 (檢查常量tag標志)
  • 指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的數(shù)據(jù)
  • Class文件中各個部分及文件本身是否有被刪除的或者附加的其他信息
    ...
    實際上驗證的不僅僅是這些,關(guān)于Class文件格式可以參考我的深入理解JVM類文件格式,這階段的驗證是基于二進制字節(jié)流的,只有通過文件格式驗證后,字節(jié)流才會進入內(nèi)存的方法區(qū)中進行存儲。

2 . 元數(shù)據(jù)驗證:主要對字節(jié)碼描述的信息進行語義分析,以保證其提供的信息符合Java語言規(guī)范的要求。
主要驗證點:

  • 該類是否有父類(只有Object對象沒有父類,其余都有)
  • 該類是否繼承了不允許被繼承的類(被final修飾的類)
  • 如果這個類不是抽象類,是否實現(xiàn)了其父類或接口之中要求實現(xiàn)的所有方法
  • 類中的字段、方法是否與父類產(chǎn)生矛盾(例如覆蓋了父類的final字段,出現(xiàn)不符合規(guī)則的方法重載,例如方法參數(shù)都一致,但是返回值類型卻不同)
    ...

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

  • 保證任意時刻操作數(shù)棧的數(shù)據(jù)類型與指令代碼序列都能配合工作,例如不會出現(xiàn)類似的情況:操作數(shù)棧里的一個int數(shù)據(jù),但是使用時卻當做long類型加載到本地變量中
  • 保證跳轉(zhuǎn)不會跳到方法體以外的字節(jié)碼指令上
  • 保證方法體內(nèi)的類型轉(zhuǎn)換是合法的。例如子類賦值給父類是合法的,但是父類賦值給子類或者其它毫無繼承關(guān)系的類型,則是不合法的。
  1. 符號引用驗證:最后一個階段的校驗發(fā)生在虛擬機將符號引用轉(zhuǎn)化為直接引用的時候,這個轉(zhuǎn)化動作將在連接的第三階段解析階段發(fā)生。符號引用是對類自身以外(常量池中的各種符號引用)的信息進行匹配校驗。
    通常有:
  • 符號引用中通過字符串描述的全限定名是否找到對應的類
  • 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段
  • 符號引用中的類、方法、字段的訪問性(private,public,protected、default)是否可被當前類訪問
    符號引用驗證的目的是確保解析動作能夠正常執(zhí)行,如果無法通過符號引用驗證,那么將會拋出一個java.lang.IncompatibleClassChangeError異常的子類,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

驗證階段非常重要,但不一定必要,如果所有代碼極影被反復使用和驗證過,那么可以通過虛擬機參數(shù)-Xverify: none來關(guān)閉驗證,加速類加載時間。

3. 準備

準備階段的任務是為類或者接口的靜態(tài)字段分配空間,并且默認初始化這些字段。這個階段不會執(zhí)行任何的虛擬機字節(jié)碼指令,在初始化階段才會顯示的初始化這些字段,所以準備階段不會做這些事情。假設(shè)有:

public static int value = 123;

value在準備階段的初始值為0而不是123,只有到了初始化階段,value才會為0。
下面看一下Java中所有基礎(chǔ)類型的零值:

數(shù)據(jù)類型 零值
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的值就已經(jīng)是123了。

4. 解析

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

符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要可以唯一定位到目標即可。符號引用于內(nèi)存布局無關(guān),所以所引用的對象不一定需要已經(jīng)加載到內(nèi)存中。各種虛擬機實現(xiàn)的內(nèi)存布局可以不同,但是接受的符號引用必須是一致的,因為符號引用的字面量形式已經(jīng)明確定義在Class文件格式中。

直接引用(Direct References):直接引用時直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用和虛擬機實現(xiàn)的內(nèi)存布局相關(guān),同一個符號引用在不同虛擬機上翻譯出來的直接引用一般不會相同。如果有了直接引用,那么它一定已經(jīng)存在于內(nèi)存中了。

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


引起解析的命令

對同一個符號進行多次解析請求是很常見的,除了invokedynamic指令以外,虛擬機基本都會對第一次解析的結(jié)果進行緩存,后面再遇到時,直接引用,從而避免解析動作重復。

對于invokedynamic指令,上面規(guī)則不成立。當遇到前面已經(jīng)由invokedynamic指令觸發(fā)過解析的符號引用時,并不意味著這個解析結(jié)果對于其他invokedynamic指令同樣生效。這是由invokedynamic指令的語義決定的,它本來就是用于動態(tài)語言支持的,也就是必須等到程序?qū)嶋H運行這條指令的時候,解析動作才會執(zhí)行。其它的命令都是“靜態(tài)”的,可以再剛剛完成記載階段,還沒有開始執(zhí)行代碼時就解析。

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

  1. 如果C不是數(shù)組類型,D的定義類加載器被用來創(chuàng)建類N或者接口C。加載過程中出現(xiàn)任何異常,可以被認為是類和接口解析失敗。
  2. 如果C是數(shù)組類型,并且它的元素類型是引用類型。那么表示元素類型的類或接口的符號引用會通過遞歸調(diào)用來解析。
  3. 檢查C的訪問權(quán)限,如果D對C沒有訪問權(quán)限,則會拋出java.lang.IllegalAccessError異常。

字段解析
要解析一個未被解析過的字段符號引用,首先會對字段表內(nèi)class_index項中索引的CONSTANT_Class_info符號引用進行解析,這邊記不清的可以繼續(xù)回顧深入理解JVM類文件格式,也就是字段所屬的類或接口的符號引用。如果在解析這個類或接口符號引用的過程中出現(xiàn)了任何異常,都會導致字段解析失敗。如果解析完成,那將這個字段所屬的類或者接口用C表示,虛擬機規(guī)范要求按照如下步驟對C進行后續(xù)字段的搜索。

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

在實際的實現(xiàn)中,要求可能更嚴格,如果同一字段名在C的父類和接口中同時出現(xiàn),編譯器可能拒絕編譯。

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

  1. 否則,宣告方法失敗,并且拋出java.lang.NoSuchMethodError
    最后的最后,如果查找過程成功返回了直接引用,將會對這個方法進行權(quán)限驗證,如果發(fā)現(xiàn)不具備對此方法的訪問權(quán)限,那么會拋出 java.lang.IllegalAccessError異常。

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

由于接口的方法默認都是public的,所以不存在訪問權(quán)限問題,也就基本不會拋出java.lang.IllegalAccessError異常。

5. 初始化

初始化是類加載的最后一步,在前面的階段里,除了加載階段可以通過用戶自定義的類加載器加載,其余部分基本都是由虛擬機主導的。但是到了初始化階段,才開始真正執(zhí)行用戶編寫的java代碼了。

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

<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態(tài)語句塊(static語句塊)中的語句合并生成的,編譯器收集的順序是由語句在源文件中出現(xiàn)的順序決定的,靜態(tài)語句塊中只能訪問到定義在靜態(tài)語句塊之前的變量,定義在它之后的變量,在前面的靜態(tài)語句塊中可以賦值,但是不能訪問

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

<clinit>()方法與類的構(gòu)造函數(shù)<init>()方法不同,它不需要顯示地調(diào)用父類構(gòu)造器,虛擬機會寶成在子類的<clinit>()方法執(zhí)行之前,父類的<clinit>()已經(jīng)執(zhí)行完畢,因此在虛擬機中第一個被執(zhí)行的<clinit>()一定是java.lang.Object的。

也是由于<clinit>()執(zhí)行的順序,所以父類中的靜態(tài)語句塊優(yōu)于子類的變量賦值操作,所以下面的代碼段,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>()方法對于類來說不是必須的,如果一個類中既沒有靜態(tài)語句塊也沒有靜態(tài)變量賦值動作,那么編譯器都不會為類生成<clinit>()方法。

接口中不能使用靜態(tài)語句塊,但是允許有變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>()方法,但是接口中的<clinit>()不需要先執(zhí)行父類的,只有當父類中定義的變量使用時,父接口才會初始化。除此之外,接口的實現(xiàn)類在初始化時也不會執(zhí)行接口的<clinit>()方法。

虛擬機會保證一個類的<clinit>()方法在多線程環(huán)境中能被正確的枷鎖、同步。如果多個線程初始化一個類,那么只有一個線程會去執(zhí)行<clinit>()方法,其它線程都需要等待。

6. Java虛擬機退出

Java虛擬機退出的一般條件是:某些線程調(diào)用Runtime類或System類的exit方法,或者時Runtime類的halt方法,并且Java安全管理器也允許這些exit或者halt操作。
除此之外,在JNI(Java Native Interface)規(guī)范中還描述了當使用JNI API來加載和卸載(Load & Unload)Java虛擬機時,Java虛擬機退出過程。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容