1. 程式人生 > >JVM理論:(三/5)類加載機制

JVM理論:(三/5)類加載機制

it! 4條 extend 用戶 輸入 編譯器 alac 實例化 結束

  前文已經講了Class文件的存儲格式、方法的調用過程、字節碼的執行過程。但是虛擬機是如何加載這些Class文件的呢?Class文件中的信息進入到虛擬機後會發生什麽變化?

  虛擬機將描述類的數據從Class文件加載到內存,並對數據進行校驗,轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。在Java語言中,類型的加載,連接和初始化過程都是在程序運行期間完成的。  

  類從被加載到虛擬機內存中開始,到卸載出內存為止,它的整個生命周期包括:加載、驗證、準備、解析、初始化、使用、卸載7 個階段。其中驗證、準備、解析 3 個部分統稱為連接,如下圖。接下依次總結一下加載、驗證、準備、解析、初始化這5個過程。

  技術分享圖片

  

一、加載  

在加載階段,虛擬機需要完成以下3件事情:

  1)通過一個類的全限定名來獲取定義此類的二進制字節流。

  2)將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構

  3)加載階段完成後,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區中,然後在內存中實例化一個java.lang.Class類的對象,作為程序訪問方法區中的這個類的類型數據的外部接口。

對於一個非數組類,開發人員既可以使用系統提供的引導類加載器,也可以由用戶自定義的類加載器去控制字節流的獲取方式。

但對於數組類而言,數組類本身不通過類加載器創建,它是由Java虛擬機直接創建的。一個數組類C創建過程遵循以下規則:

  1)如果數組的組件類型(指的是數組去掉一個維度的類型)是引用類型,那就遞歸采用本節中定義的加載過程去加載這個組件類型,數組C將在加載該組件類型的類加載器的類名稱空間上被標識,這點很重要,一個類必須與類加載器一起確定唯一性

  2)如果數組的組件類型不是引用類型(例如int[]數組),Java虛擬機將會把數組C標記為與引導類加載器關聯。

  3)數組類的可見性與它的組件類型的可見性一致,如果組件類型不是引用類型,那數組類的可見性將默認為public。

二、驗證

這一階段的目的是為了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。驗證階段大致包括以下4個階段:文件格式驗證

元數據驗證字節碼驗證符號引用驗證。

  1)文件格式驗證。驗證字節流是否符合Class文件格式的規範。保證輸入的字節流能正確地解析並存儲於方法區之內,格式上符合描述一個Java類型信息的要求。這階段的驗證是基於二進制字節流進行的,只有通過了這個階段的驗證後,字節流才會進入內存的方法區中進行存儲,後面的3個驗證階段全部是基於方法區的存儲結構進行的,不會再直接操作字節流。

  2)元數據驗證。對字節碼描述的信息進行語義分析,保證不存在不符合Java語言規範的元數據信息。

  3)字節碼驗證。在第二階段對元數據信息中的數據類型做完校驗後,這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事件。

  4)符號引用驗證。發生在虛擬機將符號引用轉化為直接引用的時候,這個轉化動作將在連接的第三階段——解析階段中發生,以確保解析動作能正常執行。

三、準備

  準備階段僅為類變量(被static修飾的變量)在方法區中分配內存並設置類變量初始值,不包括實例變量,實例變量將會在對象實例化時隨著對象一起分配在Java堆中。

  這裏所說的初始值是值數據類型的零值,基本數據類型的零值如下表。

  技術分享圖片

  假設定義一個類變量如下,那變量value在準備階段過後的初始值為0而不是123,因為這時候尚未開始執行任何Java方法,而把value賦值為123的putstatic指令是程序被編譯後,存放於類構造器<clinit>()方法之中,所以把value賦值為123的動作將在初始化階段才會執行。

public static int value=123;

  如果類字段的字段屬性表中存在ConstantValue屬性,那在準備階段變量value就會被初始化為ConstantValue屬性所指定的值。

