深入淺出JVM(三)之HotSpot虛擬機器類載入機制

發表於2024-02-24

HotSpot虛擬機器類載入機制

類的生命週期

什麼叫做類載入?

類載入的定義: JVM把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗,解析和初始化,最終變成可以被JVM直接使用的Java型別(因為可以動態產生,這裡的Class檔案並不是具體存在磁碟中的檔案,而是二進位制資料流)

一個型別被載入到記憶體使用 到 結束解除安裝出記憶體,它的生命週期分為7個階段: 載入->驗證->準備->解析->初始化->使用->解除安裝

其中重要階段一般的開始順序: 載入->驗證->準備->解析->初始化

驗證,準備,解析合起來又稱為連線所以也可以是載入->連線->初始化

注意這裡的順序是一般的開始順序,並不一定是執行完某個階段結束後才開始執行下一個階段,也可以是執行到某個階段的中途就開始執行下一個階段

還有種特殊情況就是解析可能在初始化之後(因為Java執行時的動態繫結)

基本資料型別不需要載入,引用型別才需要被類載入

類載入階段

接下來將對這五個階段進行詳細介紹

Loading

載入

  • 載入的作用
  1. 透過這個類的全限定名來查詢並載入這個類的二進位制位元組流

    • JVM透過檔案系統載入某個class字尾檔案
    • 讀取jar包中的類檔案
    • 資料庫中類的二進位制資料
    • 使用類似HTTP等協議透過網路載入
    • 執行時動態生成Class二進位制資料流
  2. 將這個類所代表的靜態儲存結構(靜態常量池)轉化為方法區執行時資料結構(執行時常量池)
  3. 在堆中建立這個類的Class物件,這個Class物件是對方法區訪問資料的"入口"

    • 堆中例項物件中物件頭的型別指標指向它這個類方法區的類後設資料
  • 對於載入可以由JVM的自帶類載入器來完成,也可以透過開發人員自定義的類載入器來完成(實現ClassLoader,重寫findClass())

注意

  1. 陣列類是直接由JVM在記憶體中動態構造的,陣列中的元素還是要靠類載入器進行載入
  2. 反射正是透過載入建立的Class物件才能在執行期使用反射
Verification

驗證

  • 驗證的作用

    確保要載入的位元組碼符合規範,防止危害JVM安全

  • 驗證的具體劃分

    • 檔案格式驗證

      目的: 保證位元組流能正確解析並儲存到方法區之內,格式上符合Java型別資訊

      驗證位元組流是否符合Class檔案格式規範(比如Class檔案主,次版本號是否在當前虛擬機器相容範圍內...)

    • 後設資料驗證

      目的: 對類的後設資料資訊進行語義驗證

      後設資料:簡單的來說就是描述這個類與其他類之間關係的資訊

      後設資料資訊驗證(舉例):

      1. 這個類的父類有沒有繼承其他的最終類(被final修飾的類,不可讓其他類繼承)
      2. 若這個類不是抽象類,那這個類有沒有實現(抽象父類)介面的所有方法
    • 位元組碼驗證(驗證中最複雜的一步)

      目的: 對位元組碼進行驗證,保證校驗的類在執行時不會做出對JVM危險的行為

      位元組碼驗證舉例:

      1. 型別轉換有效: 子類轉換為父類(安全,有效) 父類轉換為子類(危險)
      2. 進行算術運算,使用的是否是相同型別指令等
    • 符號引用驗證

      發生在解析階段前:符號引用轉換為直接引用

      目的: 保證符號引用轉為直接引用時,該類不缺少它所依賴的資源(外部類),確保解析可以完成

驗證階段是一個非常重要的階段,但又不一定要執行(因為許多第三方的類,自己封裝的類等都被反覆"實驗"過了)

在生產階段可以考慮關閉 -Xverify:none以此來縮短類載入時間

Preparation

準備

準備階段為類變數(靜態變數)分配記憶體並預設初始化

  • 分配記憶體

    • 邏輯上應該分配在方法區,但是因為hotSpot在JDK7時將字串常量,靜態變數挪出永久代(放在堆中)
    • 實際上它應該在堆中
  • 預設初始化

    • 類變數一般的預設初始化都是初始化該型別的零值

      型別零值
      byte(byte)0
      short(short)0
      int0
      long0L
      float0.0F
      double0.0
      booleanfalse
      char'\u0000'
      referencenull
    • 特殊的類變數的欄位屬性中存在ConstantValue屬性值,會初始化為ConstantValue所指向在常量池中的值
    • 只有被final修飾的基本型別或字面量且要賦的值在常量池中才會被加上ConstantValue屬性

image-20210516122919733.png

Resolution

