理解JVM(四):JVM類載入機制

Joepis發表於2018-06-21

Class檔案

我們寫的Java程式碼,經過編譯器編譯之後,就成為了.class檔案,從本地機器碼變成了位元組碼。Class檔案是一組以8位位元組為基礎單位的二進位制流,各個資料專案嚴格按照順序緊湊地排列在Class檔案之中,中間沒有新增任何分隔符,這使得整個Class檔案中儲存的內容幾乎全部是程式執行的必要資料,沒有空隙存在。Class檔案中只有2種資料結構:無符號數和表。

每個Class檔案的頭4個位元組稱為魔數(Magic Number),值為0xCAFEBABE。緊接著4個位元組是Class檔案的版本號。再往後,就是類的具體資訊了,比如常量池、類索引、父類索引、介面索引、欄位、方法等資訊了。

所謂類的載入,就是把Class檔案讀到記憶體中。

類的生命週期

理解JVM(四):JVM類載入機制
類從被載入到虛擬機器記憶體中開始,到解除安裝出記憶體為止,它的整個生命週期包括:載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)7個階段。其中驗證、準備、解析3個部分統稱為連線(Linking)。

載入、驗證、準備、初始化和解除安裝這5個階段的順序是確定的,類的載入過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的執行時繫結(也稱為動態繫結或晚期繫結)。注意,是按部就班地“開始”,而不是按部就班地“進行”或“完成”,強調這點是因為這些階段通常都是互相交叉地混合式進行的,通常會在一個階段執行的過程中呼叫、啟用另外一個階段。

載入

在載入階段,虛擬機器做3件事:

  1. 通過一個類的全限定名來獲取定義此類的二進位制位元組流。
  2. 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
  3. 在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。

驗證

驗證是連線階段的第一步,這一階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。

驗證階段大致上會完成4個階段的檢驗動作

  1. 檔案格式驗證:驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。比如是否以魔數0xCAFEBABE開頭,主、次版本號是否能被當前虛擬機器處理,常量型別,指向常量的索引是否符合要求等。這階段的驗證是基於二進位制位元組流進行的,只有通過了這個階段的驗證後,位元組流才會進入記憶體的方法區中進行儲存,所以後面的3個驗證階段全部是基於方法區的儲存結構進行的,不會再直接操作位元組流。
  2. 後設資料驗證:對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求。比如繼承關係。
  3. 位元組碼驗證:對類的方法體進行校驗分析,保證被校驗類的方法在執行時不會做出危害虛擬機器安全的事件。通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。
  4. 符號引用驗證:對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗,確保解析動作能正常執行。它發生在虛擬機器將符號引用轉化為直接引用的時候,這個轉化動作將在連線的第三階段——解析階段中發生。

驗證階段是非常重要的,但不是必須的。它對程式執行期沒有影響,如果所引用的類經過反覆驗證,那麼可以考慮採用-Xverify:none引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。

準備

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。這個階段中有兩個容易產生混淆的概念需要強調一下,首先,這時候進行記憶體分配的僅包括類變數(被static修飾的變數),而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在Java堆中。其次,這裡所說的初始值“通常情況”下是資料型別的零值。

假設一個類變數的定義為:public static int value = 123;

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

當然也有特殊情況:如果類欄位的欄位屬性表中存在ConstantValue屬性,那在準備階段變數value就會被初始化為ConstantValue屬性所指定的值。

假設上面類變數value的定義變為:public static final int value = 123;

編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機器就會根據ConstantValue的設定將value賦值為123。

解析

解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符7類符號引用進行。

  • 符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可 以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的 記憶體佈局無關,引用的目標並不一定已經載入到記憶體中。各種虛擬機器實現的記憶體佈局可以各 不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義 在Java虛擬機器規範的Class檔案格式中。
  • 直接引用(Direct References):直接引用可以是直接指向目標的指標、相對偏移量或是 一個能間接定位到目標的控制程式碼。直接引用是和虛擬機器實現的記憶體佈局相關的,同一個符號引 用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目 標必定已經在記憶體中存在。

初始化

這一步開始執行類中定義的Java程式程式碼(或者說是位元組碼)。虛擬機器會保證一個類的初始化方法在多執行緒環境中被正確地加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的初始化方法,其他執行緒都需要阻塞等待,直到活動執行緒執行完畢。

