JVM筆記:Java虛擬機的類加載機制

前言

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

  • 類加載的流程

    類從被加載到虛擬機內存中開始,到卸載出內存位置,它的整個生命週期包括:加載、驗證、準備、解析、初始化、使用和卸載,其中驗證、準備、解析三個部分統稱爲鏈接。這七個階段的發生順序如圖1-1所示。
    圖1-1:類加載流程圖

上圖中,加載、驗證、準備、初始化和卸載這5個階段的順序是固定的,類的加載過程必須按照這種順序循序漸進地開始,可是解析階段則不必定:他在否種狀況下能夠再初始化階段以後再開始,這是爲了支持Java語言的運行時綁定。同事,上面這是階段一般都是互相交叉地混合進行的,一般會在一個階段執行的過程當中調用、激活另外一個階段(例如在一個類的內部初始化另外一個類)。數據庫

  • 類加載的時機

    什麼狀況下須要開始類加載過程的第一個階段:加載?Java虛擬機規範中並無進行強制約束,這點交給虛擬機的具體實現來自由把握。可是對於初始化階段,虛擬機規範則是嚴格規定了有且只有5中狀況必須當即對類進行初始化(加載、驗證、準備天然須要在此以前開始)。數組

    • 遇到new 、getstatic、putstatic、invokestatic這四條字節碼指令時,若是類沒有進行國儲石化,則須要先觸發其初始化。生成這四條指令的場景是:使用new關鍵字實例化對象,讀取或這隻一個類的靜態變量(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。
    • 使用java.lang.reflect包的方法對類進行反射調用的時候,若是類沒有通過初始化,則須要先觸發其初始化
    • 當初始化一個類的時候,若是其父類尚未通過初始化,則須要先觸發其父類的初始化。
    • 虛擬機啓動時,用戶須要制定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
    • 當使用JDK1.7的動態語言支持時,若是一個java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,而且這個方法句柄所對應的類沒有通過初始化,則須要先觸發其初始化。

    對於以上5種會觸發類進行初始化的場景,虛擬機規範中使用了一個很強烈的現定於:有且只有,這5種場景中的行爲被稱爲對一個類進行主動引用,可是除此以外,全部引用類的方式都不會觸發初始化,稱爲被動引用,以下面例子:緩存

public class Parent {
    public static int a = 1;
    static {
        System.out.println("Parent init");
    }
}
public class Son extends Parent{
    static {
        System.out.println("Son init");
    }
}
   public static void main(String[] args) {
        System.out.println("args = [" + Son.a + "]");
    }
輸出結果:
Parent init
args = [1]
複製代碼

對於靜態字段,只有直接定義這個字段的類纔會被初始化,所以經過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化,至因而否要觸發子類的加載和驗證,在虛擬機規範中並未明確規定,這點取決於虛擬機的具體實現,對於Sun HotSpot虛擬機來講,可經過-XX:_TraceClassLoading參數觀察到次操做會致使子類的加載。安全

除此以外,經過數組定義來引用類,不會觸發此類的初始化。bash

public static void main(String[] args) {
        Parent[] parentArry = new Parent[10];
    }
複製代碼

運行上述代碼後什麼輸出也沒有,說明並無觸發Parent類的初始化階段。可是這段代碼裏面觸發了另外一個名爲[Lxxx.xxx.Parent(前面的xxx指代類的包名)的類的初始化,這裏是否是看起來有點眼熟,在前面字節碼的文章裏能夠知道[L這裏表示的是一個對象數組。它是由虛擬機自動生成的、直接繼承與Object的類,建立動做由字節碼指令newarray觸發。 這個類表示了一個元素類型爲Parent的一維數組,數組中應有的屬性和方法(可被用戶直接調用的方法只有length和clone)都實如今這個類裏。在Java語言中,當檢查到數組越界時會拋出ArrayIndexOutOfBoundsException異常,可是這個異常檢測不是封裝在數組元素訪問的類中,而是封裝在數組訪問的xaload、xastore字節碼指令中。服務器

當引用一個類的靜態且被final修飾的常量時,不會觸發此類的初始化網絡

public class Parent {
    public static final int a = 1;
    static {
        System.out.println("Parent init");
    }
}
  public static void main(String[] args) {
        System.out.println("args = [" + Son.a + "]");
    }
輸出結果:
args = [1]
複製代碼

由於做爲final修飾的常量時一個不可變的值,因此在編譯階段會經過常量傳播優化,將此常量的值1存儲到了主類(main方法所在的類)的常量池中,因此之後主類中對常量1的引用實際都被轉化了主類對自身常量池的引用,也就是說,實際上主類的Class文件中並無Parent類得符號引用,這兩個類在編異常Class以後就不存在任何聯繫了。數據結構

接口的架子啊過程與類加載過程稍有不一樣,針對接口須要作一些特殊說明:接口也有初始化過程,這點和類是一致的,可是接口中不能使用static{}語句塊,可是編譯器仍然會爲接口生成<client>類構造器,用於初始化接口中所定義的成員變量。接口與類正則有所區別的是前面講述的須要初始化場景的第三種:當一個類在初始化時,要求其父類所有都已經初始化過了。可是一個接口在初始化時,並不要求其負藉口所有都完成了初始化,只有在真正使用到負藉口的時候(如引用接口中定義的常量)纔會被初始化。多線程

  • 類加載的步驟

接下來詳細講解一下類加載的全過程,也就是加載、驗證、準備、解析、初始化這5個階段鎖執行的具體動做。

  • 加載

加載是類加載過程的一個階段,在加載階段,虛擬機主要完成一下三件事

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

加載階段沒有規定加載的內容從哪來,由於它加載的是一個類的全限定名來獲取定義此類的二進制字節流。因此,虛擬機根本沒有制定要從那裏獲取,怎樣獲取,可是常見的獲取方式有下面幾種:

  • 從zip包中獲取,也就是常見的JAR,EAR,WAR
  • 從網絡中獲取,最典型的場景應用就是Applet
  • 運行時計算生成,主要用於動態代理技術,在java.lang.reflect.Proxy中就是用了ProxyGenerator.gengrateProxyClass來爲特定接口生成形式爲*$Proxy的代理類的二進制字節流
  • 由其餘文件生成,例如由JSP文件生成對應的Class類
  • 從數據庫中讀取,例若有些中間件服務器能夠選擇把程序安裝到數據庫中來完成程序代碼在集羣間的分發。 ......

對於類加載過程的其餘階段,一個非數組的加載階段(準確的說,是加載階段中獲取類的二進制字節流的動做)是開發人員可控性最強的,由於加載階段可使用系統提供的引導類加載器來完成,也能夠由用戶自定義的類加載器去完成(例如對字節碼加密,而後經過自定義類加載器來解密後加載類),開發人員能夠經過定義本身的類加載器去控制字節流的獲取方式。

可是數組類並非經過類加載器建立的,它是由Java虛擬機直接建立的。不過數據類型與類加載器仍然有很密切的關係,由於數組類的元素類型最終仍是要考類加載器去建立,一個數組類的建立過程就遵循如下規則:

  • 1 . 若是數組的類型時一個引用類型,那就須要去加載這個組件類型,而後在加載該組件類型的類加載器的類名稱空間上被標識,這一點在後續的類加載器中會講述到。
  • 2 . 若是數組的類型時基礎數據類型,Java虛擬機會把數組標記爲與引導類加載器關聯。
  • 3 . 數組類的可見性與它的組件類型可見性一致,若是組件類型不是引用類型,那數組的可見性將默認爲public。

加載階段完成後,虛擬機將外部的二進制字節流按照虛擬機所需的格式存儲在方法區之中,方法區中的數據存儲格式由虛擬機自行定義,而後在內存中實例化一個Class類的對象(並無明確是在Java堆中,對於HotSpot虛擬機而言mClass對象比較特殊,他雖然是對象,可是存放在方法區裏面),這個對象將做爲程序訪問方法區中的這些類型數據的外部接口。

加載階段和後續的鏈接階段的部份內容是交叉進行的,加載階段還沒有完成時,鏈接階段可能已經開始了,可是這些夾在加載階段的動做仍然屬於鏈接階段。

  • 驗證

    驗證是鏈接階段的第一部,這一步的目的是爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。虛擬機若是不檢查輸入的字節流,對其徹底信任的話,極可能會由於載入了有害的字節流而致使系統崩潰,因此驗證是虛擬機對自身保護的一項重要工做。 從2011年發佈的《Java虛擬機規範(JSE 7版)》中從總體上上看,研製階段大體上會完成下面4個階段的校驗動做:文件格式校驗、元數據校驗、字節碼校驗、符號引用驗證。

  • 1 . 文件格式驗證 驗證字節流是否符合Class文件格式的規範,而且能被當前版本的虛擬機處理,可能包含下面這些驗證點:

    • 是否以魔數0xCAFEBABY開頭。
    • 主次版本號是否在當前虛擬機處理範圍以內。
    • 常量池的常量中是否有不被支持的常量類型(檢查常量的tag標誌)。
    • 指向常量的各類索引值是否有指向不存在的常量或不符合類型的常量。
    • CONSTANT_Utf8_info類型的常量中是否有不符合UTF8編碼的數據。
    • Class文件中各個部分及文件自己是否有被刪除的或附加的其餘信息。 ......

上面只是驗證的一小部分點,目的是包在輸入的字節流能正確地解析而且格式上符合一個Java類型的數據要求。只有經過這個階段的兗州,字節流纔會進入內存的方法區進行存儲,後面的三個驗證階段所有是基於方法取得存儲結構進行的,不會再直接操做字節流。

  • 2 . 元數據驗證 第二步是對字節碼描述的信息進行語義分析,保證其描述的信息符合Java語言規範的要求,這個階段的包含的驗證點以下:

    • 這個類是否有父類(除了Object,全部的類都應該有父類)。
    • 這個類的父類是否繼承了不被容許繼承的類(被final修飾的類)。
    • 若是這個類不是抽象類,是否實現了其父類或接口中的要求實現的全部方法。
    • 類中的字段、方法是否和父類產生了矛盾(例如覆蓋了父類的final字段)。 ......
  • 3 .字節碼驗證 這是驗證過程當中最複雜的一個階段,主要目的是經過數據流和控制流分析,肯定程序語義是合法的、符合邏輯的。交驗完元數據後,這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會作出危害虛擬機安全的事件,例如:

    • 操做數棧的數據類型和指令代碼序列能配合工做,例如不會出現操做數棧存入了int類型數據,加載時卻用long類型。
    • 保證跳轉指令(goto)不會跳轉到方法體之外的字節碼指令上。
    • 保證方法體中的類型轉換時有效的。 ......

    若是一個類方法體沒經過校驗,那確定是有問題的,可是經過了校驗也不必定是徹底安全的,即經過程序去校驗程序邏輯是沒法作到絕對準確的

    虛擬機設計團隊爲了不過多的時間消耗在字節碼校驗階段,在JDK1.6以後Javac虛擬機中進行了一項優化,給方法體的Code屬性的屬性表中增長了一項名爲StackMapTable的屬性,這項屬性描述了方法體中全部的基本虧啊開始時本地變量表和操做數棧應有的狀態,字節碼校驗期間,就不須要根據程序推導這些狀態的合法性,只須要檢查StackMapTable屬性中的記錄是否合法便可,這樣將字節碼驗證的類型推導轉換爲類型檢查,從而節省一些時間。

  • 4 .符號引用驗證

    最後一個階段校驗發生在虛擬機將符號引用轉化爲直接引用的時候,這個轉化動做將在鏈接的第三階段解析中發生,符號引用驗證能夠看作是對類自身之外(常量池中的各類符號引用)的信息進行匹配性校驗,一樣須要校驗下列內容:

    • 符號引用中經過字符串描述的全限定名是否能找到對應的類。
    • 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段。
    • 符號引用中的類、字段、方法的訪問類型是否可被當前類訪問。 ...... 符號引用驗證的目的是確保解析動做能正常執行,若是沒法經過符號引用,那麼會拋出一個IncompatibleClassChangeError異常的子類,例如NoSuchField(Method)Error

    對於虛擬機來講,驗證階段是一個重要,但不是必要的階段,若是你的代碼已經被反覆使用和驗證過了,那麼在實施階段就能夠考慮用-Xverify:none參數來關閉大部分的類驗證措施,以縮短類加載的時間。

  • 準備

    準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這個階段有兩個容易混淆的概念須要強調一下:首先,這個時候進行內存分配的僅包含類變量(static變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一塊兒分配在Java堆中;其次,這裏所說的初始值他那個場狀況下是數據類型的零值。

public static  int number= 1;
  public static final int numberFinal= 123;
複製代碼

上面例子中number在準備階段後的初始值爲0而不是1,由於這個時候還沒有開始執行仍和Java方法,而把number賦值爲1的putstatic指令時程序被編譯後,存放於類構造器<clinit>()方法之中,因此把number賦值爲1的動做將在初始化階段纔會執行。

可是在特殊狀況下,若是類字段的字段屬性表中存在ConstantValue屬性(被final修飾),那在準備階段變量numberFinal就會被初始化爲指定的值。編譯時Javac將會爲numberFinal生成ConstantValue屬性,在準備階段虛擬機就會根據ConstantValue的設置將值設爲123。

  • 解析

    解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程,符號引用在JVM筆記:Java虛擬機的常量池提到過不少次了,在Class文件中他以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等類型的常量出現,那解析階段中所說的直接引用和符號引用又有什麼關聯呢?

    符號引用(SymbolicReferences):符號引用以一組符號來描述所引用的目標,符號能夠是任何形式的字面量,只要使用時可以無歧義地定位到目標便可。可是引用的目標並不必定已經加載到內存中,它在不少狀況下相似一個佔位符,表示未來須要指向這麼一個內容,而後在後續階段將其替換爲直接引用。各類虛擬機所能接受的符號引用必須是一致的,沒由於符號引用的字面量形式明肯定義在Java虛擬機規範的Class文件格式中。

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

    虛擬機規範中並未規定解析階段發生的具體時間,只要求了在執行anewarray、multianewarray、checkcast、getfield、getstatic、instanceof、invoke(dynamic,interfance,special,static,virtual)、ldc、ldc_w、new、putfield、putstatic這16個字節碼以前,先對他們所使用的符號引用進行解析。因此虛擬機實現能夠根據須要來判斷究竟是在類被加載器加載時就對常量池中的符號引用進行解析,仍是等到一個符號引用將要被使用前纔去解析它。

    除了invokedynamic指令之外,虛擬機實現了對第一次解析的結果進行緩存,在運行時常量池中記錄直接引用,並把常量標識爲已解析狀態,從而避免解析動做重複,若是一個符號引用解析成功或失敗,那麼後續對其的引用解析也應該收到成功或者異常告知。

    對於invokedynamic指令,當碰到某個前面已經由invokedynamic指令觸發過解析的符號引用時,並不意味着這個解析結果對於其餘invokedynamic指令也一樣生效。由於invokedynamic指令的目的原本就是用於動態語言支持,它所對應的引用稱爲動態調用點限定符,這裏動態的含義就是必須等到程序運行到這條指令的時候,解析動做才能進行。相對的,其他可觸發解析的指令都是靜態的,便可以在剛剛完成加載階段,尚未開始執行代碼時就開始進行解析。

    解析動做主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行,這裏主要介紹前面4種,後面三種與JDK新增的動態語言支持息息相關,暫時這裏很少作贅述,前面三種分別對於常量池的CONSTANT_(Class、Fieldref、Methodref、InterfaceMethodref)_info

    1 . 類或接口的解析

    假設在類W要把一個從未解析過的符號引用N解析爲一個類或接口O的直接引用,那虛擬機完成整個過程主要分爲如下三個步驟。

    • 若是O不是一個數組類型,那虛擬機將會把表明N的全限定名傳遞給W的類加載器中去加載這個類O。在加載過程當中,因爲元數據驗證,字節碼驗證的須要,有可能觸發其餘相關類的加載動做,一旦這個加載過程出現了異常,解析過程就宣告失敗。

    • 若是O是一個數組類型,而且數組類型爲對象(描述符爲[Lxxx/xxx),那將會按照上面的規則加載數組元素類型,若是N的描述符如前面鎖假設的形式,那麼就會加載該元素類型的對象,接着由虛擬機生成一個表明此數組維度和元素的數組對象。

    • 若是上面兩步沒有出現異常,那麼在c虛擬機中實際上已經成爲一個有效的類或接口了,但在解析完成前還要進行符號引用驗證,確認W是否具有對O的訪問權限,若是發現不具有訪問權限,將拋出IlleagalAccessError異常。

    2 . 字段解析

    解析一個未被解析過得字段符號引用,首先將會對字段表內class_index項中索引的CONSTANT_Class_info符號引用進行解析,也就是字段所屬的類和接口的符號引用,也就是說,欲解字段,必先解其所在類。

    • 解析完類後,若是類自己包含了簡單名稱和字段描述符都與目標匹配的字段,則直接返回該字段的直接引用

    • 若是該類實現了接口,將會按照繼承關係遞歸搜索各個接口和他的父接口,若是接口中包含了簡單名稱和字段描述符都與目標匹配的字段,則直接返回該字段的直接引用。

    • 若是該類不是Object的話,將會按照繼承關係遞歸搜索其父類,若是父類中包含了簡單名稱和字段描述符都與目標匹配的字段,則直接返回該字段的直接引用。

    • 若是以上步驟都失敗,那麼拋出NoSuchFieldError異常。

    • 一樣的若是不具有對返回的字段引用的訪問權限,拋出IlleagalAccessError異常。

    • 若是一個同名字段同時出如今類的接口和父類中,或者在本身父類的多個接口中出現,那麼編譯器將可能拒絕編譯。

    3 . 類方法解析

    類方法解析第一個步驟和字段解析同樣,也須要先解析出該方法所在的類。而後按照下面步驟進行後續的類方法搜索。

    • 1)類方法和接口方法符號引用的常量類型定義是分開的(一個是Methodref,一個是InterfaceMethodref),若是類方法表中發現索引的是一個接口,那麼會拋出IncompatibleClassChangeError異常。

    • 2)若是經過第一步,接着在類中查找是否包含了簡單名稱和字段描述符都與目標匹配的方法,則直接返回該方法的直接引用。

    • 3)不然,在類的父類中遞歸查找是否包含了簡單名稱和字段描述符都與目標匹配的方法,則直接返回該方法的直接引用。

    • 4)不然,在類實現的接口列表和他們的父接口中遞歸查找是否包含了簡單名稱和字段描述符都與目標匹配的方法,若是存在,說明該類是一個抽象類(若是不是抽象類,該類中會查找到這個方法),這時候拋出AbstractMethodError異常。

    • 5)以上步驟都不行,拋出NoSuchMethodError異常。

    • 6)一樣的若是不具有對返回的方法引用的訪問權限,拋出IlleagalAccessError異常。

    4 . 類方法解析

    老樣子,接口方法也須要先解析出接口方法表class_info想中索引的方法所屬的類或接口的符號引用。而後按照下面步驟進行後續的接口方法搜索。

    • 1)與類方法解析相反,若是在接口方法表中發現該接口所對應的是一個類而不是接口,拋出IncompatibleClassChangeError異常。

    • 2)若是經過第一步,接着在接口中查找是否包含了簡單名稱和字段描述符都與目標匹配的方法,則直接返回該方法的直接引用。

    • 3)不然,在接口的父接口中遞歸查找,直到Object類爲止,看是否包含了簡單名稱和字段描述符都與目標匹配的方法,則直接返回該方法的直接引用。

    • 4)以上步驟都不行,拋出NoSuchMethodError異常。

    • 5)由於接口方法默認都是public的沒因此不存在訪問權限,因此接口方法不會拋出IlleagalAccessError異常。

  • 初始化

    類初始化時類加載過程的最後一步,前面的類加載過程當中,除了在加載階段用戶應用程序能夠經過自定義類加載器參與以外,其他動做徹底由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼(或者說字節碼)。

    在準備階段,變量已經賦值過一次系統要求的初始值,而在初始化階段,則根據程序制定的計劃去初始化類變量和其餘資源,從另外一個角度來表達:初始化階段是指向類構造器<clinit>()方法的過程。

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

