引言

当你在代码中写下 new ArrayList<>() 时,JVM 需要先找到 ArrayList.class 的字节码,将其加载到内存中,验证其合法性,解析符号引用,并最终完成初始化——这一切都发生在对象创建之前。这个看似简单的过程背后,是一套精密的类加载机制(Class Loading Mechanism)。理解这一机制,是深入 JVM 和掌握高级 Java 开发的必经之路。

类加载的生命周期

一个 Java 类从被加载到 JVM 中,到最终被卸载,其生命周期包含七个阶段:

1
加载(Loading) → 验证(Verification) → 准备(Preparation) → 解析(Resolution) → 初始化(Initialization) → 使用(Using) → 卸载(Unloading)

其中,验证、准备、解析三个环节合称为链接(Linking)

加载(Loading)

加载阶段完成三件事:

  1. 通过类的全限定名获取定义该类的二进制字节流
  2. 将字节流代表的静态存储结构转换为方法区的运行时数据结构
  3. 在堆内存中生成代表该类的 java.lang.Class 对象,作为方法区中该类数据的外部访问入口

字节流的来源非常灵活——可以是本地 class 文件、JAR 包、网络传输,甚至是运行时动态生成(如动态代理)。

验证(Verification)

验证阶段确保字节码符合 JVM 规范,不会危害 JVM 自身安全。包括文件格式验证、元数据验证、字节码验证和符号引用验证四层检查。这也是类加载中最耗时的阶段。

准备(Preparation)

准备阶段为类的静态变量分配内存并设置零值(而非代码中的赋值)。例如:

1
static int value = 123;

在准备阶段,value 被赋值为 0(而不是 123)。123 的赋值操作会在初始化阶段执行。但 static final 常量(编译期常量)除外,它在准备阶段即完成赋值。

解析(Resolution)

解析阶段将常量池内的符号引用替换为直接引用。符号引用用字符串描述目标(如 java/lang/Object),而直接引用是指向目标的指针或偏移量。

初始化(Initialization)

初始化阶段是类加载过程的最后一步,执行 <clinit>() 方法——这是由编译器自动收集类中所有静态变量的赋值动作和静态代码块合并产生的。

1
2
3
4
5
6
7
class Example {
static int a = 1;
static {
a = 2;
}
}
// <clinit>() 按顺序包含 a=1 和 a=2

JVM 保证一个类的 <clinit>() 在多线程环境下只会被执行一次,且加锁安全执行。

类加载器层次结构

Java 从 JDK 1.2 开始引入了一套双亲委派模型,标准的三层类加载器结构如下:

1
2
3
4
5
6
7
Bootstrap ClassLoader (启动类加载器)

Platform ClassLoader (平台类加载器,Java 9+;原 Extension ClassLoader)

Application ClassLoader (应用程序类加载器)

User Custom ClassLoader (用户自定义类加载器)

Bootstrap ClassLoader

由 C++ 实现,是 JVM 的一部分。负责加载 <JAVA_HOME>/lib 目录下的核心类库(rt.jartools.jar,或 Java 9+ 的模块文件)。它不是 ClassLoader 的子类,在 Java 代码中通过 getClassLoader() 获取时会返回 null

Platform ClassLoader(Java 8 为 Extension ClassLoader)

Java 8 及之前称为 Extension ClassLoader,加载 <JAVA_HOME>/lib/ext 目录下的 jar。Java 9 之后升级为 Platform ClassLoader,加载 Java SE 平台模块。

Application ClassLoader

也叫系统类加载器(System ClassLoader),由 sun.misc.Launcher$AppClassLoader 实现。负责加载用户类路径(CLASSPATH)上指定的类库。

双亲委派模型

