天天看點

【深入了解JVM】:類加載機制概述類加載的時機類加載的過程

概述

虛拟機把描述類的資料從Class檔案加載到記憶體,并對資料進行校驗、轉換解析和初始化,最終形成可以被虛拟機直接使用的Java類型,這就是虛拟機的類加載機制。

與那些在編譯時需要進行連結工作的語言不同,在Java語言裡,類型的加載、連接配接和初始化過程都是在程式運作期間完成的,例如

import java.util.*

下面包含很多類,但是,在程式運作的時候,虛拟機隻會加載哪些我們程式需要的類。這種政策雖然會令類加載時稍微增加一些性能開銷,但是會為Java應用程式提供高度的靈活性。

類加載的時機

類從建立起(這裡的類也可能是接口,下同),就注定了其是有生命周期的(這裡的生命周期指的是類在運作期間所經曆的過程,與是否存儲在存儲媒體上無關)。類從被虛拟機加載到記憶體中開始,到解除安裝出記憶體為止,它的生命周期經曆了加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading),一共七個階段,其中驗證、準備、解析部分統稱為連接配接。這七個階段可以用如下圖描述:

【深入了解JVM】:類加載機制概述類加載的時機類加載的過程

從上圖中可以明顯看出各個階段是有順序的,加載、驗證、準備、初始化這個5個階段的順序是固定的,也就是說類的加載過程必須按照這種順序按部就班開始;解析階段則不一定,解析階段的工作完全可能在初始化之後才開始,之是以這麼設計,就是為了支援Java語言的動态綁定。還有一點需要注意的是,雖然上述的5個階段可能按照順序開始,但是并不是說一個接一個階段完成後才開始,一個階段的進行完全可能激活另一個階段的進行,交叉混合式的進行。

那麼什麼情況下需要開始類加載過程的第一個階段,加載到記憶體中呢?這就不得不涉及兩個概念:主動引用和被動引用。根據Java虛拟機的規範,隻有5中情況屬于主動引用:

  1. 遇到new(使用new 關鍵字執行個體化一個對象)、getstatic(讀取一個類的靜态字段)、putstatic或者invokestatic(設定一個類的靜态字段)這4條指令的時候,如果累沒有進行過初始化。則需要先觸發其初始化。
  2. 使用反射進行反射調用的時候,如果類沒有初始化,則需要先觸發其初始化。
  3. 當初始化一個類的時候,如果其父類沒有初始化,則需要先觸發其父類的初始化
  4. 程式啟動需要觸發main方法的時候,虛拟機會先觸發這個類的初始化
  5. 當使用jdk1.7的動态語言支援的時候,如果一個java.lang.invoke.MethodHandler執行個體最後的解析結果為REF_getStatic、REF_pusStatic、REF_invokeStatic的方法句柄(句柄中包含了對象的執行個體資料和類型資料,句柄是通路對象的一種方法。句柄存儲在堆中),并且句柄對應的類沒有被初始化,那麼需要先觸發這個類的初始化。

5種之外情況就是被動引用。被動引用的經典例子有:

  1. 通過子類引用父類的靜态字段

    這種情況不會導緻子類的初始化,因為對于靜态字段,隻有直接定義靜态字段的類才會被觸發初始化,子類不是定義這個靜态字段的類,自然不能被執行個體化。

  2. 通過數組定義來引用類,不會觸發該類的初始化

    例如, Clazz[] arr = new Clazz[10];并不會觸發。

  3. 常量不會觸發定義常量的類的初始化

    因為常量在編譯階段會存入調用常量的類的常量池中,本質上并沒有引用定義這個常量的類,是以不會觸發定義這個常量的類的初始化。

對于這5種主動引用會觸發類進行初始化的場景,在java虛拟機規範中限定了“有且隻有”這5種場景會觸發類的加載。

類加載的過程

加載

