【JVM之記憶體與垃圾回收篇】類載入子系統

Nemo&發表於2020-07-19

類載入子系統

概述

【JVM之記憶體與垃圾回收篇】類載入子系統

完整圖如下:

【JVM之記憶體與垃圾回收篇】類載入子系統

如果自己想手寫一個 Java 虛擬機器的話,主要考慮哪些結構呢?

  • 類載入器
  • 執行引擎

類載入器子系統作用

【JVM之記憶體與垃圾回收篇】類載入子系統

類載入器子系統負責從檔案系統或者網路中載入 Class 檔案,Class 檔案在檔案開頭有特定的檔案標識(CAFE BABE)。

ClassLoader 只負責 Class 檔案的載入,至於它是否可以執行,則由 Execution Engine 決定。

載入的類資訊存放於一塊稱為方法區的記憶體空間。除了類的資訊外,方法區中還會存放執行時常量池資訊,可能還包括字串字面量和數字常量(這部分常量資訊是 Class 檔案中常量池部分的記憶體對映)

【JVM之記憶體與垃圾回收篇】類載入子系統

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

類的載入過程

例如下面的一段簡單的程式碼

/**
 * 類載入子系統
 * @author: Nemo
 */
public class HelloLoader {
    public static void main(String[] args) {
        System.out.println("我已經被載入啦");
    }
}

它的載入過程是怎麼樣的呢?

【JVM之記憶體與垃圾回收篇】類載入子系統

完整的流程圖如下所示

【JVM之記憶體與垃圾回收篇】類載入子系統

載入階段

  1. 通過一個類的全限定名獲取定義此類的二進位制位元組流

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

  3. 在記憶體中生成一個代表這個類的 java.lang.Class 物件,作為方法區這個類的各種資料的訪問入口

載入.class檔案的方式

  • 從本地系統中直接載入
  • 通過網路獲取,典型場景:Web Applet
  • 從 zip 壓縮包中讀取,成為日後 jar、war 格式的基礎
  • 執行時計算生成,使用最多的是:動態代理技術
  • 由其他檔案生成,典型場景:JSP 應用從專有資料庫中提取 .class 檔案,比較少見
  • 從加密檔案中獲取,典型的防止 Class 檔案被反編譯的保護措施

連結階段

【JVM之記憶體與垃圾回收篇】類載入子系統

驗證(Verify)

  • 目的在於確保 Class 檔案的位元組流中包含資訊符合當前虛擬機器要求,保證被載入類的正確性,不會危害虛擬機器自身安全。

  • 主要包括四種驗證,檔案格式驗證,後設資料驗證,位元組碼驗證,符號引用驗證。


工具:Binary Viewer 檢視

【JVM之記憶體與垃圾回收篇】類載入子系統

如果出現不合法的位元組碼檔案,那麼將會驗證不通過

同時我們可以通過安裝 IDEA 的外掛,來檢視我們的 Class 檔案

【JVM之記憶體與垃圾回收篇】類載入子系統

安裝完成後,我們編譯完一個 class 檔案後,點選 view 即可顯示我們安裝的外掛來檢視位元組碼方法了

【JVM之記憶體與垃圾回收篇】類載入子系統

準備(Prepare)

  • 為類變數分配記憶體並且設定該類變數的預設初始值,即零值。

  • 這裡不包含用 final 修飾的 static,因為 final 在編譯的時候就會分配了,準備階段會顯式初始化;

  • 這裡不會為例項變數分配初始化,類變數會分配在方法區中,而例項變數是會隨著物件一起分配到 Java 堆中。


例如下面這段程式碼

/**
 * @author: Nemo
 */
public class HelloApp {
    private static int a = 1;  // 準備階段為0,在下個階段,也就是初始化的時候才是1
    public static void main(String[] args) {
        System.out.println(a);
    }
}

上面的變數 a 在準備階段會賦初始值,但不是 1,而是 0。

解析(Resolve)

  • 將常量池內的符號引用轉換為直接引用的過程。

  • 事實上,解析操作往往會伴隨著 JVM 在執行完初始化之後再執行。

  • 符號引用就是一組符號來描述所引用的目標。符號引用的字面量形式明確定義在《Java 虛擬機器規範》的 Class 檔案格式中。直接引用就是直接指向目標的指標、相對偏移量或一個間接定位到目標的控制程式碼。

  • 解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別等。對應常量池中的 CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info等

初始化階段

初始化階段就是執行類構造器法 <clinit>() 的過程。

此方法不需定義,是 javac 編譯器自動收集類中的所有類變數的賦值動作和靜態程式碼塊中的語句合併而來。

也就是說,當我們程式碼中包含 static 變數的時候,就會有 clinit 方法

構造器方法中指令按語句在原始檔中出現的順序執行。

<clinit>() 不同於類的構造器。(關聯:構造器是虛擬機器視角下的 <init>())若該類具有父類,JVM 會保證子類的 <clinit>() 執行前,父類的 <clinit>() 已經執行完畢。