解析

  • 解析的作用

    將常量池中的常量池中符號引用替換為直接引用(把符號引用代表的地址替換為真實地址)

    • 符號引用

      • 使用一組符號描述引用(為了定位到目標引用)
      • 與虛擬機器記憶體佈局無關
      • 還是符號引用時目標引用不一定被載入到記憶體
    • 直接引用

      • 直接執行目標的指標,相對偏移量或間接定位目標引用的控制程式碼
      • 與虛擬機器記憶體佈局相關
      • 解析直接引用時目標引用已經被載入到記憶體中
  • 並未規定解析的時間

    可以是類載入時就對常量池的符號引用解析為直接引用

    也可以在符號引用要使用的時候再去解析(動態呼叫時只能是這種情況)

  • 同一個符號引用可能會被解析多次,所以會有快取(標記該符號引用已經解析過),多次解析動作都要保證每次都是相同的結果(成功或異常)
類和介面的解析

當我們要訪問一個未解析過的類時

  1. 把要解析的類的符號引用 交給當前所在類的類載入器 去載入 這個要解析的類
  2. 解析前要進行符號引用驗證,如果當前所在類沒有許可權訪問這個要解析的類,丟擲異常IllegalAccessError
欄位的解析

解析一個從未解析過的欄位

  1. 先對此欄位所屬的類(類, 抽象類, 介面)進行解析
  2. 然後在此欄位所屬的類中查詢該欄位簡單名稱和描述符都匹配的欄位,返回它的直接引用

    • 如果此欄位所屬的類有父類或實現了介面,要自下而上的尋找該欄位
    • 找不到丟擲NoSuchFieldError異常
  3. 對此欄位進行許可權驗證(如果不具備許可權丟擲IllegalAccessError異常)

確保JVM獲得欄位唯一解析結果

如果同名欄位出現在父類,介面等中,編譯器有時會更加嚴格,直接拒絕編譯Class檔案

方法的解析

解析一個從未解析過的方法

  1. 先對此方法所屬的類(類, 抽象類, 介面)進行解析
  2. 然後在此方法所屬的類中查詢該方法簡單名稱和描述符都匹配的方法,返回它的直接引用

    • 如果此方法所屬類是介面直接丟擲IncompatibleClassChangeError異常
    • 如果此方法所屬的類有父類或實現了介面,要自下而上的尋找該方法(先找父類再找介面)
    • 如果在介面中找到了,說明所屬類是抽象類,丟擲AbstractMethodError異常(自身找不到,父類中找不到,最後在介面中找到了,說明他是抽象類),找不到丟擲NoSuchMethodError異常
  3. 對此方法進行許可權驗證(如果不具備許可權丟擲IllegalAccessError異常)
介面方法的解析

解析一個從未解析過的介面方法

  1. 先對此介面方法所屬的介面進行解析
  2. 然後在此介面方法所屬的介面中查詢該介面方法簡單名稱和描述符都匹配的介面方法,返回它的直接引用

    • 如果此介面方法所屬介面是類直接丟擲IncompatibleClassChangeError異常
    • 如果此方法所屬的介面有父介面,要自下而上的尋找該介面方法
    • 如果多個不同的介面中都存在這個介面方法,會隨機返回一個直接引用(編譯會更嚴格,這種情況應該會拒絕編譯)
  3. 找不到丟擲NoSuchMethodError
Initializtion

初始化

執行類構造器<clinit>的過程

  • 什麼是<clinit> ?

    • <clinit>是javac編譯器 在編譯期間自動收集類變數賦值的語句和靜態程式碼塊合併 自動生成的
    • 如果沒有對類變數賦值動作或者靜態程式碼塊<clinit>可能不會生成 (帶有ConstantValue屬性的類變數初始化已經在準備階段做過了,不會在這裡初始化)
  • 類和介面的類構造器

    • <clinit>又叫類構造器,與<init>例項構造器不同,類構造器不用顯示父類類構造器呼叫

      但是父類要在子類之前初始化,也就是完成類構造器

    • 介面

      執行介面的類構造器時,不會去執行它父類介面的類構造器,直到用到父介面中定義的變數被使用時才執行

  • JVM會保證執行<clinit>在多執行緒環境下被正確的加鎖和同步(也就是隻會有一個執行緒去執行<clinit>其他執行緒會阻塞等待,直到<clinit>完成)

     public class TestJVM {
         static class  A{
             static {
                 if (true){
                     System.out.println(Thread.currentThread().getName() + "<clinit> init");
                     while (true){
     ​
                     }
                 }
             }
         }
         @Test
         public void test(){
             Runnable runnable = new Runnable() {
                 @Override
                 public void run() {
                     System.out.println(Thread.currentThread().getName() + "start");
                     A a = new A();
                     System.out.println(Thread.currentThread().getName() + "end");
                 }
             };
     ​
             new Thread(runnable,"1號執行緒").start();
             new Thread(runnable,"2號執行緒").start();
         }
     ​
     }
     ​
     /*
     1號執行緒start
     2號執行緒start
     1號執行緒<clinit> init
     */
