Java 的類別載入機制是 JVM 執行 Java 程式碼的第一步。理解類別載入過程,對於解決 ClassNotFoundException、理解框架原理、實現熱部署等場景至關重要。

類別載入的三個階段#

Java 類別的載入過程分為三個主要階段:載入(Loading)連結(Linking)初始化(Initialization)

flowchart LR
    subgraph Loading["載入 Loading"]
        L1[讀取位元組碼]
    end

    subgraph Linking["連結 Linking"]
        direction LR
        V[驗證] --> P[準備] --> R[解析]
    end

    subgraph Init["初始化 Initialization"]
        I1[執行 clinit]
    end

    Loading --> Linking --> Init

1. 載入(Loading)#

載入階段負責查找並讀取類別的位元組碼。

// JVM 在載入階段會執行以下操作:
// 1. 透過類別的全限定名獲取位元組流
// 2. 將位元組流轉換為方法區的執行時資料結構
// 3. 在堆積區建立 java.lang.Class 物件作為存取入口

類別的位元組碼可以來自多種來源:本地 .class 檔案、JAR/WAR 套件、網路、動態生成(如動態代理)等。

2. 連結(Linking)#

連結階段分為三個子階段:

驗證(Verification)#

驗證位元組碼的正確性與安全性,確保不會危害 JVM。

驗證內容:
├── 檔案格式驗證:魔數 0xCAFEBABE、版本號等
├── 中繼資料驗證:語義分析,如是否有父類別
├── 位元組碼驗證:資料流與控制流分析
└── 符號參照驗證:確保解析能正常執行

準備(Preparation)#

類別變數(static 變數)分配記憶體並設定初始值

public class Example {
    // 準備階段:value = 0(int 的預設值)
    // 初始化階段:value = 123
    public static int value = 123;

    // 準備階段:CONSTANT = 100(final 常數直接賦值)
    public static final int CONSTANT = 100;
}

準備階段只處理類別變數(static),不處理實體變數。實體變數會在物件實體化時隨物件一起分配在堆積區。

各資料型別的初始值
資料型別初始值
byte(byte) 0
short(short) 0
int0
long0L
float0.0f
double0.0d
char'\u0000'
booleanfalse
參照型別null

解析(Resolution)#

將常數池中的符號參照替換為直接參照

// 編譯期:使用符號參照
// "java/lang/String" (字串形式的類別名)

// 解析後:使用直接參照
// 指向方法區中 String 類別資料的指標

3. 初始化(Initialization)#

執行類別的初始化程式碼,包括靜態變數賦值和靜態區塊。

public class InitExample {
    static int a = 10;           // 靜態變數賦值
    static {
        System.out.println("靜態區塊執行");
        a = 20;
    }

    // <clinit> 方法會依序執行:
    // 1. a = 10
    // 2. 執行靜態區塊,a = 20
}

JVM 會保證 <clinit>() 方法在多執行緒環境下被正確地加鎖同步,因此靜態內部類別單例模式是執行緒安全的。

類別初始化的觸發時機#

JVM 規範定義了以下六種情況會觸發類別初始化:

// 1. new、getstatic、putstatic、invokestatic 指令
MyClass obj = new MyClass();           // new 指令
int value = MyClass.staticValue;       // getstatic 指令
MyClass.staticValue = 100;             // putstatic 指令
MyClass.staticMethod();                // invokestatic 指令

// 2. 反射呼叫
Class.forName("com.example.MyClass");

// 3. 初始化子類別時,父類別尚未初始化
class Parent {}
class Child extends Parent {}          // 初始化 Child 會先初始化 Parent

// 4. JVM 啟動時的主類別
public static void main(String[] args) // 包含 main 方法的類別

// 5. MethodHandle 解析結果為 REF_getStatic 等

// 6. 介面定義了 default 方法實作類別初始化時
不會觸發初始化的情況
// 1. 透過子類別存取父類別的靜態欄位,只會初始化父類別
class Parent {
    static int value = 100;
}
class Child extends Parent {}
System.out.println(Child.value);  // 不會初始化 Child

