理解JVM 類加載機制

什麼是類的加載機制

虛擬機把類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這既是虛擬機的類加載機制

類加載的過程(生命週期)

類從被加載到虛擬機內存開始,到卸載爲止,它的整個生命週期包括:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸載(Unloading)7個階段,其中驗證、準備、解析三個部分被稱爲連接(Linking)。

其中加載、驗證、準備、初始化和卸載這5個階段的順序是確定的,類加載的過程會按照這種順序執行,而解析階段不一定,它在某些情況下可以在初始化階段之後再開始,這是爲了支持Java語言的運行時綁定(動態綁定)

 

可能對這些東西看完之後我們還是不太理解,所以我先引入一個面試題看下:

class Grandpa
{
    static
    {
        System.out.println("爺爺在靜態代碼塊");
    }
}    
class Father extends Grandpa
{
    static
    {
        System.out.println("爸爸在靜態代碼塊");
    }

    public static int factor = 25;

    public Father()
    {
        System.out.println("我是爸爸~");
    }
}
class Son extends Father
{
    static 
    {
        System.out.println("兒子在靜態代碼塊");
    }

    public Son()
    {
        System.out.println("我是兒子~");
    }
}
public class InitializationDemo
{
    public static void main(String[] args)
    {
        System.out.println("爸爸的歲數:" + Son.factor);	//入口
    }
}

請寫出最後的輸出字符串。

正確答案是:

爺爺在靜態代碼塊
爸爸在靜態代碼塊
爸爸的歲數:25

我想很多人看完之後是完全不知道怎麼去解,但是如果我們對java類機制理解的話,就能知道怎麼去解決這個問題了。

我們再來說下類的加載機制的幾個過程:

加載

對於加載過程最爲官方的描述。

加載階段是類加載過程的第一個階段。在這個階段,JVM 的主要目的是將字節碼從各個位置(網絡、磁盤等)轉化爲二進制字節流加載到內存中,接着會爲這個類在 JVM 的方法區創建一個對應的 Class 對象,這個 Class 對象就是這個類各種數據的訪問入口。

其實加載階段用一句話來說就是:把代碼數據加載到內存中,創建了一個Class對象。

驗證

當 JVM 加載完 Class 字節碼文件並在方法區創建對應的 Class 對象之後,JVM 便會啓動對該字節碼流的校驗,只有符合 JVM 字節碼規範的文件才能被 JVM 正確執行。這個校驗過程大致可以分爲下面幾個類型:

  • JVM規範校驗。JVM 會對字節流進行文件格式校驗,判斷其是否符合 JVM 規範,是否能被當前版本的虛擬機處理。例如:文件是否是以 0x cafe bene開頭,主次版本號是否在當前虛擬機處理範圍之內等。
  • 代碼邏輯校驗。JVM 會對代碼組成的數據流和控制流進行校驗,確保 JVM 運行該字節碼文件後不會出現致命錯誤。例如一個方法要求傳入 int 類型的參數,但是使用它的時候卻傳入了一個 String 類型的參數。一個方法要求返回 String 類型的結果,但是最後卻沒有返回結果。代碼中引用了一個名爲 Apple 的類,但是你實際上卻沒有定義 Apple 類。

當代碼數據被加載到內存中後,虛擬機就會對代碼數據進行校驗,看看這份代碼是不是真的按照JVM規範去寫的。所以驗證階段主要是從各方面去驗證這個lei是不是符合我們虛擬機的規範。

準備(重點)

當完成字節碼文件的校驗之後,JVM 便會開始爲類變量分配內存並初始化。這裏需要注意兩個關鍵點,即內存分配的對象以及初始化的類型。

  • 內存分配的對象。Java 中的變量有「類變量」和「類成員變量」兩種類型,「類變量」指的是被 static 修飾的變量,而其他所有類型的變量都屬於「類成員變量」。在準備階段,JVM 只會爲「類變量」分配內存,而不會爲「類成員變量」分配內存。「類成員變量」的內存分配需要等到初始化階段纔開始。

例如下面的代碼在準備階段,只會爲 factor 屬性分配內存,而不會爲 website 屬性分配內存。

public static int factor = 3;
public String website = "www.cnblogs.com/chanshuyi";
  • 初始化的類型。在準備階段,JVM 會爲類變量分配內存,併爲其初始化。但是這裏的初始化指的是爲變量賦予 Java 語言中該數據類型的零值,而不是用戶代碼裏初始化的值。

例如下面的代碼在準備階段之後,sector 的值將是 0,而不是 3。

public static int sector = 3;

但如果一個變量是常量(被 static final 修飾)的話,那麼在準備階段,屬性便會被賦予用戶希望的值。例如下面的代碼在準備階段之後,number 的值將是 3,而不是 0。

public static final int number = 3;

之所以 static final 會直接被複制,而 static 變量會被賦予零值。其實我們稍微思考一下就能想明白了。

兩個語句的區別是一個有 final 關鍵字修飾,另外一個沒有。而 final 關鍵字在 Java 中代表不可改變的意思,意思就是說 number 的值一旦賦值就不會在改變了。既然一旦賦值就不會再改變,那麼就必須一開始就給其賦予用戶想要的值,因此被 final 修飾的類變量在準備階段就會被賦予想要的值。而沒有被 final 修飾的類變量,其可能在初始化階段或者運行階段發生變化,所以就沒有必要在準備階段對它賦予用戶想要的值。