任何一個類在宣告後,都有生成一個構造器,預設是空參構造器

/**
 * @author: Nemo
 */
public class ClassInitTest {
    private static int num = 1;
    static {
        num = 2;
        number = 20;
        System.out.println(num);
        System.out.println(number);  //報錯,非法的前向引用
    }

    private static int number = 10;

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

關於涉及到父類時候的變數賦值過程

/**
 * @author: Nemo
 */
public class ClinitTest1 {
    static class Father {
        public static int A = 1;
        static {
            A = 2;
        }
    }

    static class Son extends Father {
        public static int b = A;
    }

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

我們輸出結果為 2,也就是說首先載入 ClinitTest1 的時候,會找到 main 方法,然後執行 Son 的初始化,但是 Son 繼承了 Father,因此還需要執行 Father 的初始化,同時將 A 賦值為 2。我們通過反編譯得到 Father 的載入過程,首先我們看到原來的值被賦值成 1,然後又被複製成 2,最後返回

iconst_1
putstatic #2 <com/atguigu/java/chapter02/ClinitTest1$Father.A>
iconst_2
putstatic #2 <com/atguigu/java/chapter02/ClinitTest1$Father.A>
return

虛擬機器必須保證一個類的 <clinit>() 方法在多執行緒下被同步加鎖。

/**
 * @author: Nemo
 */
public class DeadThreadTest {
    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t 執行緒t1開始");
            new DeadThread();
        }, "t1").start();

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t 執行緒t2開始");
            new DeadThread();
        }, "t2").start();
    }
}
class DeadThread {
    static {
        if (true) {
            System.out.println(Thread.currentThread().getName() + "\t 初始化當前類");
            while(true) {

            }
        }
    }
}

上面的程式碼,輸出結果為

執行緒t1開始
執行緒t2開始
執行緒t2 初始化當前類

從上面可以看出初始化後,只能夠執行一次初始化,這也就是同步加鎖的過程

類載入器的分類

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

從概念上來講,自定義類載入器一般指的是程式中由開發人員自定義的一類類載入器,但是 Java 虛擬機器規範卻沒有這麼定義,而是將所有派生於抽象類 ClassLoader 的類載入器都劃分為自定義類載入器。

無論類載入器的型別如何劃分,在程式中我們最常見的類載入器始終只有 3 個,如下所示:

【JVM之記憶體與垃圾回收篇】類載入子系統

這裡的四者之間是包含關係,不是上層和下層,也不是子系統的繼承關係。

我們通過一個類,獲取它不同的載入器

/**
 * @author: Nemo
 */
public class ClassLoaderTest {
    public static void main(String[] args) {
        // 獲取系統類載入器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader);

        // 獲取其上層的:擴充套件類載入器
        ClassLoader extClassLoader = systemClassLoader.getParent();
        System.out.println(extClassLoader);

        // 試圖獲取 根載入器
        ClassLoader bootstrapClassLoader = extClassLoader.getParent();
        System.out.println(bootstrapClassLoader);

        // 獲取自定義載入器
        ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
        System.out.println(classLoader);
        
        // 獲取String型別的載入器
        ClassLoader classLoader1 = String.class.getClassLoader();
        System.out.println(classLoader1);
    }
}

得到的結果,從結果可以看出 根載入器無法直接通過程式碼獲取,同時目前使用者程式碼所使用的載入器為系統類載入器。同時我們通過獲取 String 型別的載入器,發現是 null,那麼說明 String 型別是通過根載入器進行載入的,也就是說 Java 的核心類庫都是使用根載入器進行載入的。

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1540e19d
null
sun.misc.Launcher$AppClassLoader@18b4aac2
null 

虛擬機器自帶的載入器

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

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

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

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

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

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

使用者自定義類載入器

在 Java 的日常應用程式開發中,類的載入幾乎是由上述 3 種類載入器相互配合執行的,在必要時,我們還可以自定義類載入器,來定製類的載入方式。

為什麼要自定義類載入器?

  • 隔離載入類
  • 修改類載入的方式
  • 擴充套件載入源
  • 防止原始碼洩漏

使用者自定義類載入器實現步驟:

  1. 開發人員可以通過繼承抽象類 java.lang.ClassLoader 類的方式,實現自己的類載入器,以滿足一些特殊的需求
  2. 在 JDK1.2 之前,在自定義類載入器時,總會去繼承 ClassLoader 類並重寫 loadClass() 方法,從而實現自定義的類載入類,但是在 JDK1.2 之後已不再建議使用者去覆蓋 loadclass() 方法,而是建議把自定義的類載入邏輯寫在 findclass() 方法中
  3. 在編寫自定義類載入器時,如果沒有太過於複雜的需求,可以直接繼承 URIClassLoader 類,這樣就可以避免自己去編寫 findclass() 方法及其獲取位元組碼流的方式,使自定義類載入器編寫更加簡潔。

