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

GC 篇讲的是”对象怎么回收”,本文讲的是”类怎么进来”——一个 .class 文件从磁盘上的字节流,到内存中可以被 new 出来的 Class 对象,中间要经历什么?为什么 Tomcat 能在同一台机器上部署两个不同版本的 Spring?为什么 ClassNotFoundException 和 NoClassDefFoundError 长得像却完全是两回事?这一切的答案都藏在类加载机制里。本文把加载过程、双亲委派、破坏双亲委派的经典案例、自定义类加载器一次讲透。

一、类的生命周期:从字节流到可用对象

一个类的完整生命周期是:加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载。前五个阶段合起来就是”类加载过程”,其中验证、准备、解析又合称连接(Linking)。

注意:加载、验证、准备、初始化这四个阶段的开始顺序是确定的,但不要求同步完成——验证、准备、解析常常交叉进行(解析可以在初始化之后才做,这是 Java 支持运行时绑定的基础)。

1.1 加载(Loading)阶段做什么

加载阶段只做三件事:

  1. 通过全限定名获取字节流:从 jar、网络、动态代理生成,甚至 JSP 编译产物中来——来源不限,这是自定义类加载器的发挥空间;
  2. 解析为方法区的运行时数据结构;
  3. 在堆中生成一个 java.lang.Class 对象,作为方法区这个类的访问入口。

1.2 验证与准备

验证:确保字节流符合 JVM 规范,防止恶意构造的字节码搞垮虚拟机。包括文件格式验证(魔数 0xCAFEBABE)、元数据验证、字节码验证、符号引用验证四步。

准备:为类的静态变量分配内存并赋”零值”,注意还不是代码里写的值:

1
2
3
4
5
6
public static int value = 123;
// 准备阶段:value = 0
// 初始化阶段:value = 123

public static final int CONST = 123;
// 编译期常量,准备阶段直接 = 123(ConstantValue 属性)
变量类型 准备阶段的值 原因
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
2
3
4
5
6
7
8
9
10
11
12
class Parent {
public static String A = "parent";
static { System.out.println("Parent init"); }
}
class Child extends Parent {
public static String B = "child";
static { System.out.println("Child init"); }
}

Parent p = new Child(); // 打印 Parent init、Child init
System.out.println(Child.A); // 只打印 Parent init!静态字段只有直接定义它的类才被初始化
MyClass[] arr = new MyClass[10]; // 不打印任何初始化信息,数组类 [LMyClass 由 JVM 直接生成

二、类加载器与双亲委派模型

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
2
3
4
System.out.println(String.class.getClassLoader());      // null(Bootstrap)
System.out.println(ClassLoader.getPlatformClassLoader()); // platform
System.out.println(Main.class.getClassLoader()); // AppClassLoader
System.out.println(Main.class.getClassLoader().getParent()); // platform

2.2 双亲委派:工作流程与设计动机

双亲委派的核心逻辑一句话:收到类加载请求,先一路向上委托给父加载器,父加载器反馈无法完成时,才由自己加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ClassLoader.loadClass 的核心逻辑(简化)
protected Class<?> loadClass(String name, boolean resolve) {
synchronized (getClassLoadingLock(name)) {
// 1. 查缓存,已加载的直接返回
Class<?> c = findLoadedClass(name);
if (c == null) {
if (parent != null) {
c = parent.loadClass(name, false); // 2. 先委托父加载器
} else {
c = findBootstrapClassOrNull(name); // 3. 没有父了,找 Bootstrap
}
if (c == null) {
c = findClass(name); // 4. 父加载不了,自己加载
}
}
return c;
}
}

双亲委派不是强制约束,而是 ClassLoader.loadClass() 默认实现推荐的模式。它解决两个问题:① 避免重复加载——同一个类只会被同一个加载器加载一次,保证全 JVM 中类的唯一性;② 安全——你自己写一个 java.lang.String 也无法替换核心类,因为请求永远先到 Bootstrap,核心版本永远优先。