四、解析

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

  • 符號引用包括三類常量:類和接口的全限定名、字段名稱和描述符、方法名稱和描述符。符號可以是任何形式的字面量,引用的目標並不一定要已經加載到內存中。
  • 直接引用可以是指向目標的指針,相對偏移量或是一個能間接定位到目標的句柄。如果有了直接引用,那引用的目標必定已經在內存中存在。

  虛擬機會在在執行anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield、putstatic這16個用於操作符號引用的字節碼指令之前,先對它們所使用的符號引用進行解析,虛擬機可以根據需要來判斷到底是在類被加載器加載時就對常量池中的符號引用進行解析,還是等到一個符號引用將要被使用前才去解析它。除invokedynamic指令以外,虛擬機實現可以對第一次解析的結果進行緩存從而避免解析動作重復進行。對於invokedynamic指令,上面規則則不成立,因為invokedynamic指令的目的本來就是用於動態語言支持,所以必須等到程序實際運行到這條指令的時候,解析動作才能進行。

1、類或接口的解析

  假設當前代碼所處的類為D,要把一個從未解析過的符號引用N解析為一個類或接口C的直接引用,那虛擬機完成整個解析的過程需要以下3個步驟:
  1)如果C不是一個數組類型,那虛擬機將會把代表N的全限定名傳遞給D的類加載器去加載這個類C。這個過程中又可能觸發這個類的父類或實現接口的加載動作。
  2)如果C是一個數組類型,並且數組的元素類型為對象,那將會按照第1點的規則加載數組元素類型。
  3)如果上面的步驟沒有出現任何異常,那麽C在虛擬機中實際上已經成為一個有效的類或接口了,但在解析完成之前還要進行符號引用驗證,確認D是否具備對C的訪問權限(public、private、protected)。如果發現不具備訪問權限,將拋出java.lang.IllegalAccessError異常。

2、字段解析  

  1)要解析一個未被解析過的字段符號引用,首先將會對字段表內class_index項中索引的CONSTANT_Class_info符號引用進行解析,也就是字段所屬的類或接口的符號引用。若字段所屬的類或接口用C表示。

  2)如果C本身就包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
  3)如未找到且在C中實現了接口,將會按照繼承關系從下往上遞歸搜索各個接口和它的父接口。
  4)如未找到且C不是java.lang.Object的話,將會按照繼承關系從下往上遞歸搜索其父類。
  5)未找到,查找失敗,拋出java.lang.NoSuchFieldError異常。

  6)如果查找成功且返回了引用,將會對這個字段進行權限驗證,如果發現不具備對字段的訪問權限,將拋出java.lang.IllegalAccessError異常。   

  在實際應用中,如果有一個同名字段同時出現在C的接口和父類中,或者同時在自己或父類的多個接口中出現,那編譯器將可能拒絕編譯。

3、類方法解析

  1)先解析出類方法表的class_index項中索引的方法所屬的類或接口的符號引用。依然用C表示這個類。

  2)如果在類方法表中發現class_index中索引的C是個接口,那就直接拋出java.lang.IncompatibleClassChangeError異常。
  3)如果在類C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
  4)否則,在類C的父類中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法。
  5)否則,在類C實現的接口列表及它們的父接口之中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果存在匹配的方法,說明類C是一個抽象類,這時查找結束,拋出java.lang.AbstractMethodError異常。
  6)否則,宣告方法查找失敗,拋出java.lang.NoSuchMethodError。
  7)最後,如果查找過程成功返回了直接引用,將會對這個方法進行權限驗證,如果發現不具備對此方法的訪問權限,將拋出java.lang.IllegalAccessError異常。

4、接口方法解析

  1)解析出接口方法表的class_index項中索引的方法所屬的類或接口的符號引用。依然用C表示這個接口。

  2)如果在接口方法表中發現class_index中的索引C是個類而不是接口,那就直接拋出java.lang.IncompatibleClassChangeError異常。

  3)否則,在接口C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
  4)否則,在接口C的父接口中遞歸查找,直到java.lang.Object類(查找範圍會包括Object類)為止。
  5)否則,宣告方法查找失敗,拋出java.lang.NoSuchMethodError異常。

  由於接口中的所有方法默認都是public的,所以不存在訪問權限的問題,因此接口方法的符號解析應當不會拋出java.lang.IllegalAccessError異常。

