JVM 类加载机制深度解析:加载过程、双亲委派与自定义类加载器

JVM 类加载机制深度解析:加载过程、双亲委派与自定义类加载器
神经蛙GC 篇讲的是”对象怎么回收”,本文讲的是”类怎么进来”——一个 .class 文件从磁盘上的字节流,到内存中可以被 new 出来的 Class 对象,中间要经历什么?为什么 Tomcat 能在同一台机器上部署两个不同版本的 Spring?为什么 ClassNotFoundException 和 NoClassDefFoundError 长得像却完全是两回事?这一切的答案都藏在类加载机制里。本文把加载过程、双亲委派、破坏双亲委派的经典案例、自定义类加载器一次讲透。
一、类的生命周期:从字节流到可用对象
一个类的完整生命周期是:加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载。前五个阶段合起来就是”类加载过程”,其中验证、准备、解析又合称连接(Linking)。
1.1 加载(Loading)阶段做什么
加载阶段只做三件事:
- 通过全限定名获取字节流:从 jar、网络、动态代理生成,甚至 JSP 编译产物中来——来源不限,这是自定义类加载器的发挥空间;
- 解析为方法区的运行时数据结构;
- 在堆中生成一个
java.lang.Class对象,作为方法区这个类的访问入口。
1.2 验证与准备
验证:确保字节流符合 JVM 规范,防止恶意构造的字节码搞垮虚拟机。包括文件格式验证(魔数 0xCAFEBABE)、元数据验证、字节码验证、符号引用验证四步。
准备:为类的静态变量分配内存并赋”零值”,注意还不是代码里写的值:
1 | public static int value = 123; |
| 变量类型 | 准备阶段的值 | 原因 |
|---|---|---|
static int value = 123 |
0 | 赋值动作在 <clinit>() 中执行 |
static final int CONST = 123 |
123 | 编译期常量,有 ConstantValue 属性 |
static String s = "abc" |
null | 引用类型零值是 null |
1.3 初始化:<clinit>() 与线程安全
初始化阶段执行编译器收集而来的 <clinit>() 方法——它是静态变量赋值语句 + 静态代码块按源码顺序合并而成。几个关键规则:
<clinit>()与类的构造器(<init>())不同,JVM 保证它只被执行一次且加锁——这也是”静态内部类单例模式”线程安全的原理;- 父类的
<clinit>()保证先于子类执行; - 接口的
<clinit>()不需要先执行父接口的,只有真正用到父接口的变量时才触发; - 如果
<clinit>()抛异常,这个类会被标记为错误状态,之后再引用只会抛NoClassDefFoundError,不会重试初始化(生产上常见的坑)。
1.4 什么时候才会初始化:主动引用 vs 被动引用
JVM 规范只规定了六种主动引用会触发初始化,其余都是被动引用(不触发):
| 主动引用(触发初始化) | 被动引用(不触发) |
|---|---|
new、getstatic、putstatic、invokestatic 四条字节码指令 |
通过子类引用父类的静态字段(只初始化父类) |
反射调用(Class.forName()) |
定义数组:MyClass[] arr = new MyClass[10] |
| 初始化子类时父类还没初始化 | 引用编译期常量(已进常量池) |
| JVM 启动的主类(main 所在类) | —— |
MethodHandle 句柄对应的类 |
—— |
| 接口含 default 方法时实现类初始化 | —— |
1 | class Parent { |
二、类加载器与双亲委派模型
2.1 四层类加载器
| 类加载器 | 实现语言 | 负责加载 | 备注 |
|---|---|---|---|
| Bootstrap ClassLoader | C++(JVM 自身) | JAVA_HOME/lib 核心类库,如 java.lang.* |
JVM 中没有对应对象,String.class.getClassLoader() 返回 null |
| Extension / Platform ClassLoader | Java | lib/ext 或 JDK 9 模块平台部分 |
JDK 9 起改名为 Platform |
| Application ClassLoader | Java | classpath 上的类,即我们自己写的类 | ClassLoader.getSystemClassLoader() |
| 自定义 ClassLoader | Java | 特定来源:加密文件、网络、热部署 | 继承 ClassLoader,覆写 findClass() |
1 | System.out.println(String.class.getClassLoader()); // null(Bootstrap) |
2.2 双亲委派:工作流程与设计动机
双亲委派的核心逻辑一句话:收到类加载请求,先一路向上委托给父加载器,父加载器反馈无法完成时,才由自己加载。
1 | // ClassLoader.loadClass 的核心逻辑(简化) |
记住一个推论:类的相等性 = 全限定名相同 + 定义类加载器相同。同一个 class 文件被两个加载器各加载一次,得到的是两个不相等的 Class,互相赋值会抛 ClassCastException。
三、破坏双亲委派:SPI、Tomcat 与热部署
3.1 线程上下文类加载器:JDBC 的破局
双亲委派有个天然矛盾:核心库(Bootstrap 加载)需要回调用户代码(Application 加载)。典型就是 JDBC——java.sql.DriverManager 在 rt.jar 里由 Bootstrap 加载,但具体的 com.mysql.cj.jdbc.Driver 在用户 classpath 上,Bootstrap 根本”看”不到。
解法是线程上下文类加载器(Thread Context ClassLoader):JDK 提供一个后门,让父加载器可以”借用”当前线程持有的加载器(默认是 Application ClassLoader)去加载 SPI 实现类——这是父类加载器请求子类加载器完成加载,双亲委派被破坏:
1 | // DriverManager 内部(简化) |
3.2 Tomcat:隔离优先
Tomcat 的 WebappClassLoader 则是另一种破坏:先自己加载,加载不到再委托父加载器。这是有意为之——每个 Web 应用要用独立的 WebappClassLoader,实现:
- 依赖隔离:A 应用用 Spring 5,B 应用用 Spring 6,互不干扰;
- 热部署:卸载旧的 WebappClassLoader 再建新的,整个应用的类全部换血,无需重启 Tomcat。
3.3 JDK 9 模块化之后
JPMS 引入后,”三层加载器”变成 Bootstrap + Platform + Application,加载逻辑有所变化(部分类由 Bootstrap 直接从模块加载),但双亲委派的整体骨架依然保留,自定义类加载器的写法没有变化。
四、自定义类加载器与热部署实战
4.1 标准写法:只覆写 findClass
1 | public class CryptoClassLoader extends ClassLoader { |
4.2 热部署:重新加载一个”新”的类
1 | public static Class<?> hotSwap(String classDir, String className) throws Exception { |
热部署生效的前提是类能被卸载,而类卸载的三个条件极其苛刻:
- 该类所有实例都已被 GC;
- 加载该类的 ClassLoader 实例已被 GC;
- 对应的
Class对象没有任何地方引用。
所以热部署的正确姿势是:换掉加载器 → 丢掉旧引用 → 让 GC 完成类的卸载。只要有一处静态变量或 ThreadLocal 还抓着旧 Class,旧类就永远卸不掉,Metaspace 就会持续增长。
五、高频问题排查:三个”找不到类”的错误
| 错误 | 含义 | 典型原因 | 排查方向 |
|---|---|---|---|
ClassNotFoundException |
受查异常,显式加载时找不到 | Class.forName() 路径写错、依赖没打进 jar |
检查 classpath、 fat jar 是否包含该类 |
NoClassDefFoundError |
Error,编译期存在、运行期缺失 | 依赖 scope 配成 provided、<clinit>() 抛过异常、冲突 jar 被裁剪 |
看首次异常日志里是否有 ExceptionInInitializerError |
NoSuchMethodError |
运行期方法缺失 | jar 包冲突,编译时的版本和运行时加载的版本不一致 | mvn dependency:tree 找冲突,排除旧版本 |
1 | // 一个隐蔽的 NoClassDefFoundError 现场 |
六、避坑清单
- 静态变量赋值分两步走:准备阶段是零值,初始化阶段才是代码值——单例模式饿汉式写
private static Singleton s = new Singleton()时,构造器里不要依赖另一个还没赋值的静态变量。 <clinit>()只执行一次且失败不重试:静态块里别写可能抛异常的代码(连不上数据库、读不到配置),否则整个类永久”报废”,报的还是误导性的NoClassDefFoundError。- 判断两个类是否相同,除了全限定名还要看类加载器:跨加载器的 Class 强转会抛
ClassCastException,Tomcat 多应用部署时最容易遇到。 - 自定义类加载器优先覆写
findClass,别一上来就覆写loadClass破坏委派,除非真的要做隔离或热部署。 - SPI 场景理解线程上下文类加载器:写框架代码要加载用户实现类时,用
Thread.currentThread().getContextClassLoader(),而不是getClass().getClassLoader()。 - 热部署必须检查类卸载三条件:Metaspace 持续上涨往往是旧 ClassLoader 没被 GC,用 jmap 查
ClassLoader实例数。 - 依赖冲突统一用 maven-enforcer-plugin 阻断:
NoSuchMethodError九成是版本冲突,与其靠经验排查不如构建期直接失败。 - 不要用自定义类加载器加载
java.*:Bootstrap 优先,写也白写;而且以java.开头的类名会被 JVM 直接拒绝(SecurityException)。
JVM GC 篇 + 本文,运行时的两大主线——“类怎么进来”和”对象怎么出去”——就齐了。下一篇计划聊聊 JVM 内存分配与逃逸分析,把对象创建的另一面补上。
