JVM初始化步驟

  1. 假如這個類還沒有被載入和連線,則程式先載入並連線該類
  2. 假如該類的直接父類還沒有被初始化,則先初始化其直接父類
  3. 假如類中有初始化語句,則系統依次執行這些初始化語句

類初始化時機

只有當主動使用一個類的時候才會觸發這個類的初始化,類的主動使用包括以下六種:

  • 建立類的例項,也就是new的方式
  • 訪問某個類或介面的靜態變數,或者對該靜態變數賦值
  • 呼叫類的靜態方法
  • 反射,比如Class.forName("com.mysql.jdbc.Driver")
  • 初始化某個類的子類,則其父類也會被初始化
  • Java虛擬機器啟動時被標明為啟動類的類(Java Test),直接使用java.exe命令來執行某個主類

類載入器

虛擬機器設計團隊把類載入階段中的“通過一個類的全限定名來獲取描述此類的二進位制位元組流”這個動作放到Java虛擬機器外部去實現,以便讓應用程式自己決定如何去獲取所需要的類。實現這個動作的程式碼模組稱為“類載入器”。

雙親委派模型

從Java虛擬機器的角度來講,只存在兩種不同的類載入器:一種是啟動類載入器(Bootstrap ClassLoader),這個類載入器使用C++語言實現,是虛擬機器自身的一部分;另一種就是所有其他的類載入器,這些類載入器都由Java語言實現,獨立於虛擬機器外部,並且全都繼承自抽象類java.lang.ClassLoader。

從Java開發人員的角度來看,類載入器可以劃分為以下3種:

  1. 啟動類載入器(Bootstrap ClassLoader):負責載入存放在JAVA_HOME\lib目錄中的,或被-Xbootclasspath引數指定的路徑中的,並且能被虛擬機器識別的類庫(如rt.jar,所有的java.開頭的類均被Bootstrap ClassLoader載入)。啟動類載入器是無法被Java程式直接引用的。
  2. 擴充套件類載入器(Extension ClassLoader):這個載入器由sun.misc.Launcher$ExtClassLoader實現,它負責載入JAVA_HOME\lib\ext目錄中的,或者被java.ext.dirs系統變數所指定的路徑中的所有類庫,開發者可以直接使用擴充套件類載入器。
  3. 應用程式類載入器(Application ClassLoader):該類載入器由sun.misc.Launcher$AppClassLoader來實現,它負責載入使用者類路徑(ClassPath)所指定的類,開發者可以直接使用該類載入器,如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器。

我們的應用程式都是由這3種類載入器互相配合進行載入的,如果有必要,還可以加入 自己定義的類載入器。

理解JVM(四):JVM類載入機制

雙親委派模型要求除了頂層的啟動類載入器外,其餘的類載入器都應當有自己的父類載入器。這裡類載入器之間的父子關係一般不會以繼承的關係來實現,而是都使用組合關係來複用父載入器的程式碼。它不是強制性的約束模型,而是Java設計者推薦的一種類載入器實現方式。

雙親委派模型的工作過程:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的類載入器都是如此,因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有當父載入器反饋自己無法完成這個載入請求(它的搜尋範圍中沒有找到所需的類)時,子載入器才會嘗試自己去載入。

ClassLoader原始碼分析:

public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
}

protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        // 首先判斷該型別是否已經被載入
        Class c = findLoadedClass(name);
        if (c == null) {
            //如果沒有被載入,就委託給父類載入或者委派給啟動類載入器載入
            try {
                if (parent != null) {
                     //如果存在父類載入器,就委派給父類載入器載入
                    c = parent.loadClass(name, false);
                } else {
                    //如果不存在父類載入器,就檢查是否是由啟動類載入器載入的類,通過呼叫本地方法native Class findBootstrapClass(String name)
                    c = findBootstrapClass0(name);
                }
            } catch (ClassNotFoundException e) {
                // 如果父類載入器和啟動類載入器都不能完成載入任務,才呼叫自身的載入功能
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
複製程式碼

通過分析原始碼,我們知道,雙親委派模型可以保證每個類都只會被載入一次(類似快取機制)。

參考

相關文章