在加載階段虛拟機需要完成以下三件事:

  1. 通過一個類的全限定名稱來擷取此類的二進制位元組流
  2. 将這個位元組流所代表的靜态存儲結構轉化為方法區的運作時資料結構
  3. 在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種資料的通路入口

這三件事在Java虛拟機中并沒有說的很詳細,比如類的全限定名稱是如何加載進來的,以及從哪裡加載進來的。通常來講,一個類的全限定名稱可以從zip、jar包中加載,也可以從網絡中擷取,也可以在運作的時候生成(這點最明顯的技術展現就是反射機制)。

對于類的加載,可以分為數組類型和非數組類型,對于非數組類型可以通過系統的引導類加載器進行加載,也可以通過自定義的類加載器進行加載。這點是比較靈活的。而對于數組類型,數組類本身不通過類加載器進行加載,而是通過Java虛拟機直接進行加載的,那麼是不是數組類型的類就不需要類加載器了呢?答案是否定的。因為當數組去除所有次元之後的類型最終還是要依靠類加載器進行加載的,是以數組類型的類與類加載器的關系還是很密切的。

通常一個數組類型的類進行加載需要遵循以下的原則:

  1. 如果數組的元件類型(也就是數組類去除一個次元之後的類型,比如對于二維數組,去除一個次元之後是一個一維數組)是引用類型,那麼遞歸采用上面的過程加載這個元件類型
  2. 如果數組類的元件類型不是引用類型,比如是基本資料類型,Java虛拟機将把數組類标記為與引導類加載器關聯
  3. 數組類的可見性與元件類型的可見性是一緻的。如果元件類型不是引用類型,那麼數組類的可見性是public,意味着元件類型的可見性也是public。

前面已經介紹過,加載階段與連接配接階段是交叉進行的,是以可能加載階段還沒有完成,連接配接階段就已經開始。但是即便如此,記載階段與連接配接階段之間的開始順序仍然保持着固定的順序。

驗證

驗證階段的目的是為了確定Class位元組流中包含的資訊符合目前虛拟機的要求,并且不會危害虛拟機的安全。

我們知道Java語言具有相對的安全性(這裡的安全性展現為兩個方面:一是Java語言本身特性,比如Java去除指針,這點可以避免對記憶體的直接操作;二是Java所提供的沙箱運作機制,Java保證所運作的機制都是在沙箱之内運作的,而沙箱之外的操作都不可以運作)。但是需要注意的是Java虛拟機處理的Class檔案并不一定是是從Java代碼編譯而來,完全可能是來自其他的語言,甚至可以直接通過十六進制編輯器書寫Class檔案(當然前提是編寫的Class檔案符合規範)。從這個角度講,其他來源的Class檔案是不可能都保證其安全性的。是以如果Java虛拟機都信任其加載進來的Class檔案,那麼很有可能會造成對虛拟機自身的危害。

虛拟機的驗證階段主要完後以下4項驗證:檔案格式驗證、中繼資料驗證、位元組碼驗證、符号引用驗證。(結合前文,檢視Class類檔案結構)

檔案格式驗證

這裡的檔案格式是指Class的檔案規範,這一步的驗證主要保證加載的位元組流(在計算機中不可能是整個Class檔案,隻有0和1,也就是位元組流)符合Class檔案的規範(根據前面對Class類檔案的描述,Class檔案的每一個位元組表示的含義都是确定的。比如前四個位元組是否是一個魔數等)以及保證這個位元組流可以被虛拟機接受處理。

在Hotspot的規範中,對檔案格式的驗證遠不止這些,但是隻有通過檔案格式的驗證才能進入方法區中進行存儲。是以自然也就知道,後面階段的驗證工作都是在方法區中進行的。

中繼資料驗證