// 2. 陣列定義
Parent[] array = new Parent[10];  // 不會初始化 Parent

// 3. 常數在編譯期進入常數池,不會觸發類別初始化
public static final String CONSTANT = "hello";
System.out.println(MyClass.CONSTANT);  // 不會初始化 MyClass

雙親委派模型#

類別載入器層級#

graph TD
    B["Bootstrap ClassLoader<br/>啟動類別載入器<br/><i>JAVA_HOME/lib</i>"]
    E["Extension ClassLoader<br/>擴展類別載入器<br/><i>JAVA_HOME/lib/ext</i>"]
    A["Application ClassLoader<br/>應用程式類別載入器<br/><i>classpath</i>"]
    C["Custom ClassLoader<br/>自訂類別載入器"]

    B --> E --> A --> C

    style B fill:#e1f5fe
    style E fill:#fff3e0
    style A fill:#e8f5e9
    style C fill:#fce4ec

從 Java 9 開始,Extension ClassLoader 被 Platform ClassLoader(平台類別載入器)取代,但雙親委派的核心機制保持不變。

雙親委派機制#

// ClassLoader.loadClass() 的簡化邏輯
protected Class<?> loadClass(String name, boolean resolve) {
    // 1. 檢查類別是否已載入
    Class<?> c = findLoadedClass(name);

    if (c == null) {
        // 2. 委派給父類別載入器
        if (parent != null) {
            c = parent.loadClass(name, false);
        } else {
            // 到達 Bootstrap ClassLoader
            c = findBootstrapClassOrNull(name);
        }

        // 3. 父類別載入器無法載入,自己嘗試載入
        if (c == null) {
            c = findClass(name);
        }
    }
    return c;
}
flowchart TB
    REQ["請求載入<br/>com.example.MyClass"] --> APP

    subgraph Delegate["委派流程"]
        direction TB
        APP["Application ClassLoader"] -->|委派| EXT["Extension ClassLoader"]
        EXT -->|委派| BOOT["Bootstrap ClassLoader"]
    end

    subgraph Return["返回流程"]
        direction TB
        BOOT2["Bootstrap ClassLoader"] -->|找不到| EXT2["Extension ClassLoader"]
        EXT2 -->|找不到| APP2["Application ClassLoader"]
        APP2 -->|自己載入| FOUND["載入成功"]
    end

    BOOT -.-> BOOT2

    style Delegate fill:#e3f2fd
    style Return fill:#fff9c4
    style FOUND fill:#c8e6c9

雙親委派的優點#

雙親委派模型確保了 Java 核心類別庫的安全性與一致性。例如,無論哪個類別載入器要載入 java.lang.Object,最終都會委派給 Bootstrap ClassLoader,保證所有環境下 Object 類別都是同一個。

// 嘗試自訂一個 java.lang.String 類別?
package java.lang;
public class String {
    // 這個類別永遠不會被載入
    // 因為會優先委派給 Bootstrap ClassLoader
    // Bootstrap ClassLoader 會載入 JDK 的 String
}

打破雙親委派#

某些場景需要打破雙親委派機制:

1. SPI 機制#

// JDBC 驅動載入是經典案例
// DriverManager 在 rt.jar 中,由 Bootstrap ClassLoader 載入
// 但具體的驅動(如 MySQL Driver)在 classpath 中

// 使用執行緒上下文類別載入器(Thread Context ClassLoader)解決
Thread.currentThread().setContextClassLoader(classLoader);
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);

2. 熱部署#

// 實現熱部署需要自訂類別載入器
public class HotSwapClassLoader extends ClassLoader {
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // 特定類別不委派,直接自己載入
        if (name.startsWith("com.myapp.hotswap")) {
            return findClass(name);
        }
        return super.loadClass(name);
    }

    @Override
    protected Class<?> findClass(String name) {
        // 從指定位置讀取最新的 class 檔案
        byte[] classData = loadClassData(name);
        return defineClass(name, classData, 0, classData.length);
    }
}

3. OSGi 模組化#