解析

該階段是把類中的符號引用轉換爲直接引用,解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程,解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行。符號引用就是一組符號來描述目標,可以是任何字面量。該解析過程又分爲4種引用解析過程,類或接口解析、字段解析、類方法解析、接口方法解析

初始化(重點)

初始化,爲類的靜態變量賦予正確的初始值,JVM負責對類進行初始化,主要對類變量進行初始化。在Java中對類變量進行初始值設定有兩種方式:

  • [x] 聲明類變量是指定初始值。
  • [x] 使用靜態代碼塊爲類變量指定初始值。

JVM 會根據語句執行順序對類對象進行初始化,一般來說當 JVM 遇到下面 5 種情況的時候會觸發初始化:

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

看到上面幾個條件你可能會暈了,其實這幾條知道一下就好。

類什麼時候才被初始化:

1)創建類的實例,也就是new一個對象

2)訪問某個類或接口的靜態變量,或者對該靜態變量賦值

3)調用類的靜態方法

4)反射(Class.forName())

5)初始化一個類的子類(會首先初始化子類的父類)

6)JVM啓動時標明的啓動類,即文件名和類名相同的那個類

類的初始化步驟:

1)如果這個類還沒有被加載和鏈接,那先進行加載和鏈接

2)假如這個類存在直接父類,並且這個類還沒有被初始化,那就初始化直接的父類(不適用於接口)

3 ) 假如類中存在初始化語句(如static變量和static塊),那就依次執行這些初始化語句。

看到這裏我們來分析下上面的那個面試題:

對面上面的這個例子,我們可以從入口開始分析一路分析下去:

  • 首先程序到 main 方法這裏,使用標準化輸出 Son 類中的 factor 類成員變量,但是 Son 類中並沒有定義這個類成員變量。於是往父類去找,我們在 Father 類中找到了對應的類成員變量,於是觸發了 Father 的初始化。
  • 但根據我們上面說到的初始化的 5 種情況中的第 3 種(當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化)。我們需要先初始化 Father 類的父類,也就是先初始化 Grandpa 類再初始化 Father 類。於是我們先初始化 Grandpa 類輸出:「爺爺在靜態代碼塊」,再初始化 Father 類輸出:「爸爸在靜態代碼塊」。
  • 最後,所有父類都初始化完成之後,Son 類才能調用父類的靜態變量,從而輸出:「爸爸的歲數:25」。

也許會有人問爲什麼沒有輸出「兒子在靜態代碼塊」這個字符串?

這是因爲對於靜態字段,只有直接定義這個字段的類纔會被初始化(執行靜態代碼塊)因此通過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。

類加載器

說到這裏,我們再來簡單說下類加載器。什麼是類加載器?

虛擬機設計團隊把類加載階段中的通過一個類的全限定名來獲取描述此類的二進制字節流的動作放到了Java虛擬機的外部去實現,以便讓應用程序自己決定如何去獲取所需要的類,實現這個動作的代碼模塊稱爲類加載器

一般分爲三種類加載器:

  • 啓動類加載器(Bootstrap ClassLoader)
該類加載器負責將java_home\lib目錄下或被-Xbootclasspath參數所指定的路徑中並且是虛擬機識別的類庫加載到虛擬機內存中。啓動類加載器無法被Java程序直接引用,用戶在編寫自定義類加載器時,如果需要把加載請求委派給引導類加載器,那直接使用null代替即可
  • 擴展類加載器(Extension ClassLoader)
該加載器負責加載java_home\lib\ext目錄下的或被java.ext.dirs系統變量所指定的路徑中的所有類庫,開發者可以直接使用擴展類加載器
  • 應用程序類加載器(Application ClassLoader)
該類加載器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也稱他爲系統類加載器。它負責加載用戶類路徑(ClassPath)下所指定的類庫,開發者可以直接使用該類加載器

加載某個類的class文件時,Java虛擬機採用的是雙親委派模式即把請求交由父類處理,它是一種任務委派模式。

雙親委派模式

  • 雙親委派模型的工作流程
如果一個類加載器收到了類加載的請求,它首先不會自己嘗試加載這個類,而是把請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的類加載請求最終都會傳遞給頂層的啓動類加載器,只有當父類反饋無法完成這個加載請求時,子加載器纔會嘗試自己去加載
  • 雙親委派模型的優點
1、Java類隨着類加載器具備了帶有優先級的層次關係
例如:類java.lang.Object,它存放在rt.jar中,無論哪個加載器要加載這個類都要委派給啓動類加載器進行加載,因此Object類在各種類加載器環境中都是同一個類。反之,如果用戶自定義了一個稱爲java.lang.Object類,並放在了程序的ClassPath下,那系統中就會出現多個不同的Object類,應用程序將變得混亂
2、保證Java程序的穩定運行
實現雙親委派的代碼都集中在java.lang.ClassLoader的loadClass()方法之中,它會先檢查是否已經被加載過,若沒有加載則調用父加載器loadClass()方法,如果父容器爲空則默認使用啓動類加載器作爲父加載器。如果父類加載失敗,拋出ClassNotFoundException異常後,再調用自己的findClass()方法進行加載

                                                          github:https://github.com/servef-toto/luu_yinchuishiting.git 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章