中繼資料可以了解為描述資料的資料,更通俗的說,中繼資料是描述類之間的依賴關系的資料,比如Java語言中的注解使用(使用@interface建立一個注解)。中繼資料驗證主要目的是對類的中繼資料資訊進行語義校驗,保證不存在不符合Java語言規範(Java文法)的中繼資料資訊。

具體的驗證資訊包括以下幾個方面:

  1. 這個類是否有父類(除了java.lang.Object外其餘的類都應該有父類)
  2. 這個類的父類是否繼承了不允許被繼承的類(比如被final修飾的類)
  3. 如果這個類不是抽象類,是否實作了其父類或者接口中要求實作的方法
  4. 類中的字段、方法是否與父類産生沖突(比如是否覆寫了父類的final字段)

位元組碼驗證

這個階段主要對類的方法體進行校驗分析。通過了位元組碼的驗證并不代表就是沒有問題的,但是如果沒有通過驗證就一定是有問題的。整個位元組碼的驗證過程比這個複雜的多,由于位元組碼驗證的高度複雜性,在jdk1.6版本之後的虛拟機增加了一項優化,Class類檔案結構這篇文章中說到過有一個屬性:StackMapTable屬性。可以簡單了解這個屬性是用于檢查類型是否比對。

符号引用驗證

這個驗證是最後階段的驗證,符号引用是Class檔案的邏輯符号,直接引用指向的方法區中某一個位址,在解析階段,将符号引用轉為直接引用,這裡隻進行轉化前的比對性校驗。符号引用驗證主要是對類自身以外的資訊進行比對性校驗。比如符号引用是否通過字元串描述的全限定名是否能夠找到對應點類。

  • 符号引用(Symbolic Reference)

    符号引用以一組符号來描述所引用的目标,符号引用可以是任何形式的字面量,隻要使用時能無歧義的定位到目标即可(符号字面量,還沒有涉及到記憶體)。符号引用與虛拟機實作的記憶體布局無關,引用的目标并不一定已經加載在記憶體中。各種虛拟機實作的記憶體布局可以各不相同,但是他們能接受的符号引用必須都是一緻的,因為符号引用的字面量形式明确定義在Java虛拟機規範的Class檔案格式中。

  • 直接引用(Direct Reference)

    直接引用可以是直接指向目标的指針、相對偏移量或是一個能間接定位到目标的句柄(可以了解為記憶體位址)。直接引用是與虛拟機實作的記憶體布局相關的,同一個符号引用在不同的虛拟機執行個體上翻譯出來的直接引用一般都不相同,如果有了直接引用,那引用的目标必定已經在記憶體中存在。

進行符号引用驗證的目的在于確定解析動作能夠正常執行,如果無法通過符号引用驗證那麼将會抛出java.lang.IncomingChangeError異常的子類。

準備

完成了驗證階段之後,就進入準備階段。準備階段是正式為變量配置設定記憶體空間并且設定類變量初始值。

需要注意的是,這時候進行記憶體配置設定的僅僅是類變量(也就是被static修飾的變量),執行個體變量是不包括的,執行個體變量的初始化是在對象執行個體化的時候進行初始化,而且配置設定的記憶體區域是Java堆。這裡的初始值也就是在程式設計中預設值,也就是零值。

例如public static int value = 123 ;value在準備階段後的初始值是0而不是123,因為此時尚未執行任何的Java方法,而把value指派為123的putStatic指令是程式被編譯後,存放在類構造器clinit()方法之中,把value指派為123的動作将在初始化階段才會執行。

特殊情況:如果類字段的字段屬性表中存在ConstantValue屬性,那在準備階段變量就會被初始化為ConstantValue屬性所指定的值,例如public static final int value = 123 編譯時javac将會為value生成ConstantValue屬性,在準備階段虛拟機就會根據ConstantValue的設定将變量指派為123。

解析

