《深入理解java虛擬機器》第七章讀書筆記——虛擬機器類載入機制

Cuzzz發表於2023-02-19

系列文章目錄和關於我

一丶虛擬機器類載入機制是什麼

java虛擬機器將描述類的資料從class檔案載入到記憶體,並對資料進行校驗,轉換解析和初始化,最終形成可用被虛擬機器直接使用的java型別。

二丶類載入時機

1.什麼時候會觸發虛擬機器的類類載入暱?

  • 遇到new(使用new關鍵字例項化物件)getstatic(讀取一個類的靜態非final欄位)putstatic(設定一個類的非final靜態欄位),或invokestatic(呼叫一個型別的靜態方法)這些位元組碼指令的時候,如果類沒有經過初始化,那麼需要先觸發其初始化階段。
  • 使用java反射手段,對型別進行反射呼叫的時候,如果型別沒有進行初始化,那麼將先觸發其初始化。
  • 當都初始化類的時候,如果其父類沒有進行初始化,那麼則需要先觸發其父類的初始化。
  • 當虛擬機器啟動的時候,虛擬機器會先初始化使用者指定的主類
  • 當使用MethodHandler例項,並解析為REF_getstatic,REF_putstatic,REF_invokestatic,REF_newinvokeSpecial四種型別方法控制程式碼,並且這個方法控制程式碼對應的類沒有經過初始化,那麼先進行類的初始化
  • 如果一個介面定義了default方法,其實現類發生了初始化,那該介面要在其之前進行初始化

2.不會觸發類載入,但是有趣的幾種案例

2.1子類引用父類靜態屬性

public class SuperClass {
    public static int staticValue =1;
    public  static final int finalValue = 2;
    static {
        System.out.println("super");
    }
}

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


public class Main {

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

Main類的main方法執行,只會輸出super,說明並沒有觸發子類的初始化。這是因為:對於靜態欄位,只有直接定義這個欄位的類才會初始化

2.2 使用靜態final欄位,並不會觸發類的初始化

public class Main {

    public static void main(String[] args) {
        System.out.println(SuperClass.finalValue);
    }
}

使用靜態final欄位並不會觸發類的初始化。這是由於編譯階段透過常量傳播最佳化,已經將此常量的值直接儲存於SuperClass的常量池中,對常量的使用,都被轉化為SuperClass對自身常量池的引用了,Main的Class檔案並沒有SuperClass類的符號引用入口,編譯之後Main類和SuperClass並沒有任何聯絡了。

image-20230219175633495

這是Idea target中Main類,反編譯後的內容,可用看到並沒有對SuperClass的引用。

2.3 使用類的陣列型別,並不會觸發類的初始化

public class Main {

    public static void main(String[] args) {
        SubClass[] subClasses = new SubClass[10];
    }
}

上面所示程式碼,並不會觸發SubClass的初始化,因為SubClass[].class這種型別,是由虛擬機器自動生成,並繼承自Object型別,建立動作由newarry位元組碼指令觸發。

三丶類載入過程

image-20230219181223429

1.載入

  1. 透過類的全限定類名,獲取此類的二進位制位元組流、

    • 從zip壓縮包中獲取——jar,war 格式的基礎
    • 從網路中獲取 —— web applet 引用
    • 執行時計算生成——動態代理結束,使用java動態代理Proxy生成代理物件的時候,就是生成*$Proxy代理類的二進位制位元組流

    載入階段可用使用java虛擬機器裡內建的引導類載入器完成,也可以自定義類載入,重寫findClass 或者loadClass方法,可用自定義如何獲取類的二進位制位元組流。

  2. 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構

  3. 在堆中生成一個代表此類的Class物件,作為方法區這個類的各種資料資料的入口

image-20230219183145659

經過載入階段,java虛擬機器外部的二進位制位元組流就按照虛擬機器設定的格式儲存在方法區中了(圖中的執行時資料結構),型別資料儲存後,會在java堆中生成Class物件,作為方法區型別資料的訪問介面

注意這時還不能生成該類的例項,還需要進行驗證,準備,解析,初始化

2.驗證

驗證時為了保證class檔案位元組流中包含的資訊符合虛擬機器規範,保證這些資訊被當作程式碼執行後,不會危害虛擬機器的安全。

2.1 檔案格式校驗

驗證位元組流符合class檔案格式規範,並且可用被當前版本的虛擬機器處理。

2.2 後設資料校驗

對位元組碼描述資訊進行語義校驗,保證其描述的資訊符合虛擬機器規範

  • 當前類是否由父類,除了Object外所有類都需要由父類
  • 這個類是否繼承了被final修飾的類
  • 如果類不是抽象類,那麼是否實現了必須實現的方法
  • .....

2.3 位元組碼驗證

透過資料流分析,和控制流分析,確定程式語義是合法的,符合邏輯的。

2.4 符號引用驗證

符號引用驗證,發生在解析階段,就是驗證對類滋生外的各類資訊進行匹配型校驗。檢查該類是否缺少或者被禁止訪問它依賴的某些外部類,方法,欄位等。

3.準備

準備階段是正式為類中i黨員的變數(靜態屬性)分配記憶體和設定初始值的階段。

如果是static final型別的欄位在此階段會進行值的設定(static final int a= 1,在這個階段會設定a的值為1)

但是static屬性只會設定初值(static int a =1,在這個階段值為0)

4.解析

解析階段負責將常量池中符號引用,替換為直接引用。

  • 符號引用:

    用一組符號描述所引用的目標,可用是任何形式的字面量,可以沒有歧義的定位到目標。

  • 直接引用:

    指向目標的指標,相對偏移,或者能簡介定位到目標的控制程式碼。