檢視根載入器所能載入的目錄

剛剛我們通過概念瞭解到了,根載入器只能夠載入 java /lib 目錄下的 class,我們通過下面程式碼驗證一下

/**
 * @author: Nemo
 */
public class ClassLoaderTest1 {
    public static void main(String[] args) {
        System.out.println("*********啟動類載入器************");
        // 獲取BootstrapClassLoader 能夠載入的API的路徑
        URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
        for (URL url : urls) {
            System.out.println(url.toExternalForm());
        }

        // 從上面路徑中,隨意選擇一個類,來看看他的類載入器是什麼:得到的是null,說明是  根載入器
        ClassLoader classLoader = Provider.class.getClassLoader();
    }
}

得到的結果

*********啟動類載入器************
file:/E:/Software/JDK1.8/Java/jre/lib/resources.jar
file:/E:/Software/JDK1.8/Java/jre/lib/rt.jar
file:/E:/Software/JDK1.8/Java/jre/lib/sunrsasign.jar
file:/E:/Software/JDK1.8/Java/jre/lib/jsse.jar
file:/E:/Software/JDK1.8/Java/jre/lib/jce.jar
file:/E:/Software/JDK1.8/Java/jre/lib/charsets.jar
file:/E:/Software/JDK1.8/Java/jre/lib/jfr.jar
file:/E:/Software/JDK1.8/Java/jre/classes
null

關於ClassLoader

ClassLoader 類,它是一個抽象類,其後所有的類載入器都繼承自 ClassLoader(不包括啟動類載入器)

方法名稱 概述
getParent() 返回該類載入器的超類載入器
loadClass(Sting name) 載入名稱為 name 的類,返回結果為 java.lang.Class 類的例項
findClass(String name) 查詢名稱為 name 的類,返回結果為 java.lang.Class 類的例項
findLoadedClass(String name) 查詢名稱為 name 的已經被載入過的類,返回結果為 java.lang.Class 類的例項
defineClass(String name,Byte[b],int off,int len) 把位元組陣列 b 中的內容轉換為一個 Java 類,返回結果為 java.lang.Class 類的例項
resolveClass(Class<?> c 連線指定的一個 Java 類

【JVM之記憶體與垃圾回收篇】類載入子系統

sun.misc.Launcher 它是一個 java 虛擬機器的入口應用
【JVM之記憶體與垃圾回收篇】類載入子系統

獲取 ClassLoader 的途徑

  • 方法一:獲取當前 ClassLoader
    clazz.getClassLoader()

    一般用 clazz 表示一個類的例項,而 class 只是個關鍵字

  • 方法二:獲取當前執行緒上下文的 ClassLoader
    Thread.currentThread().getContextClassLoader()
  • 方法三:獲取系統的 ClassLoader
    ClassLoader.getSystemClassLoader()
  • 方法四:獲取呼叫者的 ClassLoader
    DriverManager.getCallerClassLoader()

雙親委派機制

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

工作原理

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

【JVM之記憶體與垃圾回收篇】類載入子系統

雙親委派機制舉例

當我們載入 jdbc.jar 用於實現資料庫連線的時候,首先我們需要知道的是 jdbc.jar 是基於 SPI 介面進行實現的,所以在載入的時候,會進行雙親委派,最終從根載入器中載入 SPI 核心類,然後在載入 SPI 介面類,接著在進行反向委派,通過執行緒上下文類載入器進行實現類 jdbc.jar 的載入。

【JVM之記憶體與垃圾回收篇】類載入子系統

雙親委派機制的優勢

通過上面的例子,我們可以知道,雙親機制可以

  • 避免類的重複載入
  • 保護程式安全,防止核心 API 被隨意篡改
    • 自定義類:java.lang.String
    • 自定義類:java.lang.ShkStart(報錯:阻止建立 java.lang 開頭的類)

沙箱安全機制

沙盒(英語:sandbox,又譯為沙箱),計算機術語,在電腦保安領域中是一種安全機制,為執行中的程式提供的隔離環境。

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

其它

如何判斷兩個 class 物件是否相同

在JVM中表示兩個 class 物件是否為同一個類存在兩個必要條件:

  • 類的完整類名必須一致,包括包名。
  • 載入這個類的 ClassLoader(指 ClassLoader 例項物件)必須相同。

換句話說,在 JVM 中,即使這兩個類物件(class 物件)來源同一個 Class 檔案,被同一個虛擬機器所載入,但只要載入它們的 ClassLoader 例項物件不同,那麼這兩個類物件也是不相等的。

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

類的主動使用和被動使用

Java 程式對類的使用方式分為:王動使用和被動使用。

主動使用,又分為七種情況:

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

除了以上七種情況,其他使用 Java 類的方式都被看作是對類的被動使用,都不會導致類的初始化

相關文章