public class Parent {
    static {
        a=2;
        System.out.println("Parent init"+a);
    }
    public static  int a = 1;
}
複製代碼

上面代碼中能夠在代碼塊中對a進行賦值,可是沒啥做用,由於會被後面的a從新賦值爲1,並且代碼塊內不能調用下面的類變量,會顯示illeagal forward reference錯誤

<clinit>()方法與類的構造方法,也就是實例構造器 <init>()不一樣,它不須要顯示地調用它父類構造器,虛擬機會保證在子類的 <clinit>()方法執行以前,父類的 <clinit>()方法已經執行完畢,也就是說,父類中定義的靜態語句塊要因爲子類的變量賦值操做,所以在虛擬機中第一個被執行的 <clinit>()方法的類確定是Object。

下面例子中輸出的結果就是2,由於父類的靜態賦值操做比子類先執行

public class Parent {
    public static  int a = 1;
    static {
        a=2;
    }
}
public class Son extends Parent{
      public static int b=a;
}
 public static void main(String[] args) {
        System.out.println("args = [" + Son.b + "]");
    }
複製代碼

<clinit>()方法不是必須的,若是一個類中沒有靜態語句塊,也沒有對類變量的賦值操做,那麼編譯器能夠不爲這個類生成 <clinit>()方法。

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

虛擬機會保證一個類的 <clinit>()方法在多線程環境中被正確的加鎖,用不,若是多個線程同時去初始化一個類,那麼只有一個線程回去執行這個類 <clinit>()方法,其餘線程都須要阻塞等待,這也是靜態單例實現的原理。

  • 總結

    本文內容來自於《深刻Java虛擬機》,感興趣的朋友能夠入這本書看看。
相關文章
相關標籤/搜索