JVM-類載入子系統

niulongwei發表於2021-05-31
 

類載入子系統

1.JVM記憶體結構圖

jvm記憶體結構
注意:方法區是Java虛擬機器規範規定的區域,只不過各家虛擬機器對方法區有不同的實現;HotSpot最開始用永久代來實現方法區,垃圾回收也會兼顧此區域,但是永久代實現的方法區有不少的問題,如容易記憶體溢位,回收效率低下等,HotSpot在1.8開始採用元空間來實現方法區。

2.什麼是類載入子系統

類載入器子系統負責從檔案系統或者網路中載入Class檔案,class檔案在檔案開頭有特定的檔案標識。當中的類載入器只負責class檔案的載入,至於它是否可以執行,則由Execution Engine(執行引擎)決定。載入的類資訊存放於一塊稱為方法區的記憶體空間。除了類的資訊外,方法區中還會存放執行時常量池資訊,可能還包括字串字面量和數字常量(這部分常量資訊是Class檔案中常量池部分的記憶體對映)
在這裡插入圖片描述
舉例來說類載入子系統就像是一箇中央快遞站,當快遞被打包好(編譯後)傳送過來的時候,去進行接收,首先收到快遞看是什麼型別的快遞(順豐,郵政等),不同的快遞由不同的人員去接收(不同的類由不同的載入器去載入),接收完成後要進行驗證,看是不是有什麼損壞(連結階段----驗證),當一切無誤後為該快遞貼上取貨碼(連結階段----準備:初始化一些資訊比如類變數),再然後檢視快遞所要去往的地方,由快遞員去派送(連結階段----解析:將常量池內的符號引用轉換為直接引用的過程),到達目的快遞站後交由本地快遞站進行處理(進入到初始化階段),快遞可以由快遞站直接送往顧客家裡(類的被動使用),也可以由顧客主動來領(類的主動使用例如:建立類的例項,呼叫類的靜態方法等).

當然在類的載入階段還有雙親委派機制,在後面會提到.

2.1 載入階段

2.1.1 類載入器ClassLoader
  • class file(編譯後的檔案)存在於本地硬碟上,可以理解為設計師畫在紙上的模板,而最終這個模板在執行的時候是要載入到JVM當中來根據這個檔案例項化出n個一模一樣的例項。
  • class file載入到JVM中,被稱為DNA後設資料模板放在方法區。
  • 在.class檔案–>JVM–>最終成為後設資料模板,此過程就要一個運輸工具(類裝載器Class Loader),扮演一個快遞員的角色。
2.2.2 類載入階段過程
public class Loader {

    public static void main(String[] args) {
        System.out.println("謝謝ClassLoader載入我....");
    }
}

對於上面的程式碼他的載入過程是什麼樣呢?

  • 首先要想執行 main() 方法(靜態方法)就需要先載入main方法所在類 Loader
  • 如果載入成功,則進行連結、初始化等操作。完成後呼叫 Loader類中的靜態方法 main
  • 載入失敗則會丟擲異常
    在這裡插入圖片描述

2.2 連結階段

連結分為三個子階段:驗證 -> 準備 -> 解析

2.2.1 驗證(Verify)
  • 目的在於確保Class檔案的位元組流中包含資訊符合當前虛擬機器要求,保證被載入類的正確性,不會危害虛擬機器自身安全
  • 主要包括四種驗證,檔案格式驗證,後設資料驗證,位元組碼驗證,符號引用驗證。

比如說如果你檢視java編譯後的位元組碼檔案就會發現,它們的開頭都是CAFE BABE(很多人稱之為咖啡寶貝),如果出現不合法的位元組碼檔案,那麼將會驗證不通過。

2.2.2 準備(Prepare)
  • 為類變數(static變數)分配記憶體並且設定該類變數的預設初始值
  • 當然這裡不包含用final修飾的static,因為final在編譯的時候就會分配好了預設值,準備階段會顯式初始化
  • 同時要注意這裡不會為例項變數分配初始化,類變數會分配在方法區中,而例項變數是會隨著物件一起分配到Java堆中