OSGi 的網狀類別載入結構:

    ┌─────────┐     ┌─────────┐     ┌─────────┐
    │ Bundle A │ ←→ │ Bundle B │ ←→ │ Bundle C │
    └─────────┘     └─────────┘     └─────────┘
         ↑               ↑               ↑
         └───────────────┴───────────────┘
                ┌────────▼────────┐
                │  System Bundle  │
                └─────────────────┘

自訂類別載入器#

public class MyClassLoader extends ClassLoader {

    private String classPath;

    public MyClassLoader(String classPath) {
        this.classPath = classPath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            // 將類別名轉換為檔案路徑
            String fileName = classPath + File.separator
                + name.replace(".", File.separator) + ".class";

            // 讀取位元組碼
            byte[] classData = Files.readAllBytes(Paths.get(fileName));

            // 定義類別
            return defineClass(name, classData, 0, classData.length);

        } catch (IOException e) {
            throw new ClassNotFoundException(name, e);
        }
    }
}

// 使用自訂類別載入器
MyClassLoader loader = new MyClassLoader("/path/to/classes");
Class<?> clazz = loader.loadClass("com.example.MyClass");
Object instance = clazz.getDeclaredConstructor().newInstance();

AppCDS 應用程式類別資料共享#

從 Java 10 開始,AppCDS(Application Class-Data Sharing)可以顯著加速應用程式啟動時間,特別適合微服務和容器化部署。

# 步驟 1:建立要共享的類別清單
java -Xshare:off -XX:+UseAppCDS \
     -XX:DumpLoadedClassList=classes.lst \
     -cp myapp.jar com.example.Main

# 步驟 2:建立共享檔案
java -Xshare:dump -XX:+UseAppCDS \
     -XX:SharedClassListFile=classes.lst \
     -XX:SharedArchiveFile=app-cds.jsa \
     -cp myapp.jar

# 步驟 3:使用共享檔案啟動
java -Xshare:on -XX:+UseAppCDS \
     -XX:SharedArchiveFile=app-cds.jsa \
     -cp myapp.jar com.example.Main
AppCDS 效果評估

在典型的 Spring Boot 應用中,AppCDS 可以:

  • 減少 10-30% 的啟動時間
  • 降低多個 JVM 實體的記憶體佔用(共享唯讀資料)
  • 特別適合 Kubernetes 中多副本部署的場景

測試指令:

# 不使用 AppCDS
time java -jar myapp.jar &
# 等待啟動完成後記錄時間

# 使用 AppCDS
time java -Xshare:on -XX:SharedArchiveFile=app-cds.jsa -jar myapp.jar &
# 比較啟動時間

常見問題與排查#

ClassNotFoundException vs NoClassDefFoundError#

// ClassNotFoundException:執行時找不到類別
// 通常是 Class.forName() 或 ClassLoader.loadClass() 時發生
try {
    Class.forName("com.example.NotExist");
} catch (ClassNotFoundException e) {
    // 類別路徑中沒有這個類別
}

// NoClassDefFoundError:編譯時存在,執行時不存在
// 或者類別初始化失敗
public class Example {
    // 如果 DependencyClass 在執行時不存在
    // 會拋出 NoClassDefFoundError
    private DependencyClass dep = new DependencyClass();
}

類別載入相關的 JVM 參數#

# 查看類別載入過程
-verbose:class
-XX:+TraceClassLoading
-XX:+TraceClassUnloading

# 指定類別路徑
-classpath / -cp
-Xbootclasspath

# 類別資料共享
-Xshare:on/off/auto
-XX:SharedArchiveFile=<file>

總結#

類別載入機制的核心要點:

  1. 三階段:載入 → 連結(驗證、準備、解析)→ 初始化
  2. 雙親委派:保證核心類別安全,避免重複載入
  3. 觸發時機:new、靜態存取、反射、子類別初始化等
  4. 打破委派:SPI、熱部署、模組化等場景需要
  5. 實用技巧:AppCDS 可加速啟動,自訂載入器實現特殊需求