關鍵結論:兩個類只有由同一個類加載器加載,且全限定名相同時,才被視為「同一個類」。
即使字節碼完全相同,若由不同加載器加載,instanceof、equals()、Class.cast() 均判定為不同類型。
public class ClassLoaderTest {
@Test
public void testClassIdentity() throws Exception {
// 自定義加載器加載同一個類文件
ClassLoader cl1 = new CustomClassLoader("D:/classes");
ClassLoader cl2 = new CustomClassLoader("D:/classes");
Class> clazz1 = cl1.loadClass("com.demo.User");
Class> clazz2 = cl2.loadClass("com.demo.User");
Object obj1 = clazz1.getDeclaredConstructor().newInstance();
System.out.println(clazz1 == clazz2); // false
System.out.println(obj1 instanceof com.demo.User); // false!
System.out.println(clazz1.getClassLoader() == clazz2.getClassLoader()); // false
}
}
應用場景:Tomcat 隔離 Web 應用、OSGi 模組化、熱部署、代碼加密。
請求載入類
↓
檢查是否已加載 (緩存)
↓
委派父加載器載入
↓ (父無法載入)
自身嘗試載入 (findClass)
↓
定義類 (defineClass)
↓
返回 Class 對象
| 加載器 | 實現語言 | 載入範圍 | 獲取方式 |
|---|---|---|---|
| 啟動類加載器 | C++ | JAVA_HOME/jre/lib/rt.jar 核心庫 | ClassLoader.getSystemClassLoader().getParent().getParent() (通常為 null) |
| 擴展類加載器 | Java | JAVA_HOME/jre/lib/ext/*.jar | ClassLoader.getSystemClassLoader().getParent() |
| 應用類加載器 | Java | CLASSPATH (-cp/classpath) | ClassLoader.getSystemClassLoader() |
| 自定義類加載器 | Java | 開發者指定路徑/網絡/資料庫 | 繼承 ClassLoader 實現 |
java.lang.String)無法被惡意代碼替換| 場景 | 打破方式 | 目的 |
|---|---|---|
| Tomcat WebappClassLoader | 優先自行載入 Web 應用類,再委派父加載器 | 隔離不同 Web 應用的同名類庫版本 |
| JDBC/JNDI/SPI | 線程上下文類加載器 | 基礎設施代碼載入應用提供的實現類 |
| 熱部署/熱加載 | 銷毀舊加載器,建立新加載器重新載入 | 不重啟 JVM 更新類字節碼 |
| OSGi | 網狀委派模型,按包導入導出精確控制 | 模組化、多版本共存、動態安裝卸載 |
public class PathClassLoader extends ClassLoader {
private final String classPath;
private final String targetPackage = "com.hotusm";
private final Map> cache = new ConcurrentHashMap<>();
public PathClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class> findClass(String name) throws ClassNotFoundException {
if (name.startsWith(targetPackage)) {
return loadClassFromFile(name);
}
return super.findClass(name); // 委派父加載器
}
private Class> loadClassFromFile(String name) throws ClassNotFoundException {
// 雙重檢查鎖 + 緩存
Class> clazz = cache.get(name);
if (clazz != null) return clazz;
synchronized (name.intern()) { // 鎖字串常量池對象
clazz = cache.get(name);
if (clazz != null) return clazz;
byte[] classData = readClassFile(name);
if (classData == null) throw new ClassNotFoundException(name);
clazz = defineClass(name, classData, 0, classData.length);
cache.put(name, clazz);
return clazz;
}
}
private byte[] readClassFile(String name) {
String path = classPath + File.separator + name.replace('.', File.separatorChar) + ".class";
try (FileInputStream fis = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[4096];
int len;
while ((len = fis.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
return baos.toByteArray();
} catch (IOException e) {
return null;
}
}
}
findClass 中讀取加密文件 → 解密 → defineClass// 簡易熱加載核心邏輯
public void reload() {
// 1. 建立新加載器
CustomClassLoader newLoader = new CustomClassLoader(classPath);
// 2. 協助舊實例遷移狀態(序列化/反序列化、字段拷貝)
// 3. 替換引用:新請求使用新加載器載入的類
// 4. 舊加載器無引用後可被 GC 回收(需無強引用持有)
}
難點:狀態遷移、靜態欄位重置、線程安全、類身份改變導致的 instanceof 失效。
每個插件擁有獨立 ClassLoader,實現依賴版本隔離(如插件 A 用 Guava 28,插件 B 用 Guava 20)。
| 坑點 | 解決方案 |
|---|---|
| findClass vs loadClass 混淆 | 重寫 findClass(),不要重寫 loadClass()(除非要打破委派) |
| 字節碼讀取不全 | 使用 ByteArrayOutputStream 循環讀取,不要用 available() 分配緩衝區 |
| defineClass 重複調用 | 同一加載器對同一名稱只能 defineClass 一次,需緩存 |
| 資源文件加載 | 重寫 getResourceAsStream() 支援加載同目錄下的配置/屬性文件 |
| 線程上下文類加載器 | SPI 场景用 Thread.currentThread().getContextClassLoader() |
核心心法:類加載器是 JVM 動態性的基石。掌握雙親委派的「守正」與打破委派的「出奇」,能解決隔離、熱更新、加密、模組化等高階架構難題。實戰中優先考慮成熟方案(Spring DevTools、JRebel、OSGi、Arthas),自研加載器作為最後手段。