舉例來說 檢視編譯後的檔案
編譯前

package com.Demo;

public class ClassInitTest {
    public static int num = 3;

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

 

編譯後

public com.Demo.ClassInitTest();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0    //可以看到在初始化階段預設賦了初值為0
         1: invokespecial #1                 
         4: return
      LineNumberTable:
        line 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/Demo/ClassInitTest;


static {}; descriptor: ()V flags: (0x0008) ACC_STATIC Code: stack=1, locals=0, args_size=0 0: iconst_3 //在初始化階段才賦值為了3 1: putstatic #3 // Field num:I 4: return LineNumberTable: line 4: 0 }

 

2.2.3 解析(Resolve)
  • 將常量池內的符號引用轉換為直接引用的過程
  • 事實上,解析操作往往會伴隨著JVM在執行完初始化之後再執行
  • 符號引用就是一組符號來描述所引用的目標。符號引用的字面量形式明確定義在《java虛擬機器規範》的class檔案格式中。直接引用就是直接指向目標的指標、相對偏移量或一個間接定位到目標的控制程式碼
  • 解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別等。對應常量池中的CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info等

通過反編譯class檔案可以檢視到符號引用

 #1 = Methodref          #6.#23         // java/lang/Object."<init>":()V
  #2 = Fieldref           #24.#25        // java/lang/System.out:Ljava/io/PrintStream;
  #3 = Fieldref           #5.#26         // com/Demo/ClassInitTest.num:I
  #4 = Methodref          #27.#28        // java/io/PrintStream.println:(I)V
  #5 = Class              #29            // com/Demo/ClassInitTest
  #6 = Class              #30            // java/lang/Object
  #7 = Utf8               num
  #8 = Utf8               I
  #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               LocalVariableTable
  #14 = Utf8               this
  #15 = Utf8               Lcom/Demo/ClassInitTest;
  #16 = Utf8               main
  #17 = Utf8               ([Ljava/lang/String;)V
  #18 = Utf8               args
  #19 = Utf8               [Ljava/lang/String;

 

2.3 初始化階段

類的初始化時機有:

  • 建立類的例項
  • 訪問某個類或介面的靜態變數,或者對該靜態變數賦值
  • 呼叫類的靜態方法
  • 反射(比如:Class.forName(“TestClass”))
  • 初始化一個類的子類
  • Java虛擬機器啟動時被標明為啟動類的類
  • JDK7開始提供的動態語言支援:java.lang.invoke.MethodHandle例項的解析結果REF_getStatic、REF putStatic、REF_invokeStatic控制程式碼對應的類沒有初始化,則初始化

除了以上七種情況,其他使用Java類的方式都被看作是對類的被動使用,都不會導致類的初始化,即不會執行初始化階段(不會呼叫 clinit() 方法和 init() 方法)

clinit()方法