记住一个推论:类的相等性 = 全限定名相同 + 定义类加载器相同。同一个 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
2
3
4
5
// DriverManager 内部(简化)
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
// ServiceLoader.load 内部:
// ClassLoader cl = Thread.currentThread().getContextClassLoader();
// 用线程上下文加载器去加载 META-INF/services 里注册的实现

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class CryptoClassLoader extends ClassLoader {
private final String classDir;

public CryptoClassLoader(String classDir, ClassLoader parent) {
super(parent); // 保留双亲委派
this.classDir = classDir;
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] data = loadClassData(name);
if (data == null) throw new ClassNotFoundException(name);
// defineClass 是加载阶段的核心:字节流 → Class 对象
return defineClass(name, data, 0, data.length);
}

private byte[] loadClassData(String name) {
String path = classDir + File.separator
+ name.replace('.', File.separatorChar) + ".class";
try (InputStream in = new FileInputStream(path);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[4096];
int len;
while ((len = in.read(buf)) != -1) out.write(buf, 0, len);
return out.toByteArray();
} catch (IOException e) {
return null;
}
}
}

要点:覆写 findClass() 而不是 loadClass()——loadClass 负责双亲委派流程,findClass 负责真正的”找字节码”。只有要主动破坏委派(如 Tomcat、热部署)时才覆写 loadClass。

4.2 热部署:重新加载一个”新”的类

1
2
3
4
5
6
public static Class<?> hotSwap(String classDir, String className) throws Exception {
// 每次热部署都 new 一个新的加载器
CryptoClassLoader loader = new CryptoClassLoader(classDir,
CryptoClassLoader.class.getClassLoader());
return loader.loadClass(className);
}

热部署生效的前提是类能被卸载,而类卸载的三个条件极其苛刻:

  1. 该类所有实例都已被 GC;
  2. 加载该类的 ClassLoader 实例已被 GC;
  3. 对应的 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
2
3
4
5
6
7
8
9
10
11
12
// 一个隐蔽的 NoClassDefFoundError 现场
public class Config {
static {
if (System.getenv("ENV") == null) {
throw new IllegalStateException("ENV not set"); // 首次初始化失败
}
}
}

// 第一次使用:ExceptionInInitializerError(真实原因)
Config c = new Config();
// 之后所有使用:NoClassDefFoundError(只报"找不到",不再重试初始化)

六、避坑清单

  1. 静态变量赋值分两步走:准备阶段是零值,初始化阶段才是代码值——单例模式饿汉式写 private static Singleton s = new Singleton() 时,构造器里不要依赖另一个还没赋值的静态变量。
  2. <clinit>() 只执行一次且失败不重试:静态块里别写可能抛异常的代码(连不上数据库、读不到配置),否则整个类永久”报废”,报的还是误导性的 NoClassDefFoundError。
  3. 判断两个类是否相同,除了全限定名还要看类加载器:跨加载器的 Class 强转会抛 ClassCastException,Tomcat 多应用部署时最容易遇到。
  4. 自定义类加载器优先覆写 findClass,别一上来就覆写 loadClass 破坏委派,除非真的要做隔离或热部署。
  5. SPI 场景理解线程上下文类加载器:写框架代码要加载用户实现类时,用 Thread.currentThread().getContextClassLoader(),而不是 getClass().getClassLoader()。
  6. 热部署必须检查类卸载三条件:Metaspace 持续上涨往往是旧 ClassLoader 没被 GC,用 jmap 查 ClassLoader 实例数。
  7. 依赖冲突统一用 maven-enforcer-plugin 阻断:NoSuchMethodError 九成是版本冲突,与其靠经验排查不如构建期直接失败。
  8. 不要用自定义类加载器加载 java.*:Bootstrap 优先,写也白写;而且以 java. 开头的类名会被 JVM 直接拒绝(SecurityException)。

JVM GC 篇 + 本文,运行时的两大主线——“类怎么进来”和”对象怎么出去”——就齐了。下一篇计划聊聊 JVM 内存分配与逃逸分析,把对象创建的另一面补上。