「類」的生命週期、ClassLoader 加載 | JVM 與 Class | Java 為例

「類」的生命週期、ClassLoader 加載 | JVM 與 Class | Java 為例

Overview of Content

JVM 會為 Java 程式提供執行的環境,而 JVM 其中一個重要的任務就是 管理類別、物件的生命週期,這個本文則針對 Class)說明

Java虛擬機(JVM)是 Java 程序的核心引擎,負責管理類別和物件的生命週期。本篇將深入探討 Java 類別的生命週期,以及 ClassLoader 在類別加載過程中的作用

  • JVM 與類加載:探索 JVM 的生命週期,從類別載入到初始化,深入了解 JVM 與類之間的關係。
  • 類的生命週期:從載入階段到初始化階段,逐一解析類別的生命週期過程。
  • 類的加載時機:探討類別的加載時機,包括啟動類別、物件創建和靜態元素初始化等。
  • Java ClassLoader 特點:了解 Java ClassLoader 的特性,包括 JVM 內建類加載器和父委託機制等。
  • 類加載測試:深入測試類別的加載與卸載過程,驗證命名空間和父委託機制的作用。

無論你是對 Java 類別生命週期感興趣,還是想深入了解 ClassLoader 的工作原理,本篇都將為你提供深入且全面的解說。

寫文章分享不易,如有引用參考請詳註出處,如有指導、意見歡迎留言(如果覺得寫得好也請給我一些支持),感謝 😀


JVM 與類加載

類的生命週期如下圖;重點是 JVM 加載 class 文件加載過後 1. 二進制放置方法區2. 創建一個(唯一)JVM 堆區

JVM 生命週期

● JVM 生命週期如下

JVM 啟動:JVM 的啟動是隨應用程式的啟動

當我們透過 Java 命令執行 Java 應用後,就會啟動一個 JVM(一個應用對應一個虛擬機的實體

JVM 結束:JVM 的結束通常有以下方式

● 程式的正常結束

● 程式發生異常錯誤導致例外中止

● 執行 System#exit() 方法,主動退出

● JVM 運行的 OS 平台,但平台發生意外,導致應用關閉,JVM 也會結束

● JVM 的生命週期會隨應用的啟動、關閉共同運行


類的生命週期

這裡說的是「類」!不是「物件」唷~

物件是運行時創建的實體 instance,類更像是物件的模板

● 類加載入 JVM 至 卸載出 JVM 的過程稱為類的生命週期,而類的生命週期有 5 個階段

A. 類加載:找查加載 Class 文檔 (Class 文檔轉為 Class類)

● 根據特定名稱找到類 or 界面的二進制字節流

● 將該二進制字節流代表的靜態儲存結構 轉化到方法區運行時數據結構

● 在內存中生成一個代表這個類的 java.lang.Class 對象放置在方法區 (唯一一個 class),做為這個類的各種數據訪問入口

B. 類連結 (分三個階段)

驗證 : 確保引入的類型的正確性

準備 : 類的 static 字段(static 區塊、static Field)分配,並初始化

解析 : VM 將 常量池 內的符號引用替換為直接引用

C. 類初始化:將類的變量初始化為正確值

D. 類使用

E. 類卸載

類的加載時機?

而一個類的加載時機又有分幾種(之後小節會提到),但有一個原則就是 Java 應用 首次主動使用 這個類的時候,該類就會加載


類別:載入階段

類(Class 文件)的載入是指

A. 方法區資料:將 .class 檔中的二進位資料讀取到記憶體中,並存放在 JVM 執行時期的方法區中!

而載入類的方式並非只有直接透過檔案,只要載入 Class 文件格式的資料,使用其他方式也可以,像是:

● 本地 .class 文件

● 網路傳輸進來的 2 進位 .class 文件

● 從 ZIP, JAR ... 或其他壓縮檔提取的.class 文件

● 從記憶體中動態創建 .class 文件並加載

B. 堆區資料:在堆區建立該類相對應的 java.lang.Class 物件

● 而將「類」加載進 JVM 的工具是啥?就是 類加載器(之後說明)

類別:連結階段

● 連結就是將已經讀入記憶體的類別資訊(2 進位)合併到虛擬機的運行時環境;我們可以知道連結有 3 個步驟:驗證、初始化準備、解析

A. 驗證Verifucation):

保證被載入的類別有正確的 Class 資料結構!這個步驟可以確認以下事項