双亲委派模型的核心机制是:当一个类加载器收到类加载请求时,它首先将请求委派给父加载器,只有当父加载器无法加载时,才尝试自己加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ClassLoader.loadClass() 的精简逻辑
protected Class<?> loadClass(String name, boolean resolve) {
synchronized (getClassLoadingLock(name)) {
// 1. 检查是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
// 2. 委托给父加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
// 3. 父加载器加载不到,自己加载
if (c == null) {
c = findClass(name);
}
}
return c;
}
}

为什么需要双亲委派?

安全性是首要原因。假如没有双亲委派,用户完全可以编写一个 java.lang.String 类放在 classpath 下,替换掉 JDK 提供的 String 类——这会导致严重的安全漏洞。双亲委派保证了核心类库始终由 Bootstrap ClassLoader 加载,用户自行编写的同名类不会被使用。

避免重复加载是另一重收益。父加载器加载过的类,子加载器无需重新加载。

打破双亲委派模型

线程上下文类加载器(Thread Context ClassLoader)

Java SPI(Service Provider Interface)机制是打破双亲委派的典型案例。以 JDBC 为例:java.sql.DriverManager 的接口定义在核心库中(由 Bootstrap ClassLoader 加载),但具体的数据库驱动实现(如 MySQL Connector)在 classpath 下(由 Application ClassLoader 加载)。根据双亲委派原则,Bootstrap ClassLoader 加载的类无法向下访问子加载器的类。

解决方案是线程上下文类加载器

1
2
3
4
// JDBC DriverManager 中的加载逻辑
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
// ServiceLoader.load() 内部使用线程上下文类加载器
Thread.currentThread().getContextClassLoader();

通过获取当前线程的上下文类加载器(默认为 Application ClassLoader),核心库可以”看见”用户路径下的实现类。这是一种”父委托子”的反向操作。

自定义类加载器

继承 ClassLoader 并重写 findClass() 方法是实现自定义类加载器的标准方式:

1
2
3
4
5
6
7
8
9
public class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) {
// 1. 从特定来源(网络/数据库/加密文件)获取字节码
byte[] bytes = loadClassData(name);
// 2. 将字节码转换为 Class 对象
return defineClass(name, bytes, 0, bytes.length);
}
}

注意:应当重写 findClass() 而非 loadClass()loadClass() 实现了双亲委派逻辑,重写它会破坏这一机制。

热部署与类卸载

在开发环境或应用服务器中,类卸载是实现热部署的基础。一个类被卸载必须满足三个条件:

  1. 该类的所有实例都已被 GC
  2. 该类的 Class 对象不再被引用
  3. 加载该类的 ClassLoader 已被 GC

因此,实现热部署的常用方法是:创建一个新的 ClassLoader 实例来加载新版本的类,同时确保旧的 ClassLoader 和它加载的类失去所有引用。

Tomcat 的 WebappClassLoader 就是这一模式的经典应用——每个 Web 应用使用独立的类加载器,卸载应用时只需丢弃其类加载器。

常见问题

ClassNotFoundException vs NoClassDefFoundError

  • ClassNotFoundException:编译时类存在,运行时通过 Class.forName()loadClass() 找不到类的定义。通常由缺少 JAR 包或类名拼写错误引起。
  • NoClassDefFoundError:编译时类存在,运行时该类在静态初始化块中抛出异常或者类文件版本不兼容,导致 JVM 无法完成该类的定义。它是 Error 的子类,通常意味着环境问题。
1
2
3
4
// 抛出 ClassNotFoundException
Class.forName("com.mysql.jdbc.Driver");

// 如果 Driver 类在初始化时抛出异常,后续使用时会抛出 NoClassDefFoundError

结语

Java 类加载机制是 JVM 安全模型的基石,也是实现模块化、热部署、OSGi 容器等高级特性的基础。理解双亲委派模型不仅有助于日常开发中排错,更能在设计框架和中间件时做出正确的架构决策。当你下次遇到 ClassNotFoundException 时,不妨停下来想一想:这个类到底应该由哪个类加载器加载?