類別載入器是 Java 語言的創新基石,也是 Java 流行的關鍵原因之一。它讓 Java 類別能動態載入至 JVM 並執行。從 JDK 1.0 為 Applet 遠端載入類別而生,到如今廣泛應用於 Web 容器、OSGi、模組化框架,類別載入器無處不在。雖然多數開發者無需直接打交道,但一旦遇到 ClassNotFoundException、NoClassDefFoundError 等棘手問題,若不懂其機制,往往耗費大量時間除錯。本文系統梳理類別載入器核心概念、工作機制、進階特性與自訂開發實戰。
類別載入器負責讀取 Java 位元碼(.class 檔案),轉換為 java.lang.Class 實例。每個 Class 實例代表一個 Java 類別,透過 newInstance() 可建立物件。位元碼來源多樣:本地檔案、網路下載、工具動態生成等。
所有類別載入器皆為 java.lang.ClassLoader 實例。其核心方法如下:
| 方法 | 說明 |
|---|---|
getParent() | 回傳父類別載入器 |
loadClass(String name) | 載入指定名稱類別,回傳 Class 實例(啟動載入流程) |
findClass(String name) | 查找指定名稱類別(自訂載入邏輯的核心覆寫點) |
findLoadedClass(String name) | 查找已載入類別 |
defineClass(byte[] b, ...) | 將位元碼轉換為 Class 實例(final 方法,不可覆寫) |
resolveClass(Class> c) | 連結指定類別 |
類別名稱參數為二進制名稱(如內部類 com.example.Outer$Inner)。
JVM 內建三大系統載入器(父子關係由下往上):
rt.jar 等),原生碼實現,非 ClassLoader 子類別。jre/lib/ext),父載入器為啟動載入器。CLASSPATH 載入應用類別,父載入器為擴展載入器。可透過 ClassLoader.getSystemClassLoader() 取得。開發者自訂載入器繼承 ClassLoader,其父載入器通常為系統載入器。所有載入器透過 getParent() 串聯成樹狀結構,根節點為啟動載入器。
public class ClassLoaderTree {
public static void main(String[] args) {
ClassLoader loader = ClassLoaderTree.class.getClassLoader();
while (loader != null) {
System.out.println(loader.toString());
loader = loader.getParent();
}
}
}
典型輸出:
sun.misc.Launcher$AppClassLoader@9304b1 (系統載入器)
sun.misc.Launcher$ExtClassLoader@190d11 (擴展載入器)
// 啟動載入器無 Java 表示,getParent() 回傳 null
當類別載入器收到載入請求時,先委派給父載入器,父載入器再委派其父載入器,層層上遞至啟動載入器。只有父載入器無法完成(找不到類別)時,子載入器才嘗試自行載入(呼叫 findClass())。
核心動機是保障 Java 核心庫型別安全。JVM 判定兩個類別是否相同,不僅看完整類別名,還看定義該類別的載入器是否相同。同樣位元碼若被不同載入器載入,得到的 Class 實例互不相同,物件互相賦值會拋出 ClassCastException。
範例驗證:兩個 FileSystemClassLoader 實例分別載入同一 com.example.Sample 類別,產生兩個 Class 實例,反射呼叫 setSample(obj2) 時拋出 ClassCastException: com.example.Sample cannot be cast to com.example.Sample。
雙親委派確保 java.lang.Object 等核心類別統一由啟動載入器載入,全應用程式共享同一版本,避免型別衝突。
defineClass() 產生 Class 實例的載入器。JVM 判定類別同一性時用此載入器。loadClass() 請求的載入器。loadClass(name) → 檢查快取(findLoadedClass)→ 已載入直接返回。loadClass()。findClass(name) 查找位元碼。defineClass() 轉換為 Class 實例。快取特性:同一載入器實例對同名類別只載入一次,loadClass 不會重複調用。loadClass() 拋 ClassNotFoundException;defineClass() 拋 NoClassDefFoundError。
JDK 1.2 引入 Thread.setContextClassLoader() / getContextClassLoader()。預設主線程的上下文載入器為系統載入器,子線程繼承父線程。
Java 核心庫提供 SPI 介面(JDBC、JNDI、JAXP 等),實現類由第三方 JAR 提供(系統載入器載入)。但 SPI 介面類由啟動載入器載入,啟動載入器無法委派給子載入器(方向相反),導致無法載入實現類。
線程上下文載入器恰好解決此問題:SPI 程式碼改用 Thread.currentThread().getContextClassLoader() 載入實現類,因預設為系統載入器,能找到第三方 JAR 中的實現。
Class.forName(String className):等同 forName(className, true, currentLoader),載入並初始化類別(執行 static 區塊)。Class.forName(String name, boolean initialize, ClassLoader loader):指定是否初始化、指定載入器。常見用法:Class.forName("org.apache.derby.jdbc.EmbeddedDriver").newInstance() 載入資料庫驅動(觸發驅動類別靜態區塊向 DriverManager 註冊)。
ClassLoader.loadClass() 封裝了雙親委派邏輯。為確保委派模式正確,自訂載入器應覆寫 findClass(),由父類別 loadClass() 在適當時機調用。
public class FileSystemClassLoader extends ClassLoader {
private String rootDir;
public FileSystemClassLoader(String rootDir) {
this.rootDir = rootDir;
}
@Override
protected Class> findClass(String name) throws ClassNotFoundException {
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}
return defineClass(name, classData, 0, classData.length);
}
private byte[] getClassData(String className) {
String path = classNameToPath(className);
try (InputStream ins = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[4096];
int bytesNumRead;
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
private String classNameToPath(String className) {
return rootDir + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
}
}
從伺服器下載 .class 位元碼並定義類別。客戶端無法直接引用伺服器類別(本地載入器找不到),兩種使用方式:
Tomcat 等 Web 容器為每個 Web 應用建立專屬載入器,委派順序相反:優先嘗試自行載入,找不到再委派給父載入器。這是 Servlet 規範推薦做法,確保 Web 應用類別優先於容器類別。例外:Java 核心庫類別仍由父載入器載入,保障型別安全。
WEB-INF/classes,JAR 放 WEB-INF/lib。OSGi 是 Java 動態模組系統,每個 Bundle 擁有獨立載入器,實現「隱藏內部包、匯出特定包、匯入依賴包」。
java.*)→ 委派父載入器(啟動載入器)。org.osgi.framework.bootdelegation 強制指定包由父載入器載入。此機制允許同一類別不同版本共存 JVM,極大提升模組化靈活性。但也帶來第三方庫使用挑戰,建議:
Bundle-ClassPath 宣告。Thread.currentThread().getContextClassLoader()。類別載入器實現了動態安裝、更新軟體組件。遇到 ClassNotFoundException、NoClassDefFoundError 時,請檢查:
class.getClassLoader())。Thread.currentThread().getContextClassLoader())。開發自訂載入器時,務必與現有載入器樹狀結構協調,遵循雙親委派,僅覆寫 findClass(),善用線程上下文載入器解決 SPI 載入難題。