Class 資料結構:確保內容格式是正確的

像是檢查 Class 魔數 就可以知道該文件是否是 Class 文檔

語意檢查:以 Java 語言的規範角度去檢查是否語意正確

final 類別不會有繼承、final 方法不會被覆蓋

ByteCode 驗證:ByteCode 以方法來說,會透過 操作碼的指令 組成方法,在這個步驟會檢查操作碼的合法性

二進位相容性驗證:確保相互參考的類別之間的 協調一致

就像是加載的類如果沒有呼叫類別需要的方法時就會拋出 NoSuchMethodError 錯誤

B. 初始化準備Prepartion):

記憶體空間:為類的靜態變數、靜態區塊分配對應的記憶體空間

初始化:賦予靜態變數初始值;這裡的初始化是針對記憶體的初始化

類似使用 C 語言的 memset 函數,將該記憶體區塊全部清為 0


public class PrepareStatic {
    
    // 初始化為 0
    private static int apple;

    static {
        // 初始化為 0,尚未真正初始化!
        int banana = 10;
    }

}

C. 解析Resolution):

JVM 會將類別(.class)的二進位資料中的 符號參考替換為直接參考值;所謂的 符號是我們程式中撰寫的程式的參考(reference)

● 也就是將「符號引用」轉為「直接引用」(類似 C/C++ 中的重新定位過程)


class Person {

    // helloWorld 就是符號,同時也是參考
    private final HelloWorld helloWorld = new HelloWorld();

    void firstSay() {
        // say 也是符號
        // 解析階段會將 HelloWorld#say 
        // 轉換為 HelloWorld#say 放在方法區的位置的指標!
        helloWorld.say();
    }

}

class HelloWorld {

    void say() {
        // do somthing...
    }

}
graph TD; Person解析-->發現Person#firstSay方法; 發現Person#firstSay方法-->找到HelloWorld方法區的say方法_替換為指標; 找到HelloWorld方法區的say方法_替換為指標-->HelloWorld#say方法; HelloWorld方法區-->HelloWorld#say方法;

類別:初始化階段

這裡的初始化與 連結時期 的初始化不同,這裡的初始化尚未到建構函數(在堆區建構物件時才會呼叫到),這裡針對類(方法區)做初始化

按照順序 執行初始化


public class PrepareStatic {

    private static int apple;

    static {
        apple = 10;
    }

    // 由於按照順序,最後賦予值的設定將為最終設定
    static {
        apple = 200;
    }

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

JVM 初始化一個類時基礎規則為

按順序初始化:如上所述

類別自身尚未加載:如果目標類別尚未加載,那會先將該類加載(載入、連結、驗證)再初始化


class SubClz {

    static int tmp = 200;

    static {
        System.out.println("SubClz static block: " + tmp);
    }

}

public class PrepareStatic {
    public static void main(String[] args) {
        System.out.println("Sub Clz tmp: " + SubClz.tmp);
    }
}

父類尚未加載:如果目標類有父類,並父類也尚未加載:那會先加載父類進方法區並建立堆區的 Class 訊息再初始化,再加載子類(目標類)最後出初始化子類


class ParentClz {
    static int tmp = 10;

    static {
        System.out.println("ParentClz static block: " + tmp);
    }

}

// 有父類
class SubClz extends ParentClz {

    static int tmp = 200;

    static {
        System.out.println("SubClz static block: " + tmp);
    }

}

public class PrepareStatic {
    public static void main(String[] args) {
        System.out.println("Sub Clz tmp: " + SubClz.tmp);
    }
}

類的加載時機

我們前面有說到,類的加載是在我們 初次 主動 使用類別時,而這個初次主動 主動又有分 的,並不是有使用到就算主動

主動使用到有分如下行為,這些行為 JVM 才會幫我們進行類加載

啟動類別

啟動類別,也就是擁有 Main 函數的類別,在呼叫 java 指令呼叫該類時,JVM 會先將其初始化


public class EntryClz {

    // 我們知道類加載後連結(初始化階段)會呼叫靜態區塊
    // 以此,我們來就可以確認 EntryClz 是否被加載
    static {
        System.out.println("Entry Clz has loaded.");
    }

