Skip to content

第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)

为什么要双亲委派

  1. 安全:用户写个 java.lang.String 不会被加载(Bootstrap 先加载核心 String),防止核心类被篡改。
  2. 唯一性:同一类只被加载一次(由发起加载的层级决定),避免重复加载导致类型不一致(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 对比

特性CJava
加载编译链接成可执行,整体加载按需加载 .class
动态加载dlopen/dlsymClassLoader
类隔离多进程多 ClassLoader
链接校验编译期运行期验证

对 C 程序员:Java 的类加载类似 C 的 dlopen(动态加载),但更精细——按需、分阶段、有双亲委派保证安全和唯一性。理解双亲委派是看懂 Tomcat/Spring 热部署、JDBC 的关键。


💡 八、最佳实践

  1. 自定义加载器重写 findClass,保持双亲委派(除非需隔离)。
  2. 用 Class.forName(name) 加载并初始化;ClassLoader.loadClass 只加载。
  3. 理解初始化触发,避免意外触发或遗漏。
  4. TCCL 处理核心类加载应用类(JDBC 等 SPI 场景)。
  5. JDK 9+ 注意模块封装,反射加 --add-opens。

📝 练习预告

完成 练习/Ex37_ClassLoader.java 中的 6 道题:

  1. 获取类加载器(String vs 自定义类)
  2. 双亲委派观察(父子关系)
  3. Class.forName vs ClassLoader.loadClass(是否初始化)
  4. 类初始化触发条件
  5. 自定义类加载器
  6. 综合:隔离加载同名类(破坏双亲委派)

完成后对比 答案/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