Appearance
第37课:类加载机制
🎯 学习目标
- 理解类加载的 5 个阶段(加载→验证→准备→解析→初始化)
- 掌握三种类加载器与双亲委派模型
- 理解为什么用双亲委派、何时破坏它(Tomcat/JDBC SPI)
- 能自定义类加载器
- 知道类初始化的触发时机
📖 一、概念讲解:类加载是什么
1. 类加载 vs C 的链接
C 程序编译后链接成可执行文件,运行时整体加载。Java 的类是按需加载——用到某类时才加载它的 .class 到 JVM,转成方法区/元空间的 Class 对象。
java
// 第一次用到 Person,触发加载
Person p = new Person();2. 类加载的 5 个阶段
.class 文件
↓ 加载:读取字节码,生成方法区的 Class 对象
↓ 验证:校验字节码合法性(格式/元数据/字节码/符号引用)
↓ 准备:为静态变量分配内存并设默认值(如 int=0)
↓ 解析:常量池的符号引用 → 直接引用(按需,可延迟)
↓ 初始化:执行 static 变量赋值 + static 块(clinit)
Class 对象可用关键:准备阶段给 static 变量默认值(int=0),初始化阶段才执行赋值语句。所以:
java
static int x = 5;
// 准备:x=0;初始化:x=5📖 二、三种类加载器
Bootstrap ClassLoader(启动类加载器,C++实现)
↓ 加载 JDK 核心类(java.lang.* 等,rt.jar/jmods)
Extension ClassLoader(扩展类加载器,Java实现)
↓ 加载 JDK 扩展(ext 目录,JDK9+ 改为 PlatformClassLoader)
Application ClassLoader(应用类加载器)
↓ 加载 classpath 下的类(用户代码)
自定义 ClassLoader层级关系:Bootstrap 是父,Extension 是子,Application 是 Extension 的子。注意"父子"是组合关系(parent 字段),不是继承。
java
ClassLoader cl = String.class.getClassLoader(); // null(Bootstrap,C++实现返回 null)
ClassLoader app = Sol37.class.getClassLoader(); // AppClassLoader
System.out.println(cl); // null
System.out.println(app); // jdk.internal.loader.ClassLoaders$AppClassLoader@...📖 三、双亲委派模型
加载类时,先委托父加载器加载,父加载不了(找不到)才自己加载:
ApplicationClassLoader.loadClass("X")
→ 委托 ExtensionClassLoader
→ 委托 BootstrapClassLoader
← Bootstrap 找不到(X 不是核心类)
← Extension 找不到
ApplicationClassLoader 自己加载(classpath 找到 X)为什么要双亲委派
- 安全:用户写个
java.lang.String不会被加载(Bootstrap 先加载核心 String),防止核心类被篡改。 - 唯一性:同一类只被加载一次(由发起加载的层级决定),避免重复加载导致类型不一致(
String由 Bootstrap 加载,全局唯一)。
loadClass 源码逻辑
java
protected Class<?> loadClass(String name, boolean resolve) {
// 1. 检查是否已加载
Class<?> c = findLoadedClass(name);
if (c == null) {
// 2. 委托父加载器
try { c = parent.loadClass(name, false); }
catch (ClassNotFoundException e) { }
// 3. 父加载不了,自己 findClass
if (c == null) c = findClass(name);
}
return c;
}📖 四、自定义类加载器
继承 ClassLoader,重写 findClass(不要重写 loadClass,否则破坏双亲委派):
java
class MyClassLoader extends ClassLoader {
private String classPath;
public MyClassLoader(String classPath, ClassLoader parent) {
super(parent); // 指定父加载器,保持双亲委派
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] data = loadClassData(name); // 自定义读取字节码
return defineClass(name, data, 0, data.length); // 定义类
}
private byte[] loadClassData(String name) { /* 读 .class 文件 */ }
}用途:从网络/加密文件/非标准路径加载类、热部署、类隔离(Tomcat 每个 webapp 独立 ClassLoader)。
📖 五、破坏双亲委派
1. Tomcat(每个 webapp 独立 ClassLoader)
Tomcat 的 WebappClassLoader 先自己加载(覆盖 loadClass),再委派父——破坏双亲委派。原因:不同 webapp 可能用不同版本的同一库,需隔离。
2. JDBC SPI(线程上下文类加载器)
JDBC 的 DriverManager 在 java.sql(Bootstrap 加载),但要加载厂商的 Driver(如 MySQL,classpath)。Bootstrap 看不到 classpath 的类。解法:用线程上下文类加载器(TCCL),让父加载器能"反向"用子加载器加载。
java
ClassLoader tccl = Thread.currentThread().getContextClassLoader();
Class<?> driverClass = Class.forName("com.mysql.cj.jdbc.Driver", true, tccl);这是双亲委派"被破坏"的经典场景——核心类加载器需要加载应用类。
3. OSGi / 模块化
OSGi 每个模块独立 ClassLoader,网状委托。Java 9 模块系统用 ModuleLayer 替代部分场景。
⚠️ 六、常见陷阱
陷阱1:混淆准备与初始化
准备阶段 static 变量是默认值(0/null),初始化才赋值。static final 常量在准备阶段就赋值(ConstantValue 属性)。
陷阱2:类初始化触发条件不清
以下触发初始化:new 实例、访问静态字段(非 final 常量)、调用静态方法、反射、子类初始化触发父类初始化。不触发:引用常量、通过数组定义类、调用 ClassLoader.loadClass(只加载不初始化,Class.forName 默认初始化)。
陷阱3:重写 loadClass 破坏双亲委派
重写 loadClass 会绕过委派,一般应重写 findClass 保持委派。除非明确要隔离(Tomcat)。
陷阱4:内存泄漏
自定义 ClassLoader 不被回收 → 它加载的类不被卸载 → 元空间泄漏。注意 ClassLoader 生命周期。
陷阱5:JDK 9 模块系统
JDK 9 后 ExtensionClassLoader 变 PlatformClassLoader,且模块封装使反射访问受限。
🆚 七、Java vs C 对比
| 特性 | C | Java |
|---|---|---|
| 加载 | 编译链接成可执行,整体加载 | 按需加载 .class |
| 动态加载 | dlopen/dlsym | ClassLoader |
| 类隔离 | 多进程 | 多 ClassLoader |
| 链接校验 | 编译期 | 运行期验证 |
对 C 程序员:Java 的类加载类似 C 的 dlopen(动态加载),但更精细——按需、分阶段、有双亲委派保证安全和唯一性。理解双亲委派是看懂 Tomcat/Spring 热部署、JDBC 的关键。
💡 八、最佳实践
- 自定义加载器重写 findClass,保持双亲委派(除非需隔离)。
- 用 Class.forName(name) 加载并初始化;
ClassLoader.loadClass只加载。 - 理解初始化触发,避免意外触发或遗漏。
- TCCL 处理核心类加载应用类(JDBC 等 SPI 场景)。
- JDK 9+ 注意模块封装,反射加 --add-opens。
📝 练习预告
完成 练习/Ex37_ClassLoader.java 中的 6 道题:
- 获取类加载器(String vs 自定义类)
- 双亲委派观察(父子关系)
- Class.forName vs ClassLoader.loadClass(是否初始化)
- 类初始化触发条件
- 自定义类加载器
- 综合:隔离加载同名类(破坏双亲委派)
完成后对比 答案/Sol37.java,查看逐行讲解与多解法。
📖 九、类加载阶段
类加载不是一步完成,而是多个阶段:
text
加载 Loading
验证 Verification
准备 Preparation
解析 Resolution
初始化 Initialization
使用 Using
卸载 Unloading重点:
text
加载:读取 class 字节流,生成 Class 对象。
验证:确保字节码安全、格式正确。
准备:为静态变量分配内存并设置默认值。
解析:把符号引用转成直接引用。
初始化:执行静态变量赋值和 static 代码块。示例:
java
static int value = 10;准备阶段 value 是 0,初始化阶段才变成 10。
📖 十、双亲委派模型
类加载器收到加载请求时,先交给父加载器:
text
BootstrapClassLoader
↓
PlatformClassLoader
↓
AppClassLoader
↓
自定义 ClassLoader这样做的好处:
text
避免核心类被篡改。
保证类的唯一性。
让基础类库优先由上层加载器加载。例如你自己写一个 java.lang.String,正常情况下不会替换 JDK 的 String。
📖 十一、类隔离与热部署
同一个 .class 文件,如果由不同 ClassLoader 加载,JVM 认为是不同类。
text
类的身份 = 类全名 + 加载它的 ClassLoader这解释了:
text
Tomcat 每个 Web 应用使用不同类加载器。
插件系统可以隔离不同插件依赖。
热部署通过丢弃旧 ClassLoader 释放旧类。类无法卸载通常是因为旧 ClassLoader 仍被引用。
🧪 十二、实战案例:Class.forName vs loadClass
java
ClassLoader loader = Thread.currentThread().getContextClassLoader();
Class<?> c1 = loader.loadClass("com.example.Demo");
Class<?> c2 = Class.forName("com.example.Demo");区别:
text
loadClass 默认只加载,不主动初始化。
Class.forName 默认加载并初始化。如果类中有静态代码块,可以观察输出差异。
📌 十三、排查建议
类加载问题常见于:
text
NoClassDefFoundError
ClassNotFoundException
ClassCastException 但类名相同
SPI 找不到实现
JDBC Driver 无法加载
热部署后内存泄漏排查时要看:
text
类是否在 classpath。
由哪个 ClassLoader 加载。
是否存在多个版本 jar。
线程上下文类加载器是否正确。
模块系统是否限制访问。🔍 十四、自测问题
text
类加载有哪些阶段?
准备阶段和初始化阶段有什么区别?
双亲委派解决什么问题?
为什么同名类由不同 ClassLoader 加载后不是同一个类?
Class.forName 和 loadClass 有什么区别?
线程上下文类加载器解决什么问题?
Tomcat 为什么需要 WebAppClassLoader?
类卸载为什么依赖 ClassLoader 可回收?🧭 十五、类加载排查流程
遇到类加载问题时,先不要直接加依赖,按下面顺序排查:
text
异常是 ClassNotFoundException 还是 NoClassDefFoundError?
类在编译期存在,还是运行期缺失?
依赖 jar 是否进入最终 classpath?
是否存在同一个类的多个版本?
当前类由哪个 ClassLoader 加载?
线程上下文类加载器是否正确?
是否运行在 Tomcat、Spring Boot fat jar、插件容器或模块系统中?几个典型异常的区别:
| 异常 | 含义 | 常见原因 |
|---|---|---|
ClassNotFoundException | 主动加载类失败 | 类名错误、依赖缺失 |
NoClassDefFoundError | 编译见过,运行找不到 | 部署包缺依赖、初始化失败 |
ClassCastException 同名类 | 类名一样但加载器不同 | 插件隔离、重复 jar |
LinkageError | 链接阶段冲突 | 版本不兼容、重复定义 |
🧪 十六、案例:插件隔离加载
插件系统常希望不同插件使用不同版本依赖,因此会给每个插件单独 ClassLoader。
示意结构:
text
AppClassLoader
├── PluginClassLoader(plugin-a)
└── PluginClassLoader(plugin-b)每个插件加载自己的类:
java
ClassLoader pluginLoader = new URLClassLoader(
new URL[]{pluginJar.toUri().toURL()},
Thread.currentThread().getContextClassLoader()
);
Class<?> pluginClass = pluginLoader.loadClass("com.example.PluginImpl");
Object plugin = pluginClass.getDeclaredConstructor().newInstance();注意事项:
text
公共接口应该由父加载器加载。
插件实现由插件加载器加载。
插件卸载时必须断开线程、缓存、静态变量对插件类的引用。
否则 ClassLoader 无法回收,元空间会泄漏。常见错误:
text
接口 jar 同时放在主程序和插件中,导致类型不相等。
插件线程没有停止,线程上下文类加载器仍引用插件加载器。
静态缓存保存插件类或实例。
日志框架、驱动管理器保存插件类引用。🛠 十七、定位类由谁加载
可以在代码中打印加载器:
java
System.out.println(User.class.getClassLoader());
System.out.println(Thread.currentThread().getContextClassLoader());也可以使用 JVM 参数观察:
bash
# JDK 8
-XX:+TraceClassLoading
# JDK 9+
-Xlog:class+load=info排查重复 jar 时重点看:
text
类实际从哪个 jar 加载。
是否有多个版本同时存在。
父加载器是否先加载了错误版本。
容器是否修改了委派顺序。✅ 十八、掌握标准
学完本课后,应能做到:
text
能说清加载、验证、准备、解析、初始化的顺序。
能解释 static 字段在准备和初始化阶段的不同值。
能画出 Bootstrap、Platform、AppClassLoader 的关系。
能解释双亲委派解决的安全和唯一性问题。
能说明 Tomcat 为什么需要破坏默认委派。
能区分 Class.forName 和 ClassLoader.loadClass。
能定位 ClassNotFoundException 与 NoClassDefFoundError。
能解释 ClassLoader 泄漏为什么会导致元空间上涨。类加载机制是理解 Spring Boot fat jar、Tomcat 隔离、JDBC SPI、插件系统和热部署的共同基础。
🎓 下一步
- 第38课:字节码 — javap、字节码指令、ASM、CGLIB