    public static void main(String[] args) {
        System.out.println("Hello Entry Clz");
    }

}

測試目標:java 命令呼叫 EntryClz 類,讓 JVM 加載該類,並執行內部的 main 方法

A. 使用 javacEntryClz.java 編譯成 EntryClz.class 檔案(編譯指令如下)


javac EntryClz.java 

B. 使用 java 執行 EntryClz.class 檔案(執行指令如下)


java EntryClz

創建物件

● 建立類的 instance 物件 也會觸發 JVM 加載一個類

最常見的就是使用 new 關鍵字 創建一個物件,而其中也 包含透過反射創建一個類的 instacnce

A. new 關鍵字 創建物件


public class NewInstance {

    public static void main(String[] args) {
        // 引用不會觸發類加載
        MyClz myClz;

        System.out.println("Before create instance");

        myClz = new MyClz();

        System.out.println("After create instance");
    }

}


class MyClz {

    static {
        System.out.println("MyClz has loaded.");
    }

}

● 從結果可以發現:引用」不會導致類的加載、初始化 (可以從 static 區塊,發現類是否有被加載)

B. 反射 創建物件


public class NewInstance {

    public static void main(String[] args) throws InstantiationException, IllegalAccessException {

        System.out.println("Before create instance");

        MyClz.class.newInstance();

        System.out.println("After create instance");
    }

}


class MyClz {

    static {
        System.out.println("MyClz has loaded.");
    }

}

靜態元素

靜態元素 包括如下兩種

● 呼叫 靜態方法


class LoadStaticMethod {

    static {
        System.out.println("LoadStaticMethod loaded");
    }

    // 呼叫會觸發加載
    static void showMsg() {
        System.out.println("HelloWorld");
    }

}

● 存取類別的 靜態 變數


class LoadStaticField {

    // 存取會觸發加載
    static int commonValue = 0;

    static {
        System.out.println("LoadStaticField loaded");
    }
}

編譯時期常數

final static 常數,這種常數又稱為 編譯時期常數;會在編譯期間直接做優化,將該常數在使用到的地方直接替代


class ConstField {

    static {
        System.out.println("ConstField loaded");
    }

    // 呼叫這個 Const Field 不會觸發 JVM 加載該 Class
    static final boolean IS_DEBUG = true;

}

public class LoadByStatic {