  • 初始化階段就是執行類構造器方法<clinit>()的過程
  • 此方法不需定義,是javac編譯器自動收集類中的所有類變數的賦值動作和靜態程式碼塊中的語句合併而來。也就是說,當我們程式碼中包含static變數的時候,就會有clinit方法
  • <clinit>()方法中的指令按語句在原始檔中出現的順序執行
  • <clinit>()不同於類的構造器。(關聯:構造器是虛擬機器視角下的<init>()
  • 若該類具有父類,JVM會保證子類的<clinit>()執行前,父類的<clinit>()已經執行完畢
  • 虛擬機器必須保證一個類的<clinit>()方法在多執行緒下被同步加鎖

3.類載入器的分類

JVM嚴格來講支援兩種型別的類載入器 。分別為引導類載入器(Bootstrap ClassLoader)和自定義類載入器(User-Defined ClassLoader)

從概念上來講,自定義類載入器一般指的是程式中由開發人員自定義的一類類載入器,但是Java虛擬機器規範卻沒有這麼定義,而是將所有派生於抽象類ClassLoader的類載入器都劃分為自定義類載入器
在這裡插入圖片描述
可以看到所有ClassLoader下的所有派生類都是屬於自定義類載入器,包括擴充套件類載入器(Extension ClassLoader)以及系統類載入器(Application ClassLoader)

在程式中我們最常見的類載入器只有3個分別是ExtClassLoader,AppClassLoader,使用者自定義載入器

3.1 虛擬機器自帶的載入器

3.1.1 啟動類載入器

啟動類載入器(引導類載入器,Bootstrap ClassLoader)

  • 這個類載入使用C/C++語言實現的,巢狀在JVM內部
  • 它用來載入Java的核心庫(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路徑下的內容),用於提供JVM自身需要的類
  • 並不繼承自java.lang.ClassLoader,沒有父載入器
  • 載入擴充套件類和應用程式類載入器,並作為他們的父類載入器
  • 出於安全考慮,Bootstrap啟動類載入器只載入包名為java、javax、sun等開頭的類
3.1.2 擴充套件類載入器

擴充套件類載入器(Extension ClassLoader)

  • Java語言編寫,由sun.misc.Launcher$ExtClassLoader實現
  • 派生於ClassLoader類
  • 父類載入器為啟動類載入器
  • 從java.ext.dirs系統屬性所指定的目錄中載入類庫,或從JDK的安裝目錄的jre/lib/ext子目錄(擴充套件目錄)下載入類庫。如果使用者建立的JAR放在此目錄下,也會自動由擴充套件類載入器載入
3.1.3 系統類載入器

應用程式類載入器(也稱為系統類載入器,AppClassLoader)

  • Java語言編寫,由sun.misc.LaunchersAppClassLoader實現
  • 派生於ClassLoader類
  • 父類載入器為擴充套件類載入器
  • 它負責載入環境變數classpath或系統屬性java.class.path指定路徑下的類庫
  • 該類載入是程式中預設的類載入器,一般來說,Java應用的類都是由它來完成載入
  • 通過classLoader.getSystemclassLoader()方法可以獲取到該類載入器

3.2 使用者自定義類載入器

3.2.1 什麼時候需要自定義類載入器?
  • 隔離載入類(比如說我假設現在Spring框架,和RocketMQ有包名路徑完全一樣的類,類名也一樣,這個時候類就衝突了。不過一般的主流框架和中介軟體都會自定義類載入器,實現不同的框架,中間價之間是隔離的)
  • 修改類載入的方式
  • 擴充套件載入源(還可以考慮從資料庫中載入類,路由器等等不同的地方)
  • 防止原始碼洩漏(對位元組碼檔案進行解密,自己用的時候通過自定義類載入器來對其進行解密)
3.2.2 如何自定義類載入器?
  • 開發人員可以通過繼承抽象類java.lang.ClassLoader類的方式,實現自己的類載入器,以滿足一些特殊的需求
  • 在JDK1.2之前,在自定義類載入器時,總會去繼承ClassLoader類並重寫loadClass()方法,從而實現自定義的類載入類,但是在JDK1.2之後已不再建議使用者去覆蓋loadClass()方法,而是建議把自定義的類載入邏輯寫在findclass()方法中
  • 在編寫自定義類載入器時,如果沒有太過於複雜的需求,可以直接繼承URIClassLoader類,這樣就可以避免自己去編寫findclass()方法及其獲取位元組碼流的方式,使自定義類載入器編寫更加簡潔。
public class CustomClassLoader extends ClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {

        try {
            byte[] result = getClassFromCustomPath(name);
            if (result == null) {
                throw new FileNotFoundException();
            } else {
                //defineClass和findClass搭配使用
                return defineClass(name, result, 0, result.length);
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }

        throw new ClassNotFoundException(name);
    }
    //自定義流的獲取方式
    private byte[] getClassFromCustomPath(String name) {
        //從自定義路徑中載入指定類:細節略
        //如果指定路徑的位元組碼檔案進行了加密,則需要在此方法中進行解密操作。
        return null;
    }

    public static void main(String[] args) {
        CustomClassLoader customClassLoader = new CustomClassLoader();
        try {
            Class<?> clazz = Class.forName("One", true, customClassLoader);
            Object obj = clazz.newInstance();
            System.out.println(obj.getClass().getClassLoader());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

 

4.雙親委派機制

4.1 什麼是雙親委派機制

Java虛擬機器對class檔案採用的是按需載入的方式,也就是說當需要使用該類時才會將它的class檔案載入到記憶體生成class物件。而且載入某個類的class檔案時,Java虛擬機器採用的是雙親委派模式,即把請求交由父類處理,它是一種任務委派模式

4.2 雙親委派機制原理

  1. 如果一個類載入器收到了類載入請求,它並不會自己先去載入,而是把這個請求委託給父類的載入器去執行;
  2. 如果父類載入器還存在其父類載入器,則進一步向上委託,依次遞迴,請求最終將到達頂層的啟動類載入器;
  3. 如果父類載入器可以完成類載入任務,就成功返回,倘若父類載入器無法完成此載入任務,子載入器才會嘗試自己去載入,這就是雙親委派模式。
  4. 父類載入器一層一層往下分配任務,如果子類載入器能載入,則載入此類,如果將載入任務分配至系統類載入器也無法載入此類,則丟擲異常
    在這裡插入圖片描述

例如:
我們自己建立一個 java.lang.String 類,寫上 static 程式碼塊

package java.lang;
public class String {
    static{
        System.out.println("自定義的String類的靜態程式碼塊");
    }
}

 

在另外的程式中載入 String 類

public class StringTest {
    public static void main(String[] args) {
        java.lang.String str = new java.lang.String();
        System.out.println("hello String");
    }
}

 

輸出結果:

hello String

 

並沒有列印自定義的String 類中的語句,所以系統載入的還是JDK 自帶的 String 類.

把剛剛的類改一下

package java.lang;
public class String {
    static{
        System.out.println("自定義的String類的靜態程式碼塊");
    }
    public static void main(String[] args) {
        System.out.println("hello String");
    }
}

 

在這裡插入圖片描述
由於雙親委派機制會一直找父類載入器,所以最後找到了Bootstrap ClassLoader(引導類載入器),Bootstrap ClassLoader找到的是 JDK 自帶的 String 類,在那個String類中並沒有相應的 main() 方法,所以就報了上面的錯誤。

4.2.3 雙親委派機制優勢

  • 避免類的重複載入
  • 保護程式安全,防止核心API被隨意篡改

5.沙箱安全機制

  1. 自定義String類時:在載入自定義String類的時候會率先使用引導類載入器載入,而引導類載入器在載入的過程中會先載入jdk自帶的檔案(rt.jar包中java.lang.String.class),報錯資訊說沒有main方法,就是因為載入的是rt.jar包中的String類。
  2. 這樣可以保證對java核心原始碼的保護,這就是沙箱安全機制。

6.補充

如果要判斷兩個class物件是否相同,在JVM中表示兩個class物件是否為同一個類存在兩個必要條件:

  1. 類的完整類名必須一致,包括包名
  2. 載入這個類的ClassLoader(指ClassLoader例項物件)必須相同
  3. 換句話說,在JVM中,即使這兩個類物件(class物件)來源同一個Class檔案,被同一個虛擬機器所載入,但只要載入它們的ClassLoader例項物件不同,那麼這兩個類物件也是不相等的

對類載入器的引用

    1. JVM必須知道一個型別是由啟動載入器載入的還是由使用者類載入器載入的
    2. 如果一個型別是由使用者類載入器載入的,那麼JVM會將這個類載入器的一個引用作為型別資訊的一部分儲存在方法區中
    3. 當解析一個型別到另一個型別的引用的時候,JVM需要保證這兩個型別的類載入器是相同的

相關文章