JVM規定6種情況下必須進行初始化(主動引用)
主動引用
  • 遇到new,getstatic,putstatic,invokestatic四條位元組碼指令

    • new
    • 讀/寫 某類靜態變數(不包括常量)
    • 呼叫 某類靜態方法
  • 使用java.lan.reflect包中方法對型別進行反射
  • 父類未初始化要先初始化父類 (不適用於介面)
  • 虛擬機器啟動時,先初始化main方法所在的類
  • 某類實現的介面中有預設方法(JDK8新加入的),要先對介面進行初始化
  • JDK7新加入的動態語言支援,部分....
被動引用
  1. 當訪問靜態欄位時,只有真正宣告這個欄位的類才會被初始化

(子類訪問父類靜態變數)

 public class TestMain {
     static {
         System.out.println("main方法所在的類初始化");
     }
 ​
     public static void main(String[] args) {
         System.out.println(Sup.i);
     }
 }
 ​
 class Sub{
     static {
         System.out.println("子類初始化");
     }
 }
 ​
 class Sup{
     static {
         System.out.println("父類初始化");
     }
     static int i = 100;
 }
 ​
 /*
 main方法所在的類初始化
 父類初始化
 100
 */

子類呼叫父類靜態變數是在父類類載入初始化的時候賦值的,所以子類不會類載入

  1. 例項陣列
 public class TestArr {
     static {
         System.out.println("main方法所在的類初始化");
     }
     public static void main(String[] args) {
         Arr[] arrs = new Arr[1];
     }
 }
 ​
 class Arr{
     static {
         System.out.println("arr初始化");
     }
 }
 ​
 /*
 main方法所在的類初始化
 */

例子裡包名為:org.fenixsoft.classloading。該例子沒有觸發類org.fenixsoft.classloading.Arr的初始化階段,但觸發了另外一個名為“[Lorg.fenixsoft.classloading.Arr”的類的初始化階段,對於使用者程式碼來說,這並不是一個合法的類名稱,它是一個由虛擬機器自動生成的、直接繼承於Object的子類,建立動作由位元組碼指令anewarray觸發. 這個類代表了一個元素型別為org.fenixsoft.classloading.Arr的一維陣列,陣列中應有的屬性和方法(使用者可直接使用的只有被修飾為public的length屬性和clone()方法)都實現在這個類裡。

建立陣列時不會對陣列中的型別物件(Arr)發生類載入

虛擬機器自動生成的一個類,管理Arr的陣列,會對這個類進行類載入

  1. 呼叫靜態常量
 public class TestConstant {
     static {
         System.out.println("main方法所在的類初始化");
     }
     public static void main(String[] args) {
         System.out.println(Constant.NUM);
     }
 }
 ​
 class Constant{
     static {
         System.out.println("Constant初始化");
     }
     static final int NUM = 555;
 }
 ​
 /*
 main方法所在的類初始化
 555
 */

我們在連線階段的準備中說明過,如果靜態變數欄位表中有ConstantValue(被final修飾)它在準備階段就已經完成初始預設值了,不用進行初始化

  1. 呼叫classLoader類的loadClass()方法載入類不導致類初始化

image-20210516130815998.png

解除安裝

方法區的垃圾回收主要有兩部分: 不使用的常量和類

回收方法區價效比比較低,因為不使用的常量和類比較少

不使用的常量

沒有任何地方引用常量池中的某常量,則該常量會在垃圾回收時,被收集器回收

不使用的類

成為不使用的類需要滿足以下要求:

  1. 沒有該類的任何例項物件
  2. 載入該類的類載入器被回收
  3. 該類對應的Class物件沒在任何地方被引用

注意: 就算被允許回收也不一定會被回收, 一般只會回收自定義的類載入器載入的類

總結

本篇文章圍繞類載入階段流程的載入-驗證-準備-解析-初始化-解除安裝 詳細展開每個階段的細節

載入階段主要是類載入器載入位元組碼流,將靜態結構(靜態常量池)轉換為執行時常量池,生成class物件

驗證階段驗證安全確保不會危害到JVM,主要驗證檔案格式,類的後設資料資訊、位元組碼、符號引用等

準備階段為類變數分配記憶體並預設初始化零值

解析階段將常量池的符號引用替換為直接引用

初始化階段執行類構造器(類變數賦值與類程式碼塊的合併)

  • 參考資料

    • 《深入理解Java虛擬機器》
    • 部分圖片來源網路

最後(不要白嫖,一鍵三連求求拉\~)

本篇文章筆記以及案例被收入 gitee-StudyJavagithub-StudyJava 感興趣的同學可以stat下持續關注喔\~

有什麼問題可以在評論區交流,如果覺得菜菜寫的不錯,可以點贊、關注、收藏支援一下\~

關注菜菜,分享更多幹貨,公眾號:菜菜的後端私房菜

本文由部落格一文多發平臺 OpenWrite 釋出!

相關文章