    public static void main(String[] args) {
        System.out.println("Is debug: " + ConstField.IS_DEBUG);
    }

}

訪問 Const Field 不會觸發 JVM 加載該類

我們也可以透過 javac、javap 指令來查看 常量得值,會被編譯器直接取代,不會呼叫該類(這是一種編譯器的優化)

● JVM 加載 編譯時期常數 不會為它在方法區創建一塊空間(編譯時期常數分配在其他區域)

編譯時期常數分配在 Java「常數池」(Constant Pool)中,這裡的訪問效率更高

反射呼叫方法

● 透過 Java 反射相關 API 操作類的方法(因為反射會分析堆區中的 Class 物件、方法區的二進制碼,所以必須先載入類


class ReflectionMethod {

    void showMsg() {
        System.out.println("Hello World, I am method.");
    }

}

public class LoadByReflection {

    public static void main(String[] args) throws InstantiationException, IllegalAccessException {
        ReflectionMethod.class.newInstance().showMsg();
    }

}

● 這裡要提早提及一個有關於 ClassLoader 的蓋念,使用 ClassLoader 加載並不會導致類的初始化只載入到方法區,尚未載入堆區

A. 透過以下範例,可以觀察到,如果 單純使用 ClassLoader 並不會導致類的初始化


class HelloWorld {

    static {
        System.out.println("Hello World class has loaded.");
    }

}

public class LoadByClassLoader {

    public static void main(String[] args) throws ClassNotFoundException,
            InstantiationException, IllegalAccessException {
        ClassLoader sysClzLoader = ClassLoader.getSystemClassLoader();

        Class<?> clz = sysClzLoader.loadClass("HelloWorld");

        System.out.println("Loader class success? " + (clz != null) + "\n");

        System.out.println("Load class finish");

        clz.newInstance();
    }

}

B. 再看看以下範例,我們使用 Class#forName 函數來加載一個類,這時會發現 Class#forName 會觸發類的初始化!


class HelloWorld {

    static {
        System.out.println("Hello World class has loaded.");
    }

}

public class LoadByReflection {

    public static void main(String[] args) throws ClassNotFoundException {
        Class.forName("HelloWorld");
    }
}

● 從上面兩個範例可以知道,JVM 加載類不等於類的初始化,這是兩個概念!只有 類被使用之時才會進行初始化

ClassLoader 加載進入 JVM 後不會立即進行初始化。類的初始化只有在需要時才會發生,即當你使用該類的實例、靜態方法、靜態變數等時

ClassLoader 負責類生命週期的前兩個步驟,不一定會有類初始化

初始化子類 & 介面

● 初始化一個類別的子類,那父類也會一起被加載進來(並且先於子類);這裡說的初始化包含創建、呼叫 Static Field、Static Method

● 接下來我們來看幾種狀況

A. 創建子類:會觸發父類加載,再加載子類


class ParentClz {
    static int tmp = 10;

    static {
        System.out.println("ParentClz static block: " + tmp);
    }

}

class SubClz extends ParentClz {

    static int tmp = 200;

    static {
        System.out.println("SubClz static block: " + tmp);
    }

}


public class PrepareStatic {
    public static void main(String[] args) {
        System.out.println("Before instance Clz");

        new SubClz();

        System.out.println("After instance Clz");
    }
}

B. 創建父類:如果 只單獨創建父類,那並不會觸發 JVM 加載子類


class ParentClz {
    static int tmp = 10;

    static {
        System.out.println("ParentClz static block: " + tmp);
    }

}


class SubClz extends ParentClz {

    static int tmp = 200;

    static {
        System.out.println("SubClz static block: " + tmp);
    }

}

public class PrepareStatic {
    public static void main(String[] args) {
        System.out.println("Before instance Clz");

        new ParentClz();

        System.out.println("After instance Clz");
    }
}

C. 類只會加載一次如果父類已經被加載,那 JVM 就不會再次加載


class ParentClz {
    static int tmp = 10;

    static {
        System.out.println("ParentClz static block: " + tmp);
    }

}


class SubClz extends ParentClz {

    static {
        System.out.println("First SubClz static block\n");
    }

}


class SubClz2 extends ParentClz {

    static {
        System.out.println("Second static block\n");
    }

}

public class PrepareStatic {
    public static void main(String[] args) {
        System.out.println("Before instance Clz");

        new SubClz();
        new SubClz2();

        System.out.println("After instance Clz");
    }
}

● 在 JVM 規範中,JVM 初始化一個類時會要求同時初始化父類,但 不會要求初始化介面

● 在初始化一個類別時,不會先初始化它所實作的介面

● 在初始化一個介面時,不會初始化它的父介面

● 只有當實際使用到介面的成員(如方法、字段)時,相關的介面才會被加載。

這可以降低複雜性、提高類加載的效率


Java ClassLoader 特點

類是透過哪個工具載入的呢?類別是透過 ClassLoader 加載,主要有幾中加載器

內建類加載器(JVM)

啟動類別加載器

擴展類別載入器

系統類載入器

● JVM 允許類別載入器(ClassLoader) 預料 某個類的載入,而順便載入,如果載入失敗,則拋出錯誤


JVM 內建類加載器

● JVM 有預設幾個內建類加載器:

類加載器特色載入類來源
Bootstrap沒有父加載器,實現依賴於底層作業系統,並且 不繼承 ClassLoader 類載入虛機的核心類別庫 (java.lang.*… 像是 Object 類)系統屬性 sun.boot.class.path
Extension父加載器為 Bootstrap放在 jre/lib/ext 目錄下的 jar 檔,會被自動加載系統屬性 java.ext.dirs
System又稱為應用類別載入器,父加載器為 Extension載入應用的類系統屬性 java.class.path 或是 classpath 屬性

加載順序如下

graph TD; Custom-->System; System-->Extension; Extenion-->Bootstrap;

Custom 是使用者可自訂的類加載器

● 加載器驗證測試範例:

Bootstrap 加載器:會加載 java 自訂的核心類,像是 Object 類


public static void main(String[] args) throws ClassNotFoundException {
    Class<?> objClz = Class.forName("java.lang.Object");

    System.out.println("Object classLoader: " + objClz.getClassLoader());
}

Bootstrap 返回的是 null

從結果看來,Bootstrap 返回的是 null,這可以安全的保護 Bootstrap 不讓其他使用者使用

父委託機制

父委託機制(也稱為 雙親委託機制),這是類加載實現的,類加載的核心概念入下:

A. 由使用者自己的類加載器,開始載入

B. 但實做載入的並非是一定是自己,而是由父加載器先嘗試加載

C. 父加載器無法加載,再由子類加載

sequenceDiagram Main->>Custom: 加載 class 文件 Custom->>Custom: 檢查緩存 Custom->>System: 沒有緩存則委託加載 System->>System: 檢查緩存 System->>Extension: 沒有緩存則委託加載 Extension->>Extension: 檢查緩存 Extension->>Bootstrap: 沒有緩存則委託加載 Bootstrap->>Extension: 嘗試加載 Extension->>System: 嘗試加載 System->>Custom: 嘗試加載 Custom->>Main: 拋出異常

上面可以看出來,這是 類加載的方式是一種經典的 遞歸結構(加載器、加載器之間是包裝關係,不是 繼承關係

所有加載器都無法加載成功的話

直到最後如果 Bootstrap 加載器仍無法加載類時,就像呼叫者拋出 ClassNotFoundException 異常

這種設計與安全性有啥關係

透過父加載器先行加載的好處是可以避免使用者(或是惡意程式),載入一個非法的類(或惡意的類),來替換調原先正常的類!

加載器特點:命名空間、執行時套件

● ClassLoader 類加載器有 兩個重要特點

A. 命名空間:有幾個重點概念

每個類別載入器都有自己的命名空間

由命名空間由該載入器、所有的父類載入器共同組成

在同一個命名空間中不會有相同的類被再次加載

如果是由不同命名空間來加載相同類,那該類就可能初始化多次(被載入多次)

B. 執行時套件

由同個類別載入器載入的屬於相同套件的類別,組成執行時套件

● 決定兩個類是否屬於同一個執行時套件,需要 比較 套件名稱類別載入器兩者要都相同才算屬於同執行套件

可以通過兩個 ClassLoader 加載 同一個 Class 文件,而文件可以被加載 2 次(因為不同類加載器,內部的緩存會分開)

這套件區分有什麼效果?

只有相同套件中的類可以存取(預設存取級別 Package)的屬性、函數;這可以限制類的存取

類加載測試

● 準備被加載的類:1. Sample.java2. Apple.java

A. Sample.java


package classLoader;

public class Sample {

    static {
        System.out.println("Sample class was loaded by " + Sample.class.getClassLoader());
    }

    public Sample() {
        System.out.println("Sample class was initialize by " + this.getClass().getClassLoader());

        new Apple();
    }

}

B. Apple.java


package classLoader;    

public class Apple {

    static {
        System.out.println("Apple class was loaded by " + Apple.class.getClassLoader());
    }

    public Apple() {
        System.out.println("Apple class was initialize by " + this.getClass().getClassLoader());
    }

}

自訂類加載器

我們來自定義一個類加載器,它的功能是讀取指定的 .class 檔,並透過 ClassLoader#defineClass 加載進記憶體中

以下假設我的 .class 檔案會存在 /Users/Hy-KylePan/IdeaProjects/JavaTest 下的三個目錄之下(代表了不同 Package)

A. lib_server 目錄

B. lib_client 目錄

C. lib_other 目錄


public class DefineClassLoader extends ClassLoader {
    private final String name;
    private static final String basePath = "/Users/Hy-KylePan/IdeaProjects/JavaTest";

    private String subPath;

    public DefineClassLoader(String name) {
        super();

        this.name = name;
    }

    public DefineClassLoader(ClassLoader parent, String name) {
        super(parent);

        this.name = name;
    }

    public void setSubPath(String subPath) {
        this.subPath = subPath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] data = loadClassAsBytes(name);

        // Converts an array of bytes into an instance of class `Class`
        return defineClass(name, data, 0, data.length);
    }

    private byte[] loadClassAsBytes(String name) throws ClassNotFoundException {
        String totalPath = basePath + subPath + "/" + name;
        String fixPath = totalPath.replace(".", "/");
        File file = new File(fixPath + ".class");

        try(FileInputStream fis = new FileInputStream(file);
            ByteArrayOutputStream baos = new ByteArrayOutputStream()) {

            int index;
            while ((index = fis.read()) != -1) {
                baos.write(index);
            }

            return baos.toByteArray();

        } catch (Exception e) {
            throw new ClassNotFoundException();
        }
    }

    @Override
    public String toString() {
        return name;
    }

    public static void main(String[] args) throws ClassNotFoundException,
            InstantiationException, IllegalAccessException {

        // 加載器 1
        DefineClassLoader serverLoader = new DefineClassLoader("serverLoader");
        serverLoader.setSubPath("/lib_server");
        serverLoader.loadClass("classLoader.Sample").newInstance();
        System.out.println();

        // 加載器 2
        // 設定 parent 加載器為 serverLoader
        DefineClassLoader clientLoader = new DefineClassLoader(serverLoader, "clientLoader");
        clientLoader.setSubPath("/lib_client");
        clientLoader.loadClass("classLoader.Sample").newInstance();
        System.out.println();

        // 加載器 3
        // 設定 parent 加載器為 null(其實代表了 BootClassLoader)
        DefineClassLoader otherLoader = new DefineClassLoader(null, "otherLoader");
        otherLoader.setSubPath("/lib_other");
        otherLoader.loadClass("classLoader.Sample").newInstance();
        System.out.println();

    }
}

待加載類 與 類加載器 之間的關係如下圖


驗證:命名空間、父委託

● 在創建完自己的類加載器後,我們首先來驗證類加載器 & 命名空間是否有關;

我們使用以下步驟

A. 編譯多個被加載類:將 Sample.javaApple.java 兩個 Java 檔分別編譯到 lib_serverlib_clientlib_other 三個資料夾之下…

讓其都存有對應的 .class 檔案


## 各別指令編譯

javac -sourcepath ./src/testClassLoader ./src/classLoader/Apple.java ./src/classLoader/Sample.java -d lib_server


javac -sourcepath ./src/testClassLoader ./src/classLoader/Apple.java ./src/classLoader/Sample.java -d lib_client


javac -sourcepath ./src/testClassLoader ./src/classLoader/Apple.java ./src/classLoader/Sample.java -d lib_other

● 其中的 .class 檔案內容都相同,只次儲存的位置不同

這可以用來驗證相同的 Class 會不會應為加載器的不同,而進行多次加載,或是只進行一次加載

B. 編譯自定義的類加載器:將 DefineClassLoader.java 編譯


javac src/classLoader/DefineClassLoader.java

C. 用 java 運行 DefineClassLoader


java -classpath ./src classLoader.DefineClassLoader

● 從結果看… 了解 JVM ClassLoader 機制的細節

JVM 方法區

● 存在兩組相同的 Sample, Apple 類,但由於是 不同類加載進行加載,所有就算相同類,也會載入 2 次

serverLoader & otherLoader 會加載類

A. serverLoader 加載:由於委託 BootClassLoader 後仍無法加載目標類,所以再退回 serverLoader 讓它加載,所以緩存會在 serverLoader 這個類加載器中!

BootClass 只加載系統類

graph BT; serverLoader-->BootClassLoader; BootClassLoader-->serverLoader 待加載類-->serverLoader;

B. clientLoader 不會加載,因為 serverLoader 已經加載過,所以會使用 serverLoader 的緩存

C. otherLoader 加載:它的父類加載器為 BootClassLoader(但它無法加載目標類),所以退回到 otherLoader 加載;

graph BT; otherLoader-->BootClassLoader; serverLoader-->BootClassLoader; BootClassLoader-->otherLoader 待加載類-->otherLoader;

BootClass 只加載系統類

● 而儘管加載的目標相同(Sample, Apple 類),但類加載器 otherLoaderserverLoader 是不同命名空間,所以 仍會加載兩次

這會造成觸發兩次類的初始化

● 由於 clientLoader 加載時受委託機制限制,所以會先去尋找 severLoader 類加載器,而 severLoader 已經加載,所以不會再方法區再建構

JVM 堆區

● 在讀取類資料方法區時,會同時在堆區建立對應的 Class 對象

這是否代表堆區中有兩個相同的 Class 呢

不會!這是觀察角度問題,以堆區的角度來看 仍是只有一個 Class(也就是我們取得的 Class 物件);但如果以 類加載器來看則是不同 Class 所以會載入兩次

● 不同類別載入器的命名空間還有以下特點

A. 同一個命名空間中的類別是相互可見的

B. 子加載器可以看見父類別的命名空間! 但父載入器的類別,不能看見子類別載入的類別

● 因為子加載器別會有自己的緩存,而在檢查自己的緩存前會先請父加載器檢查它的緩存(反向卻不行);

● 所以可以當作子加載器可見父加載器的類別,又由於類的可見,所以可以確定父、子加載器是相同空間

驗證:執行套件

● 接著我們來驗證,假設 serverLoader 加載器加載成功後,otherLoader 是否可以使用它已經加載的類;

我們使用以下步驟

A. 手動移除 otherLoader 讀取 lib_other/classLoader 資料夾內的內容;移除後 otherLoader 就讀取不到指定類


rm -rf lib_other/classLoader/*

B. 用 java 運行 DefineClassLoader


java -classpath ./src classLoader.DefineClassLoader

● 從解果看來

就算是內容相同的 class 檔案,由不同 ClassLoader 加載,也不能在 JVM 中共享;所以會拋出 ClassNotFound 異常

破解命名空間:反射

● 我們知道命名空間可以隔離不同類加載器加載的類之間相互訪問,但是我們 可以使用反射來取得類的實體(取得堆區的 Class 物件)

反向搜尋

堆區 來找到 方法區,所以就可以忽略 ClassLoader 的加載問題!(但前提是類已經被加載進來)


類的卸載

回收時機:當類在堆區的 Class 不再被參考使用,並且 GC 的可達性分析也無法觸及時,Class 物件就會被回收,並結束類的生命週期;並且類結束生命週期後

● 堆區資料被回收

● 方法區資料也被回收

至於 ClassLoader 是否會被回收

如果是內建(核心)的 ClassLoader 是隨 JVM 生命週期同生共死的(我們自己建立的 ClassLoader 則跟一般類相同)

測試:類加載器、類卸載

● 由於內建類加載器不會被卸載,所以我們這邊使用上面自定義的類加載器(DefineClassLoader),並透過設置、主動 GC 來觸發回收,查看類是否在方法區被卸載並重新創建


public static void main(String[] args) throws ClassNotFoundException,
            InstantiationException, IllegalAccessException, InterruptedException {

    // 創建第一個類加載
    DefineClassLoader serverLoader = new DefineClassLoader("serverLoader");
    serverLoader.setSubPath("/lib_server");
    Class<?> sClz = serverLoader.loadClass("classLoader.Sample");
    Object obj = sClz.newInstance();
    // 查看類在方法區的位置
    System.out.println("ClassLoader, Sample clz hashcode :" + obj.getClass().hashCode());
    // 將原有的強引用設置為 null,讓其離開可達性分析
    serverLoader = null;
    sClz = null;
    obj = null;

    // 手動觸發 GC
    System.gc();
    Thread.sleep(1000);
    System.out.println();

    // 創建第二個類加載
    serverLoader = new DefineClassLoader("serverLoader");
    serverLoader.setSubPath("/lib_server");
    sClz = serverLoader.loadClass("classLoader.Sample");
    obj = sClz.newInstance();
    // 查看類在方法區的位置
    System.out.println("ClassLoader recreate, and Sample clz hashcode :" + obj.getClass().hashCode());

}

從結果看來,類確實類重新加載到堆區了!(Class 物件的 hashcode 不同)


補充

URLClassLoader

● 在 JDK 中的 java.net 套件中,有提供一個 URLClassLoader,它可以從網路上下載一個類,並將其加載進方法區、堆區;以下寫一個使用範例

● 我們可以將一個 class 上傳到雲空間,這裡將上面的 Apple.class 傳到 github 空間


public class UseUrlClassLoader {

    public static void main(String[] args) throws MalformedURLException,
            ClassNotFoundException, InstantiationException, IllegalAccessException {
        URL url = new URL("https://github.com/KylePanHy/MyTest/raw/main/Apple.class");

        URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{
            url
        });

        Class<?> clz = urlClassLoader.loadClass("classLoader.Apple");

        Object obj = clz.newInstance();

        System.out.println("Class from url, clz name: " + obj.getClass().getName() + ", hashCode: " + obj.hashCode());
    }

}

更多的 Java 語言相關文章

Java 語言深入

● 在這個系列中,我們全方位地探討了 Java 語言的各個核心主題,旨在幫助你徹底掌握這門強大的編程語言。無論你是想深入理解 Java 的基礎類型與變數作用域,還是探索異常處理與運算子的細節,這些文章都將為您提供寶貴的知識

深入 Java 物件導向

● 探索 Java 物件導向的奧妙,掌握介面、抽象類、繼承等重要概念!幫助你針對物件導向設計有更深入的了解!


Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

發表迴響