    對於invokedynamic指令,並不會快取第一次解析的結果,invokedynamic又稱為動態呼叫限定符,動態意味著等程式執行到這條指令的時候才會去解析。其餘觸發解析的指令都是靜態的,可用在完成載入階段後進行解析。

5.初始化階段

準備階段已經進行賦零值的操作,初始化階段就是執行類的構造器<cinit>(),此方法由java編譯器自動生成,會自動收集類中所有類變數的賦值動作和靜態程式碼塊,併合併產生,收集的順序由原始檔中的順序決定。

  • 虛擬機器保證在呼叫子類的<cinit>()方法之前,父類的<cinit>()已經執行完畢。
  • 父類的<cinit>()先於子類執行
  • 如果一個類沒有靜態程式碼塊,也沒有對類靜態變數的賦值操作,那麼編譯器可用不生成<cinit>()方法
  • 介面中不能使用靜態程式碼塊,但仍然可有由變數的初始化操作,執行介面的<cinit>()不需要先執行其父類的<cinit>(),只有當父類的靜態變數被使用的時候才會呼叫父類的<cinit>()。介面實現類初始化的時候也不會呼叫介面的<cinit>()
  • 虛擬機器保證一個類的<cinit>()方法在多執行緒環境下,會正確的加鎖同步(為什麼說靜態程式碼塊的單例是執行緒安全的)。

四丶類載入器

類載入器的任務是根據一個類的全限定名來讀取此類的二進位制位元組流到JVM中,然後轉換為一個與目標類對應的java.lang.Class物件例項,在虛擬機器提供了3種類載入器,引導(Bootstrap)類載入器、擴充套件(Extension)類載入器、系統(System)類載入器(也稱應用類載入器)

類載入器在java程式中的作用不僅僅是類的載入階段,還會作用於Class物件的equals,isAssignableFrom,以及isInstance()和instanceof關鍵字,只有兩個類是由同一個類載入器載入的前提下,才能“相等”(滿足前面說的equals,isAssignableFrom等)。不同的類載入器載入相同的全限定類名,可能在java虛擬機器中存在多個獨立的類。

  • 啟動類載入器

    啟動類載入器主要載入的是JVM自身需要的類,這個類載入使用C++語言實現的,是虛擬機器自身的一部分,它負責將 <JAVA_HOME>/lib路徑下的核心類庫或-Xbootclasspath引數指定的路徑下的jar包載入到記憶體中,注意必由於虛擬機器是按照檔名識別載入jar包的,如rt.jar,如果檔名不被虛擬機器識別,即使把jar包丟到lib目錄下也是沒有作用的(出於安全考慮,Bootstrap啟動類載入器只載入包名為java、javax、sun等開頭的類)。

  • 擴充套件類載入器

    擴充套件類載入器是ExtClassLoader類,由Java語言實現的,是Launcher的靜態內部類,它負責載入<JAVA_HOME>/lib/ext目錄下或者由系統變數-Djava.ext.dir指定位路徑中的類庫,開發者可以直接使用標準擴充套件類載入器。

  • 應用程式類載入器

    AppClassLoader。它負責載入系統類路徑java -classpath-D java.class.path 指定路徑下的類庫,也就是我們經常用到的classpath路徑,開發者可以直接使用系統類載入器,一般情況下該類載入是程式中預設的類載入器,透過ClassLoader#getSystemClassLoader()方法可以獲取到該類載入器。

五丶雙親委派模型

image-20230219224746306

1.何為雙親委派

如果一個類載入器收到類載入請求,首先不會自己嘗試載入,而是把此請求交給父類載入器進行載入,因此最終所有的類都將交給啟動類載入器進行載入,只有當父載入器反饋自己無法完成這個載入請求(搜尋範圍內不存在目標類)子載入器才會去自己載入

image-20230219225254264

可用看到父類丟擲ClassNotFoundException後,也會嘗試自己去載入。

2.雙親委派的好處

雙親委派保證了,所有類的記載,首先交給父類載入器進行,保證相同的類只會存在一個(都交給了父,父如果載入不了,那麼子,最終相同的類肯定由相同的類載入器進行載入)。如果打破雙親委派,那麼我們可用自己實現java.lang.Object,系統中會出現多個Object類,java最基礎的行為都無法得到保證。

3.打破雙親委派

  • JNDI現在已經是Java的標準服務,它的程式碼由啟動類載入器去載入(在JDK 1.3時放進去的rt.jar),但JNDI的目的就是對資源進行集中管理和查詢,它需要呼叫由獨立廠商實現並部署在應用程式的ClassPath下的JNDI介面提供者(SPI,Service Provider Interface)的程式碼,但啟動類載入器不可能去載入ClassPath下的類。

    但是有了執行緒上下文類載入器就好辦了,JNDI服務使用執行緒上下文類載入器去載入所需要的SPI程式碼,也就是父類載入器請求子類載入器去完成類載入的動作,這種行為實際上就是打通了雙親委派模型的層次結構來逆向使用類載入器,實際上已經違背了雙親委派模型的一般性原則,但這也是無可奈何的事情。

  • Tomcat中可以部署多個web專案,為了保證每個web專案互相獨立,所以不能都由AppClassLoader載入,所以自定義了類載入器WebappClassLoader

    1. 先在本地快取中查詢是否已經載入過該類(對於一些已經載入了的類,會被快取在resourceEntries這個資料結構中),如果已經載入即返回,否則 繼續下一步。
    2. 讓系統類載入器(AppClassLoader)嘗試載入該類,主要是為了防止一些基礎類會被web中的類覆蓋,如果載入到即返回,返回繼續。
    3. 前兩步均沒載入到目標類,那麼web應用的類載入器將自行載入,如果載入到則返回,否則繼續下一步。
    4. 最後還是載入不到的話,則委託父類載入器(Common ClassLoader)去載入。

相關文章