解析階段是将常量池中的符号引用替換為直接引用的過程(前面已經提到了符号引用與直接引用的差別)。在進行解析之前需要對符号引用進行解析,不同虛拟機實作可以根據需要判斷到底是在類被加載器加載的時候對常量池的符号引用進行解析(也就是初始化之前),還是等到一個符号引用被使用之前進行解析(也就是在初始化之後)。

到現在我們已經明白解析階段的時機,那麼還有一個問題是:如果一個符号引用進行多次解析請求,虛拟機中除了invokedynamic指令外,虛拟機可以對第一次解析的結果進行緩存(在運作時常量池中記錄引用,并把常量辨別為一解析狀态),這樣就避免了一個符号引用的多次解析。

解析動作主要針對的是類或者接口、字段、類方法、方法類型、方法句柄和調用點限定符7類符号引用。這裡主要說明前四種的解析過程。

類或者接口解析

要把一個類或者接口的符号引用解析為直接引用,需要以下三個步驟:

  1. 如果該符号引用不是一個數組類型,那麼虛拟機将會把該符号代表的全限定名稱傳遞給調用這個符号引用的類。這個過程由于涉及驗證過程是以可能會觸發其他相關類的加載
  2. 如果該符号引用是一個數組類型,并且該數組的元素類型是對象。我們知道符号引用是存在方法區的常量池中的,該符号引用的描述符會類似”[java/lang/Integer”的形式(描述符的概念詳見前文【深入了解JVM】:Class類檔案結構),将會按照上面的規則進行加載,虛拟機将會生成一個代表此數組對象的直接引用
  3. 如果上面的步驟都沒有出現異常,那麼該符号引用已經在虛拟機中産生了一個直接引用,但是在解析完成之前需要對符号引用進行驗證,主要是确認目前調用這個符号引用的類是否具有通路權限,如果沒有通路權限将抛出java.lang.IllegalAccess異常

字段解析

對字段的解析需要首先對其所屬的類進行解析,因為字段是屬于類的,隻有在正确解析得到其類的正确的直接引用才能繼續對字段的解析。對字段的解析主要包括以下幾個步驟:

  1. 如果該字段符号引用(後面簡稱符号)就包含了簡單名稱和字段描述符都與目标相比對的字段,則傳回這個字段的直接引用,解析結束
  2. 否則,如果在該符号的類實作了接口,将會按照繼承關系從下往上遞歸搜尋各個接口和它的父接口,如果在接口中包含了簡單名稱和字段描述符都與目标相比對的字段,那麼久直接傳回這個字段的直接引用,解析結束
  3. 否則,如果該符号所在的類不是Object類的話,将會按照繼承關系從下往上遞歸搜尋其父類,如果在父類中包含了簡單名稱和字段描述符都相比對的字段,那麼直接傳回這個字段的直接引用,解析結束
  4. 否則,解析失敗,抛出java.lang.NoSuchFieldError異常

    如果最終傳回了這個字段的直接引用,就進行權限驗證,如果發現不具備對字段的通路權限,将抛出java.lang.IllegalAccessError異常

類方法解析

進行類方法的解析仍然需要先解析此類方法的類,在正确解析之後需要進行如下的步驟:

  1. 類方法和接口方法的符号引用是分開的,是以如果在類方法表中發現class_index(類中方法的符号引用)的索引是一個接口,那麼會抛出java.lang.IncompatibleClassChangeError的異常
  2. 如果class_index的索引确實是一個類,那麼在該類中查找是否有簡單名稱和描述符都與目标字段相比對的方法,如果有的話就傳回這個方法的直接引用,查找結束
  3. 否則,在該類的父類中遞歸查找是否具有簡單名稱和描述符都與目标字段相比對的字段,如果有,則直接傳回這個字段的直接引用,查找結束
  4. 否則,在這個類的接口以及它的父接口中遞歸查找,如果找到的話就說明這個方法是一個抽象類,查找結束,傳回java.lang.AbstractMethodError異常(因為抽象類是沒有實作的)
  5. 否則,查找失敗,抛出java.lang.NoSuchMethodError異常

    如果最終傳回了直接引用,還需要對該符号引用進行權限驗證,如果沒有通路權限,就抛出java.lang.IllegalAccessError異常

接口方法解析

同類方法解析一樣,也需要先解析出該方法的類或者接口的符号引用,如果解析成功,就進行下面的解析工作:

  1. 如果在接口方法表中發現class_index的索引是一個類而不是一個接口,那麼也會抛出java.lang.IncompatibleClassChangeError的異常
  2. 否則,在該接口方法的所屬的接口中查找是否具有簡單名稱和描述符都與目标字段相比對的方法,如果有的話就直接傳回這個方法的直接引用。查找結束
  3. 否則,在該接口以及其父接口中查找,直到Object類,如果找到則直接傳回這個方法的直接引用

    否則,查找失敗

接口的所有方法都是public,是以不存在通路權限問題

初始化

到了初始化階段,虛拟機才開始真正執行Java程式代碼,前文講到對類變量的初始化,但那是僅僅賦初值,使用者自定義的值還沒有賦給該變量。隻有到了初始化階段,才開始真正執行這個自定義的過程,是以也可以說初始化階段是執行類構造器方法clinit() 的過程。那麼這個clinit() 方法是這麼生成的呢?

  • clinit() 是編譯器自動收集類中所有類變量的指派動作和靜态語句塊合并生成的。編譯器收集的順序是由語句在源檔案中出現的順序決定的。靜态語句塊中隻能通路到定義在靜态語句塊之前的變量,定義在它之後的變量,在前面的靜态語句塊可以指派,但是不能通路。

    示例代碼:

public class Test {
        static{
            i =;          //給變量指派可以正常編譯通過
            System.out.println(i);  //這句編譯器會提示“非法向前引用”
        }
        static int i = ;
}
           
  • clinit() 方法與類的構造器方法不同,因為前者不需要顯式調用父類構造器,因為虛拟機會保證在子類的clinit() 方法執行之前,父類的clinit() 方法已經執行完畢
  • 由于父類的clinit() 方法會先執行,是以就表示父類的static方法會先于子類的clinit() 方法執行。如下面的例子所示,輸出結果為2而不是1。
public class Parent {  
    public static int A = ;  
    static{  
       A = ;  
    }  
}    

public class Sub extends Parent{  
    public static int B = A;  
}   

public class Test {  
    public static void main(String[] args) {  
       System.out.println(Sub.B);  
    }  
}
           
  • clinit()方法對于類或者接口來說并不是必需的,如果一個類中沒有靜态語句塊也沒有對變量的指派操作,那麼編譯器可以不為這個類生成clinit()方法。
  • 接口中不能使用靜态語句塊,但仍然有變量指派的初始化操作,是以接口也會生成clinit()方法。但是接口與類不同,執行接口的clinit()方法不需要先執行父接口的clini>()方法。隻有當父接口中定義的變量被使用時,父接口才會被初始化。另外,接口的實作類在初始化時也不會執行接口的clinit()方法。
  • 虛拟機會保證一個類的clinit()方法在多線程環境中被正确地加鎖和同步。如果有多個線程去同時初始化一個類,那麼隻會有一個線程去執行這個類的clinit()方法,其它線程都需要阻塞等待,直到活動線程執行clinit()方法完畢。如果在一個類的clinit()方法中有耗時很長的操作,那麼就可能造成多個程序阻塞。

注意:解析和初始化在繼承關系中從下往上遞歸搜尋父類的特性,可以用來解釋繼承關系中的父類和子類的初始化順序(另一個原因是Java HotSpot虛拟機的記憶體布局配置設定政策的影響),具體的分析詳見文章【深入了解JVM】:Java類繼承關系中的初始化順序。

參考

1、周志明,深入了解Java虛拟機:JVM進階特性與最佳實踐,機械工業出版社