五、初始化

初始化時機:

  類初始化階段是類加載過程的最後一步,Java虛擬機規範中並沒有強制約束什麽情況下開始類加載過程的第一個階段:加載。但是對於初始化階段,虛擬機規範則是嚴格規定了有且只有5種情況必須立即對類進行“初始化”(而加載、驗證、準備自然需要在此之前開始):

  • 1)遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段的時候(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)、調用一個類的靜態方法的時候
  • 2)使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
  • 3)當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  • 4)當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
  • 5)當使用JDK 1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。

<clinit>()方法特點: 

  在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序制定的主觀計劃去初始化類變量和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。<clinit>()方法特點如下:

  1)<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合並產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪問,如下列代碼所示,i定義在靜態塊後,但靜態塊中有調用i的操作就會報錯。

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

  2)<clinit>()方法與實例構造器<init>()方法不同,它不需要顯式地調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此在虛擬機中第一個被執行的<clinit>()方法的類肯定是java.lang.Object。

  3)由於父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變量賦值操作

  4)<clinit>()方法對於類或接口來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那麽編譯器可以不為這個類生成<clinit>()方法。

  5)接口中不能使用靜態語句塊“static{}”,但編譯器仍然會為接口生成“<clinit>()”類構造器,用於初始化接口中所定義的成員變量。但接口與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法。只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。

  6)虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖、同步,如果多個線程同時去初始化一個類,那麽只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。需要註意的是,其他線程雖然會被阻塞,但如果執行<clinit>()方法的那條線程退出<clinit>()方法後,其他線程喚醒之後不會再次進入<clinit>()方法。同一個類加載器下,一個類型只會初始化一次。

被動引用不會初始化:

  除了前面講的“有且只有”的5種場景,其余所有引用類的方式都不會觸發初始化,稱為被動引用。下面列舉三例。

/**
 * 被動使用類字段演示一:
 * 通過子類引用父類的靜態字段,不會導致子類初始化
 **/
public class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }
    public static int value = 123;
}

public class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}

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

輸出結果:
superclass init
123

  不會輸出“SubClass init!”。對於靜態字段,只有直接定義這個字段的類才會被初始化,因此通過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。對於Sun HotSpot虛擬機來說,可通過-XX:+TraceClassLoading參數觀察到此操作會導致子類的加載。

/**
 * 被動使用類字段演示二:
 * 通過數組定義來引用類,不會觸發此類的初始化
 **/
public class NotInitialization {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];
    }
}

輸出結果:什麽也沒輸出

  沒有輸出“Superclass init ! ” ,說明並沒有觸發類org.fenixsoft.classloading.SuperClass的初始化階段。但是這段代碼裏面觸發了另外一個名為“[Lorg.fenixsoftclassloading.Superclass”的類的初始化階段,對於用戶代碼來說,這並不是一個合法的類名稱,它是一個由虛擬機自動生成的、直接繼承於java.lang.Object的子類,創建動作由字節碼指令newarray觸發。

/**
 * 被動使用類字段演示三:
 * 常量在編譯階段會存入調用類的常量池中,本質上沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。
 **/
public class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }
    public static final String HELLOWORLD = "hello world";
}

public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLOWORLD);
    }
}

輸出結果:
hello world

  沒有輸出”ConstClass init!”,這是因為雖然在Java源碼中引用了ConstClass類中的常量HELLOWORLD,但其實在編譯階段通過常量傳播優化,已經將此常量的值“hello world”存儲到了Notlnitialization類的常量池中,以後Notlnitialization對常量ConstClass.HELLOWORLD的引用實際都被轉化為Notlnitializationl類對自身常量池的引用了。這兩個類在編譯成Class之後就不存在任何聯系了。

參考鏈接:

https://blog.csdn.net/huaxun66/article/details/77092308

https://blog.csdn.net/chenge_j/article/details/72677766

https://www.cnblogs.com/timlearn/p/4029823.html

https://blog.csdn.net/u013678930/article/details/51954768

http://www.importnew.com/18548.html

JVM理論:(三/5)類加載機制